Czy źle jest pisać obiektowo C? [Zamknięte]

14

Wydaje mi się, że zawsze piszę kod w C, który jest głównie zorientowany obiektowo, więc powiedzmy, że mam plik źródłowy lub coś, co utworzę strukturę, a następnie przekażę wskaźnik do tej struktury do funkcji (metod) należących do tej struktury:

struct foo {
    int x;
};

struct foo* createFoo(); // mallocs foo

void destroyFoo(struct foo* foo); // frees foo and its things

Czy to zła praktyka? Jak nauczyć się pisać „we właściwy sposób”.

mosmo
źródło
10
Duża część Linuksa (jądra) jest napisana w ten sposób, w rzeczywistości emuluje nawet więcej koncepcji podobnych do OO, takich jak wirtualna metoda wysyłania. Uważam to za całkiem właściwe.
Kilian Foth
13
[T] ustalił, że prawdziwy programista może pisać programy FORTRAN w dowolnym języku. ” - Ed Post, 1983
Ross Patterson
4
Czy jest jakiś powód, dla którego nie chcesz przejść na C ++? Nie musisz używać części, które ci się nie podobają.
svick
5
To naprawdę nasuwa pytanie: „Co to jest„ obiektowe ”? Nie nazwałbym tego obiektem zorientowanym. Powiedziałbym, że to proceduralne. (Nie masz dziedziczenia, polimorfizmu, enkapsulacji / zdolności do ukrywania stanu i prawdopodobnie brakuje ci innych cech OO, które nie schodzą mi z głowy.) To, czy to dobra, czy zła praktyka, nie zależy od tych semantyków , chociaż.
jpmc26
3
@ jpmc26: Jeśli jesteś preskrypcjonistą lingwistycznym, powinieneś posłuchać Alana Kaya, który wymyślił ten termin, może powiedzieć, co to znaczy, i mówi, że w OOP chodzi o przesyłanie wiadomości . Jeśli jesteś deskryptorem lingwistycznym, zbadaj użycie tego terminu w społeczności programistów. Cook dokładnie to zrobił, przeanalizował cechy języków, które albo twierdzą, albo są uważane za OO, i stwierdził, że mają one jedną wspólną cechę: Wiadomości .
Jörg W Mittag

Odpowiedzi:

24

Nie, to nie jest zła praktyka, nawet się do tego zachęca, chociaż można nawet stosować konwencje takie jak struct foo *foo_new();ivoid foo_free(struct foo *foo);

Oczywiście, jak mówi komentarz, rób to tylko w razie potrzeby. Nie ma sensu używać konstruktora dla int.

Przedrostek foo_jest konwencją, po której następuje wiele bibliotek, ponieważ chroni przed kolizją z nazywaniem innych bibliotek. Inne funkcje często używają konwencji foo_<function>(struct foo *foo, <parameters>);. To pozwala struct foobyć nieprzezroczystym typem.

Zajrzyj do dokumentacji libcurl dla konwencji, szczególnie z „przestrzeniami nazw”, aby wywoływanie funkcji curl_multi_*wyglądało niepoprawnie na pierwszy rzut oka, gdy pierwszy parametr został zwrócony curl_easy_init().

Są jeszcze bardziej ogólne podejścia, patrz Programowanie obiektowe z ANSI-C

Pozostałość
źródło
11
Zawsze z zastrzeżeniem „w stosownych przypadkach”. OOP nie jest srebrną kulą.
Deduplicator
Czy C nie ma przestrzeni nazw, w których można zadeklarować te funkcje? Podobnie jak std::stringnie mogłeś foo::create? Nie używam C. Może to tylko w C ++?
Chris Cirefice
@ChrisCirefice W C nie ma przestrzeni nazw, dlatego wielu autorów bibliotek używa prefiksów do swoich funkcji.
Residuum
2

Nie jest źle, jest doskonale. Object Oriented Programming jest dobre (chyba, że się ponieść, to może mieć za dużo tego dobrego). C nie jest najodpowiedniejszym językiem dla OOP, ale nie powinno to powstrzymywać Cię przed wykorzystaniem tego, co najlepsze.

gnasher729
źródło
4
Nie, żebym się nie zgadzał, ale wasza opinia powinna być poparta jakimś rozwinięciem.
Deduplicator
1

Nie jest źle. Zaleca stosowanie RAII, które zapobiega wielu błędom (wycieki pamięci, użycie niezainicjowanych zmiennych, użycie po zwolnieniu itp., Co może powodować problemy z bezpieczeństwem).

Tak więc, jeśli chcesz skompilować kod tylko za pomocą GCC lub Clang (a nie kompilatora MS), możesz użyć cleanupatrybutu, który odpowiednio zniszczy twoje obiekty. Jeśli zadeklarujesz swój obiekt w ten sposób:

my_str __attribute__((cleanup(my_str_destructor))) ptr;

Następnie my_str_destructor(ptr)zostanie uruchomiony, gdy ptr wykracza poza zakres. Pamiętaj tylko, że nie można go używać z argumentami funkcji .

Pamiętaj również, aby używać my_str_w nazwach metod, ponieważ Cnie ma przestrzeni nazw i łatwo jest zderzyć się z inną nazwą funkcji.

Marqin
źródło
2
Afaik, RAII polega na wykorzystaniu niejawnego wywołania destruktorów dla obiektów w C ++ w celu zapewnienia czyszczenia, unikając potrzeby dodawania jawnych wywołań uwalniania zasobów. Tak więc, jeśli się nie mylę, RAII i C wykluczają się wzajemnie.
cmaster
@cmaster Jeśli użyjesz #defineswoich nazw typów, __attribute__((cleanup(my_str_destructor)))otrzymasz je domyślnie w całym #definezakresie (zostanie dodane do wszystkich deklaracji zmiennych).
Marqin
Działa to, jeśli a) używasz gcc, b) jeśli używasz typu tylko w lokalnych zmiennych funkcyjnych, oraz c) jeśli używasz typu tylko w czystej wersji (bez wskaźników do #definetypu d lub tablic). W skrócie: To nie jest standardowy C i płacisz z dużą elastycznością w użyciu.
cmaster
Jak wspomniano w mojej odpowiedzi, działa to również w clang.
Marqin
Ach, nie zauważyłem tego. To rzeczywiście sprawia, że ​​wymaganie a) jest znacznie mniej surowe, ponieważ czyni to __attribute__((cleanup()))prawie quasi-standard. Jednak b) ic) nadal stoją ...
cmaster - przywróć Monikę
-2

Kod ten może mieć wiele zalet, ale niestety nie napisano standardu C, aby to ułatwić. Kompilatory w przeszłości oferowały skuteczne gwarancje behawioralne, wykraczające poza to, co wymagał standard, co pozwoliło na napisanie takiego kodu znacznie czystiej niż jest to możliwe w standardzie C, ale ostatnio kompilatory zaczęły odwoływać takie gwarancje w imię optymalizacji.

Co najważniejsze, wiele kompilatorów C ma historycznie gwarancję (na podstawie projektu, jeśli nie dokumentację), że jeśli dwa typy struktur zawierają tę samą sekwencję początkową, można użyć wskaźnika do dowolnego typu, aby uzyskać dostęp do elementów tej wspólnej sekwencji, nawet jeśli typy nie są ze sobą powiązane, a ponadto, że do celów ustanowienia wspólnej sekwencji początkowej wszystkie wskaźniki do struktur są równoważne. Kod, który wykorzystuje takie zachowanie, może być znacznie bardziej przejrzysty i bezpieczniejszy dla typu niż kod, który tego nie robi, ale niestety, mimo że Standard wymaga, aby struktury dzielące wspólną sekwencję początkową musiały być ułożone w ten sam sposób, zabrania kodowi faktycznego używania wskaźnik jednego typu, aby uzyskać dostęp do początkowej sekwencji innego.

W związku z tym, jeśli chcesz napisać obiektowy kod w C, musisz zdecydować (i powinieneś podjąć tę decyzję wcześniej), aby albo przeskoczyć przez wiele obręczy, aby przestrzegać reguł typu C wskaźnika i być przygotowanym na nowoczesne kompilatory generują nonsensowny kod, jeśli ktoś się poślizgnie, nawet jeśli starsze kompilatory wygenerowałyby kod, który działa zgodnie z przeznaczeniem, lub dokumentują wymaganie, aby kod był użyteczny tylko z kompilatorami skonfigurowanymi do obsługi zachowania wskaźnika w starym stylu (np. „-fno-ścisłe-aliasing”) Niektórzy ludzie uważają „-fno-ścisłe-aliasing” za złe, ale sugerowałbym, że bardziej pomocne jest myślenie o „-fno-ścisłym-aliasingu” jako języku, który oferuje większą moc semantyczną do niektórych celów niż „standardowe” C,ale kosztem optymalizacji, które mogą być ważne dla innych celów.

Na przykład w tradycyjnych kompilatorach historyczne kompilatory interpretują następujący kod:

struct pair { int i1,i2; };
struct trio { int i1,i2,i3; };

void hey(struct pair *p, struct trio *t)
{
  p->i1++;
  t->i1^=1;
  p->i1--;
  t->i1^=1;
}

wykonując następujące kroki w kolejności: zwiększ pierwszy element *p, uzupełnij najniższy bit pierwszego elementu *t, a następnie zmniejsz pierwszy element *pi uzupełnij najniższy bit pierwszego elementu *t. Nowoczesne kompilatory przestawią sekwencję operacji w taki sposób, który kod będzie bardziej wydajny, jeśli pi tzidentyfikuje różne obiekty, ale zmieni zachowanie, jeśli tego nie zrobią.

Ten przykład jest oczywiście celowo opracowany i w praktyce kod, który używa wskaźnika jednego typu, aby uzyskać dostęp do elementów wchodzących w skład wspólnej sekwencji początkowej innego typu, zwykle działa, ale niestety, ponieważ nie ma możliwości dowiedzenia się, kiedy taki kod może zawieść w ogóle nie można bezpiecznie z niego korzystać, z wyjątkiem wyłączenia analizy aliasingu opartej na typach.

Nieco wymyślony przykład miałby miejsce, gdyby ktoś chciał napisać funkcję umożliwiającą zamianę dwóch wskaźników na dowolne typy. W zdecydowanej większości kompilatorów C z lat 90. można to osiągnąć poprzez:

void swap_pointers(void **p1, void **p2)
{
  void *temp = *p1;
  *p1 = *p2;
  *p2 = temp;
}

Jednak w standardzie C należałoby użyć:

#include "string.h"
#include "stdlib.h"
void swap_pointers2(void **p1, void **p2)
{
  void **temp = malloc(sizeof (void*));
  memcpy(temp, p1, sizeof (void*));
  memcpy(p1, p2, sizeof (void*));
  memcpy(p2, temp, sizeof (void*));
  free(temp);
}

Jeśli *p2jest przechowywany w przydzielonej pamięci, a wskaźnik tymczasowy nie jest przechowywany w przydzielonej pamięci, efektywny typ *p2stanie się typem wskaźnika tymczasowego i kodu, który próbuje użyć *p2jako dowolnego typu, który nie pasuje do wskaźnika tymczasowego type wywoła niezdefiniowane zachowanie. Na pewno jest bardzo mało prawdopodobne, aby kompilator zauważył coś takiego, ale ponieważ współczesna filozofia kompilatora wymaga, aby programiści unikali Nieokreślonego Zachowania za wszelką cenę, nie mogę wymyślić żadnego innego bezpiecznego sposobu pisania powyższego kodu bez użycia przydzielonej pamięci .

supercat
źródło
Downvoters: Chcesz komentować? Kluczowym aspektem programowania obiektowego jest możliwość posiadania wielu typów wspólnych cech, a wskaźnik do każdego takiego typu umożliwia dostęp do tych wspólnych aspektów. Przykład OP nie robi tego, ale ledwo zarysowuje powierzchnię bycia „obiektowym”. Historyczne kompilatory C pozwoliłyby na pisanie kodu polimorficznego w znacznie bardziej przejrzysty sposób niż jest to możliwe w dzisiejszym standardzie C. Projektowanie kodu obiektowego w C wymaga zatem określenia, jaki konkretnie język jest docelowy. Z jakim aspektem ludzie się nie zgadzają?
supercat
Hm ... czy możesz pokazać, w jaki sposób gwarancje zawarte w standardzie nie pozwalają ci na czysty dostęp do członków wspólnej początkowej podsekwencji? Ponieważ myślę, że właśnie na tym polega twoje zło związane z odważnością na optymalizację w ramach zachowań umownych? (To jest moje przypuszczenie co znaleziono dwa downvoters sprzeciw.)
Deduplicator
OOP niekoniecznie wymaga dziedziczenia, więc kompatybilność między dwiema strukturami nie stanowi większego problemu w praktyce. Mogę uzyskać prawdziwe OO, umieszczając wskaźniki funkcji w strukturze i wywołując te funkcje w określony sposób. Oczywiście, ten foo_function(foo*, ...)pseudo-OO w C jest tylko szczególnym stylem API, który akurat wygląda jak klasy, ale bardziej poprawnie należy go nazwać programowaniem modułowym z abstrakcyjnymi typami danych.
amon
@Deduplicator: Zobacz wskazany przykład. Pole „i1” należy do wspólnej sekwencji początkowej obu struktur, ale współczesne kompilatory zakończą się niepowodzeniem, jeśli kod spróbuje użyć „pary struktur *” w celu uzyskania dostępu do początkowego elementu „struktury trio”.
supercat
Który nowoczesny kompilator C zawodzi w tym przykładzie? Potrzebujesz interesujących opcji?
Deduplicator
-3

Następnym krokiem jest ukrycie deklaracji struktury. Umieszczasz to w pliku .h:

typedef struct foo_s foo_t;

foo_t * foo_new(...);
void foo_destroy(foo_t *foo);
some_type foo_whatever(foo_t *foo, ...);
...

A następnie w pliku .c:

struct foo_s {
    ...
};
Solomon Slow
źródło
6
To może być następny krok, w zależności od celu. Niezależnie od tego, czy jest, czy nie, to nawet nie próbuje zdalnie odpowiedzieć na pytanie.
Deduplicator