C/Powszechne praktyki

< C

Rozdział ten ma za zadanie pokazać powszechnie stosowane metody programowania w C. Nie będziemy tu uczyć, jak należy stawiać nawiasy klamrowe ani który sposób nazewnictwa zmiennych jest najlepszy - prowadzone są o to spory, z których niewiele wynika. Zaprezentowane tu rozwiązania mają konkretny wpływ na jakość tworzonych programów.


Konstruktory i destruktory

edytuj

W większości obiektowych języków programowania obiekty nie mogą być tworzone bezpośrednio - obiekty otrzymuje się wywołując specjalną metodę danej klasy, zwaną konstruktorem. Konstruktory są ważne, ponieważ pozwalają zapewnić obiektowi odpowiedni stan początkowy. Destruktory, wywoływane na końcu czasu życia obiektu, są istotne, gdy obiekt ma wyłączny dostęp do pewnych zasobów i konieczne jest upewnienie się, czy te zasoby zostaną zwolnione.

Ponieważ C nie jest językiem obiektowym, nie ma wbudowanego wsparcia dla konstruktorów i destruktorów. Często programiści bezpośrednio modyfikują tworzone obiekty i struktury. Jednakże prowadzi to do potencjalnych błędów, ponieważ operacje na obiekcie mogą się nie powieść lub zachować się nieprzewidywalnie, jeśli obiekt nie został prawidłowo zainicjalizowany. Lepszym podejściem jest stworzenie funkcji, która tworzy instancję obiektu, ewentualnie przyjmując pewne parametry:

 struct string {
   size_t size;
   char *data;
 };
 
 struct string *create_string(const char *initial) {
   assert (initial != NULL);
   struct string *new_string = malloc(sizeof(*new_string));
   if (new_string != NULL) {
     new_string->size = strlen(initial);
     new_string->data = strdup(initial);
   }
   return new_string;
 }

Podobnie, bezpośrednie usuwanie obiektów może nie do końca się udać, prowadząc do wycieku zasobów. Lepiej jest użyć destruktora:

 void free_string(struct string *s) 
 {
   assert (s != NULL);
   free(s->data);  /* zwalniamy pamięć zajmowaną przez strukturę */
   free(s);        /* usuwamy samą strukturę */
 }

Często łączy się destruktory z zerowaniem zwolnionych wskaźników.

Czasami dobrze jest ukryć definicję obiektu, żeby mieć pewność, że użytkownicy nie utworzą go ręcznie. Aby to zapewnić struktura jest definiowana w pliku źródłowym (lub prywatnym nagłówku niedostępnym dla użytkowników) zamiast w pliku nagłówkowym, a deklaracja wyprzedzająca jest umieszczona w pliku nagłówkowym:

 struct string;
 struct string *create_string(const char *initial);
 void free_string(struct string *s);

Zerowanie zwolnionych wskaźników

edytuj

Jak powiedziano już wcześniej, po wywołaniu free() dla wskaźnika, staje się on "wiszącym wskaźnikiem". Co gorsze, większość nowoczesnych platform nie potrafi wykryć, kiedy taki wskaźnik jest używany zanim zostanie ponownie przypisany.

Jednym z prostych rozwiązań tego problemu jest zapewnienie, że każdy wskaźnik jest zerowany natychmiast po zwolnieniu:

 free(p);
 p = NULL;

Inaczej niż w przypadku "wiszących wskaźników", na wielu nowoczesnych architekturach przy próbie użycia wyzerowanego wskaźnika pojawi się sprzętowy wyjątek. Dodatkowo, programy mogą zawierać sprawdzanie błędów dla zerowych wartości, ale nie dla "wiszących wskaźników". Aby zapewnić, że jest to wykonywane dla każdego wskaźnika, możemy użyć makra:

 #define FREE(p)   do { free(p); (p) = NULL; } while(0)

(aby zobaczyć dlaczego makro jest napisane w ten sposób, zobacz #Konwencje pisania makr)

Przy wykorzystaniu tej techniki destruktory powinny zerować wskaźnik, który przekazuje się do nich, więc argument musi być do nich przekazywany przez referencję. Na przykład, oto zaktualizowany destruktor z sekcji Konstruktory i destruktory:

 void free_string(struct string **s) 
 {
   assert(s != NULL  &&  *s != NULL);
   FREE((*s)->data);  /* zwalniamy pamięć zajmowaną przez strukturę */
   FREE(*s);          /* usuwamy strukturę */
 }

Niestety, ten idiom nie jest wstanie pomóc w wypadku wskazywania przez inne wskaźniki zwolnionej pamięci. Z tego powodu niektórzy eksperci C uważają go za niebezpieczny, jako kreujący fałszywe poczucie bezpieczeństwa.

Konwencje pisania makr

edytuj

Ponieważ makra preprocesora działają na zasadzie zwykłego zastępowania napisów, są podatne na wiele kłopotliwych błędów, z których części można uniknąć przez stosowanie się do poniższych reguł:

  1. Umieszczaj nawiasy dookoła argumentów makra kiedy to tylko możliwe. Zapewnia to, że gdy są wyrażeniami kolejność działań nie zostanie zmieniona. Na przykład:
    • Źle: #define kwadrat(x) (x*x)
    • Dobrze: #define kwadrat(x) ( (x)*(x) )
    • Przykład: Załóżmy, że w programie makro kwadrat() zdefiniowane bez nawiasów zostało wywołane następująco: kwadrat(a+b). Wtedy zostanie ono zamienione przez preprocesor na: (a+b*a+b). Z kolejności działań wiemy, że najpierw zostanie wykonane mnożenie, więc wartość wyrażenia kwadrat(a+b) będzie różna od kwadratu wyrażenia a+b.
  2. Umieszczaj nawiasy dookoła całego makra, jeśli jest pojedynczym wyrażeniem. Ponownie, chroni to przed zaburzeniem kolejności działań.
    • Źle: #define kwadrat(x) (x)*(x)
    • Dobrze: #define kwadrat(x) ( (x)*(x) )
    • Przykład: Definiujemy makro #define suma(a, b) (a)+(b) i wywołujemy je w kodzie wynik = suma(3, 4) * 5. Makro zostanie rozwinięte jako wynik = (3)+(4)*5, co - z powodu kolejności działań - da wynik inny niż pożądany.
  3. Jeśli makro składa się z wielu instrukcji lub deklaruje zmienne, powinno być umieszczone w pętli do { ... } while(0), bez kończącego średnika. Pozwala to na użycie makra jak pojedynczej instrukcji w każdym miejscu, jak ciało innego wyrażenia, pozwalając jednocześnie na umieszczenie średnika po makrze bez tworzenia zerowego wyrażenia. Należy uważać, by zmienne w makrze potencjalnie nie kolidowały z argumentami makra.
    • Źle: #define FREE(p) free(p); p = NULL;
    • Dobrze: #define FREE(p) do { free(p); p = NULL; } while(0)
  4. Unikaj używania argumentów makra więcej niż raz wewnątrz makra. Może to spowodować kłopoty, gdy argument makra ma efekty uboczne (np. zawiera operator inkrementacji).
    • Przykład: #define kwadrat(x) ((x)*(x)) nie powinno być wywoływane z operatorem inkrementacji kwadrat(a++) ponieważ zostanie to rozwinięte jako ((a++) * (a++)), co jest niezgodne ze specyfikacją języka i zachowanie takiego wyrażenia jest niezdefiniowane (dwukrotna inkrementacja w tym samym wyrażeniu).
  5. Jeśli makro może być w przyszłości zastąpione przez funkcję, rozważ użycie w nazwie małych liter, jak w funkcji.

Jak dostać się do konkretnego bitu?

edytuj

Wiemy, że komputer to maszyna, której najmniejszą jednostką pamięci jest bit, jednak w C najmniejsza zmienna ma rozmiar 8 bitów (czyli jednego bajtu). Jak zatem można odczytać wartość pojedynczych bitów? W bardzo prosty sposób - w zestawie operatorów języka C znajdują się tzw. operatory bitowe. Są to m. in.:

  • & - bitowe "i"
  • | - bitowe "lub"
  • ~ - bitowe "nie"

Oprócz tego są także przesunięcia (<< oraz >>). Zastanówmy się teraz, jak je wykorzystać w praktyce. Załóżmy, że zajmujemy się jednobajtową zmienną.

 unsigned char i = 2;

Z matematyki wiemy, że zapis binarny tej liczby wygląda tak (w ośmiobitowej zmiennej): 00000010. Jeśli teraz np. chcielibyśmy "zapalić" drugi bit od lewej (tj. bit, którego zapalenie niejako "doda" do liczby wartość 26) powinniśmy użyć logicznego lub:

 unsigned char i = 2;
 i |= 64;

Gdzie 64=26. Odczytywanie wykonuje się za pomocą tzw. maski bitowej. Polega to na:

  1. wyzerowaniu bitów, które są nam w danej chwili niepotrzebne
  2. odpowiedniemu przesunięciu bitów, dzięki czemu szukany bit znajdzie się na pozycji pierwszego bitu od prawej

Do "wyłuskania" odpowiedniego bitu możemy posłużyć się operacją "i" - czyli operatorem &. Wygląda to analogicznie do posługiwania się operatorem "lub":

 unsigned char i = 3; /* bitowo: ''00000011'' */
 unsigned char temp = 0;
 temp = i & 1; /* sprawdzamy najmniej znaczący bit - czyli pierwszy z prawej */
 if (temp) {
   printf ("bit zapalony");
 }
 else {
   printf ("bit zgaszony");
 }

Jeśli nie władasz biegle kodem binarnym, tworzenie masek bitowych ułatwią ci przesunięcia bitowe. Aby uzyskać liczbę która ma zapalony bit o numerze n (bity są liczone od zera), przesuwamy bitowo w lewo jedynkę o n pozycji:

 1 << n

Jeśli chcemy uzyskać liczbę, w której zapalone są bity na pozycjach l, m, n - używamy sumy logicznej ("lub"):

 (1 << l) | (1 << m) | (1 << n)

Jeśli z kolei chcemy uzyskać liczbę gdzie zapalone są wszystkie bity poza n, odwracamy ją za pomocą operatora logicznej negacji ~

 ~(1 << n)

Warto władać biegle operacjami na bitach, ale początkujący mogą (po uprzednim przeanalizowaniu) zdefiniować następujące makra i ich używać:

 /* Sprawdzenie czy w liczbie k jest zapalony bit n */
 #define IS_BIT_SET(k, n)     ((k) & (1 << (n)))
 
 /* Zapalenie bitu n w zmiennej k */
 #define SET_BIT(k, n)        (k |= (1 << (n)))
 
 /* Zgaszenie bitu n w zmiennej k */
 #define RESET_BIT(k, n)      (k &= ~(1 << (n)))

Skróty notacji

edytuj

Istnieją pewne sposoby ograniczenia ilości niepotrzebnego kodu. Przykładem może być wykonywanie jednej operacji w razie wystąpienia jakiegoś warunku, np. zamiast pisać:

 if (warunek) {
   printf ("Warunek prawdziwy\n");
 }

możesz skrócić notację do:

 if (warunek)
   printf ("Warunek prawdziwy\n");

Podobnie jest w przypadku pętli for:

 for (;warunek;)
   printf ("Wyświetlam się w pętli!\n");

Niestety ograniczeniem w tym wypadku jest to, że można w ten sposób zapisać tylko jedną instrukcję.

Zobacz również

edytuj