Jeśli nie przywiązujesz wagi do ograniczania widoczności zmiennych, ten artykuł może zmienić Twoje podejście.

Cofając się do początków mojej znajomości z językiem C, specyfikator static wspominam dość mgliście. Nie był on niezbędny do działania mojego kodu. Z powodzeniem mogłem realizować moje hobbistyczne projekty bez tego „dodatku”. Minęło trochę czasu i ten „dodatek” stał się elementarnym narzędziem do organizacji kodu, jego upraszczania i uodparniania na własną hmm… nieuwagę.

Ten artykuł od poprzednich różni się tym, że zacznę od wprowadzenia w podstawy języka C, a dopiero potem przejdę do bardziej skompilowanych problemów. Możesz przewinąć początek, jeżeli czujesz się pewny w tym temacie.

Podstawy

Specyfikator (storage class specifier) static tyczy się zarówno funkcji jak i zmiennych. Dodany przed deklaracją albo definicją funkcji ogranicza jej widoczność do tej jednostki kompilacji (pliku źródłowego .c), w której jest zdefiniowana. W praktyce oznacza to tyle, że tej funkcji nie da się wywołać z innego pliku .c. Dla zmiennych działanie static’a jest różne, w zależności od kontekstu. Jeżeli w pliku źródłowym mamy zmienną globalną to dodanie przed nią static ograniczy jej widoczność tak, jak w przypadku funkcji. Jeżeli dodamy static do zmiennej lokalnej, to nie ograniczamy bardziej jej widoczności (jest już i tak ograniczona) – zmieniamy za to jej czas życia. Już nie jest zmienną alokowaną chwilowo na stosie, jej miejsce znajduje się w pamięci statycznej. Czas jej życia jest taki sam, jak czas życia całego programu. Kod wyrazi więcej niż tysiąc słów:

static void foo(void)
{
    int i = 1;
    i++;
    printf("i = %d\n", i);
}

int main(void)
{
    foo(); 
    foo(); 
    foo(); 
    return 0;
}

Efekt:
i = 2
i = 2
i = 2
static void foo(void)
{
    static int i = 1;
    i++;
    printf("i = %d\n", i);
}

int main(void)
{
    foo(); 
    foo(); 
    return 0;
}
Efekt:
i = 2
i = 3
i = 4

W wersji ze static zmienna i nie jest za każdym razem na nowo tworzona, tak jak ma to miejsce w wersji bez static. Na starcie programu dostała wartość 1 i stały adres w pamięci RAM. Ponieważ żyje pomiędzy wywołaniami foo, jej wartość zwiększa się z każdym wywołaniem tej funkcji. Jeżeli podstawy mamy za sobą, przejdźmy do ciekawszej części.

dobre praktyki

Dobrą praktyką jest ograniczać widoczność zmiennych i funkcji do minimum. Takie podejście pozytywnie wpływa na dwie rzeczy. Po pierwsze kod, w którym zmienne i funkcje mają ograniczone do minimum zakresy widoczności, czyta się prościej. Widząc, że interesująca zmienna jest wewnątrz jednego if’a mamy pewność, że poza jego klamrami już nie istnieje, więc żaden kompilujący się kod z niej nie skorzysta. Jeżeli „po staremu” (ANSI C, C90) definicje wszystkich zmiennych są na początku funkcji, to musimy przejrzeć całą funkcję, żeby zobaczyć, gdzie te zmienne są używane.

Po drugie kod z ograniczonymi zakresami zmiennych i funkcji jest bezpieczniejszy w utrzymaniu. Jeżeli wiemy, że funkcja nie powinna być przez nikogo użyta na zewnątrz naszego pliku źródłowego, to powinniśmy zrobić z niej funkcję statyczną. Tym sposobem ograniczamy innym programistom (w tym przyszłemu sobie) możliwość zepsucia naszego modułu poprzez niepożądane użycie funkcji, której nikt nie powinien użyć.

Zalety można by jeszcze przez chwilę mnożyć. Zakładam, że nie trzeba nikogo przekonywać do tego, że używanie zmiennych globalnych jest złym pomysłem. Prawda?

<żart>

Jaki jest najlepszy przedrostek dla nazwy zmiennej globalnej?…

//

</żart>.

extern vs static

W języku C jest jeszcze słowo kluczowe extern. W kontekście widoczności jest dokładną odwrotnością słowa kluczowego static. Jeżeli więc wszyscy zgodnie twierdzą, że powinno się ograniczać widoczność zmiennych i funkcji to jaka powinna być domyślna widoczność, gdy nie używa się ani extern, ani static? Taka jak dla static. Jaka faktycznie jest domyślna widoczność zmiennych i funkcji w języku C? Extern. Gdzie tu sens? Nie wiem.

Z tego teoretycznego wstępu płynie jasny przekaz: „Jako świadomi programiści musimy sami pamiętać o stosowaniu static„. Jeżeli o nim zapomnimy, to kompilator domyślnie stworzy symbol o zewnętrznym linkowaniu. Nie jest to tylko i wyłącznie złą praktyką pogarszającą czytelność kodu. Konsekwencje nieświadomego szastania globalnymi symbolami mogą być ciekawe, ale raczej niepożądane. Takie podejście może prowadzić do zrobienia poważnego i ciężkiego w wytropieniu błędu. Błąd za chwilę przedstawię i objaśnię, ale najpierw zapraszam na szybkie wprowadzenie teoretyczne do drugiej części artykułu.

Kompilowanie a linkowanie

Kompilowanie i linkowanie to dwa niezbędne kroki na drodze do naszego upragnionego pliku wynikowego. Kompilator z plików źródłowych tworzy pliki obiektowe, które następnie linker łączy w plik wynikowy. Zazwyczaj mało interesujące nas pliki obiektowe, z perspektywy tematu tego artykułu stają się cennym źródłem informacji. Rozważmy taki plik źródłowy.

#include <stdbool.h>

bool flag = true;

int main(void)
{
    return 0;
}

Skompilowanie go za pomocą GCC z flagą -c wygeneruje nam plik obiektowy main.o

gcc -Wall -Wextra -c main.c

Używając programu nm możemy wyświetlić symbole zawarte w pliku obiektowym main.o

$ nm main.o
0000000000000000 b .bss
0000000000000000 d .data
0000000000000000 p .pdata
0000000000000000 r .rdata$zzz
0000000000000000 t .text
0000000000000000 r .xdata
                 U __main
0000000000000000 D flag
0000000000000000 T main

Widzimy, że w symbolach pojawia się nasza zmienna flag. Jej typ to D. Z pomocą dokumentacji programu nm możemy odszyfrować typ D jako zainicjalizowane dane globalne. Według dokumentacji zewnętrzne symbole mają typ oznaczony wielką literą, lokalne literą małą. Co się stanie, gdy zmienną flag poprzedzimy słowem static?

$ nm main.o
0000000000000000 b .bss
0000000000000000 d .data
0000000000000000 p .pdata
0000000000000000 r .rdata$zzz
0000000000000000 t .text
0000000000000000 r .xdata
                 U __main
0000000000000000 d flag
0000000000000000 T main

Symbol flag widnieje w tablicy symboli jako typ d, czyli zainicjalizowane dane lokalne. Zgodnie z oczekiwaniami.

schody

Do tego momentu nic nie powinno być zaskoczeniem. Teraz zaczynają się schody. Gdy jeszcze byłem na etapie niefrasobliwego używania zmiennych globalnych, zwykłem zakładać, że definiując zmienną globalną z domyślną wartością 0, nie ma znaczenia czy tę wartość podam, czy nie. Oczekiwałem, że poniższe zmienne flag i flag2 są dokładnie takimi samymi obiektami.

#include <stdbool.h>

bool flag = false;
bool flag2;

int main(void)
{
    return 0;
}
$ gcc -c -Wall -Wextra main.c && nm main.o
0000000000000000 b .bss
0000000000000000 d .data
0000000000000000 p .pdata
0000000000000000 r .rdata$zzz
0000000000000000 t .text
0000000000000000 r .xdata
                 U __main
0000000000000000 B flag
0000000000000001 C flag2
0000000000000000 T main

Widzimy jednak, że w tablicy symboli widnieją jako dwa różne typy. Zarówno flag jak i flag2 mają zasięg globalny. Symbol flag jest typu B.

b B

The symbol is in the BSS data section. This section typically contains zero-initialized or uninitialized data, although the exact behavior is system dependent.

https://sourceware.org/binutils/docs/binutils/nm.html

Brzmi rozsądnie, można się było tego spodziewać po zmiennej, która ma domyślnie wartość 0. Czym w takim razie jest typ C?

c C

The symbol is common. Common symbols are uninitialized data. When linking, multiple common symbols may appear with the same name.

https://sourceware.org/binutils/docs/binutils/nm.html

To już brzmi dość niepokojąco. Tutaj dochodzimy do sedna naszej pułapki. Jak ona wygląda? Przedstawiam przepis na zły dzień, będziesz potrzebował:

  1. Jedną albo dwie sztuki nieświadomego programisty
  2. Języka programowanie, w którym każda zmienna jest domyślnie globalna

Nieświadomy programista (np. ja z przeszłości) tworzy w swoim module foo.c globalną zmienną flag. Nie zrobił jej static’iem, ponieważ jest tylko w pliku .c, a w jego przekonaniu skoro nie zrobił jej deklaracji w pliku .h, to nie jest to książkowa zmienna globalna. Albo błędnie zakłada, że jej widoczność jest ograniczona do tego pliku źródłowego albo nawet się nad tym nie zastanawia. Dodatkowo, ponieważ wie, że zmienne, które nie są na stosie, mają domyślnie zawsze wartość 0, to nie przypisuje jej żadnej domyślnej wartości. W tym czasie w głównym module main.c inny równie niedoświadczony programista stworzył swoją własną zmienną flag i niesiony tą samą logiką co jego kolega nie poprzedził jej słowem static. Opisany scenariusz w kodzie wygląda w ten sposób.

#include <stdbool.h>
#include <stdio.h>
#include "foo.h"


bool flag;


int main(void)
{
    printf("flag = %d\n", flag);

    FOO_handler();

    printf("flag = %d\n", flag);
    return 0;
}
#ifndef FOO_H
#define FOO_H

void FOO_handler(void);
#endif
#include "foo.h
#include <stdbool.h>

bool flag;

void FOO_handler(void)
{
    flag = true; 
}

Kompilujemy, linkujemy i uruchamiamy.

$ gcc -Wall -Wextra -c main.c foo.c
$ gcc -o test main.o foo.o
./test
flag = 0
flag = 1

Z perspektywy programisty piszącego main.c wydarzyło się w tym momencie coś dziwnego. On nigdzie w kodzie wartości zmiennej flag nie zmieniał, a w praktyce wartość jednak się zmieniła. Podglądając tablice symboli plików obiektowych foo.o, main.o i wynikowego test można prześledzić, co się stało z symbolem flag. Wycinam nieinteresujące nas informacje z wyjścia nm.

nm main.o
0000000000000000 C flag
nm foo.o
0000000000000001 C flag
nm test
0000000100407000 B flag

Symbole flag z plików foo.c i main.c zostały połączone w jeden wspólny symbol w pliku wynikowym test. To właśnie oznacza typ C w tablicy symboli. Gdyby sytuacja potoczyła się inaczej i w obu modułach programiści nadali jawnie domyślną wartość dla swoich zmiennych flag, to o problemie dowiedzieliby się już na etapie linkowania, a nie debugowania. Wtedy w każdym z plików obiektowych symbol flag byłby typu B, a nie C (common) i doszłoby do konfliktu przy linkowaniu. Objawiłoby się to w ten sposób.

$ gcc -Wall -Wextra -c main.c foo.c
$ gcc -o test main.o foo.o
$ gcc -o test main.o foo.o
foo.o:foo.c:(.data+0x0): multiple definition of `flag'
main.o:main.c:(.bss+0x0): first defined here
collect2: error: ld returned 1 exit status

W tym przypadku widać, że linker wykrywa konflikt. Sam kompilator nie widzi żadnego problemu, jest odpowiedzialny za skompilowanie każdej jednostki kompilacji osobno. Konflikt jest na etapie linkowania. Jak wyglądają pośrednie pliki obiektowe?

nm main.o
0000000000000000 B flag
nm foo.o
0000000000000000 B flag

Każdy z nich faktycznie zawiera „pewną” definicję symbolu flag. Połączenie ich w jedno nie jest możliwe.

Opisane powyżej zachowanie nie jest specjalnie zaskakujące, jeżeli jesteśmy świadomi tego, że brak static oznacza w praktyce extern. Pułapką opisaną w tym artykule jest głównie nieświadomość programisty, nie sam język.

Stwierdzenie, że brak static to extern to też nie do końca prawda. Wcześniej gdy dwa pliki .c zawierały po prostu bool flag; – w pliku wynikowym powstał sklejony symbol. Co się stanie, gdy obie te zmienne rzeczywiście poprzedzimy specyfikatorem extern?

#include <stdbool.h>
#include <stdio.h>
#include "foo.h"


extern bool flag;


int main(void)
{
    printf("flag = %d\n", flag);

    FOO_handler();

    printf("flag = %d\n", flag);
    return 0;
}
#ifndef FOO_H
#define FOO_H

void FOO_handler(void);
#endif
#include "foo.h
#include <stdbool.h>

extern bool flag;

void FOO_handler(void)
{
    flag = true; 
}
CC -c main.c
CC -c foo.c
CC -o test main.o foo.o
main.o:main.c:(.rdata$.refptr.flag[.refptr.flag]+0x0): undefined reference to `flag'
collect2: error: ld returned 1 exit status
$ nm main.o
                 U flag
$ nm foo.o
                 U flag

Linker krzyczący „undefined reference to `flag’” sugeruje, że jednak brak static to nie do końca to samo, co extern. W tabeli symboli obu plików obiektowych flag widnieje jako typ U (undefined). Jawne poprzedzenie zmiennej extern’em powoduje, że staje się ona „czystą” deklaracją, a same deklaracje (tak jak w powyższym kodzie) to za mało, żeby powstała zmienna. Finał moich długich eksperymentów jest już blisko.

tentative definition

W artykule użyłem sformułowań „czysta deklaracja”, „pewna definicja”. Wskazywałoby to na istnienie przeciwstawnych terminów: „nieczystej deklaracji” i „niepewnej definicji”. Te dwa terminy istnieją pod angielskim pojęciem „tentative definition”. Jest to mechanizm działający tylko w C, w C++ już go nie ma. Pozwala on na istnienie wielu definicji tej samej zmiennej tak długo, jak więcej niż jedna z nich nie ma jawnie nadanej wartości, a typy wszystkich tych definicji są zgodne.

Gdy tworzymy zmienne w ten sposób

bool flag1;
static bool flag2;

tworzymy właśnie te niepewne definicje. Zgodnie z tym mechanizmem kompilator linijkę bool flag1; traktuje najpierw jako deklarację. Dopiero jeżeli dotrze do końca pliku źródłowego i nie znajdzie żadnej „pewnej” definicji (czyli bool flag1 = false;), to potraktuje deklarację jako definicję. W przypadku zmiennych z zewnętrznym linkowaniem działanie tego mechanizmu widzieliśmy parę akapitów wyżej. Dopóki zmienna nie ma nadanej jawnie wartości, może potencjalnie skleić się z innym symbolem. Zachowanie to, czasami może zaskakujące, jest w pełni zgodne ze standardem.

J.5.11 Multiple external definitions

There may be more than one external definition for the identifier of an object, with or without the explicit use of the keyword extern; if the definitions disagree, or more than one is initialized, the behavior is undefined (6.9.2).

Standard C11 – dodatek J

O ile powiedzmy, że spodziewamy się tego przy zmiennych globalnych, o tyle dla zmiennych ograniczonych static’iem może nie być to tak oczywiste. W hipotetycznej sytuacji poniżej dwóch programistów w jednym ogromnym pliku stworzyło sobie swoje, z założenia niezależne zmienne flag. Żaden z nich nie wie, że kolega dodał taką samą zmienną.

#include <stdbool.h>
#include <stdio.h>

static bool flag; //tentative definition - zachowuje sie jak deklaracja
static void foo2(void)
{
    printf("foo2: flag = %d\n", flag);
}

//2000 linii kodu

static bool flag = true; //wlasciwa definicja
static void foo(void)
{
    printf("foo: flag = %d\n", flag);
}

int main(void)
{
    foo();
    foo2();
    return 0;
}

Czy kompilator ostrzeże ich o tym, że proszą się o kłopoty? Nie, po cichu sklei ze sobą dwie zmienne w jedną.

$ gcc -Wall -Wextra -Wpedantic main.c && ./a
foo: flag = 1
foo2: flag = 1

Dopiero nadanie obydwu zmiennym jawnej wartości spowoduje powstanie dwóch „pewnych” definicji, które już na etapie kompilacji spowodują konflikt.

$ gcc -Wall -Wextra -Wpedantic main.c && ./a
main.c:12:13: error: redefinition of ‘flag’
 static bool flag = true;
             ^~~~
main.c:4:13: note: previous definition of ‘flag’ was here
 static bool flag = false;

wnioski

Najważniejszy wniosek jest taki: jeżeli zmienna nie ma być widziana poza plikiem źródłowym, zawsze używaj static. Wniosek mało oryginalny, raczej oczywisty. Cały mój wywód miał być przestrogą dla tych, dla których używanie static jest tylko dobrą praktyką i nie widzą większych problemów z ich pominięciem. Mam nadzieję, że Was nastraszyłem.

Można by z artykułu wyciągnąć wniosek, że zawsze trzeba nadawać domyślną wartość zmiennym, a nie polegać na ich zerowaniu przez kompilator. Jeżeli wszyscy by się do tego stosowali, faktycznie uniknęlibyśmy sklejania zmiennych. Czy jest to rekomendowana przeze mnie praktyka? Jeszcze nie jestem do końca o tym przekonany, chociaż na razie nie widzę przeciwwskazań.

Przykład ze sklejeniem się dwóch zmiennych statycznych wzmacnia znaczenie nadawania wyrazistych nazw zmiennych. Używanie takich ogólników jak flag, index może przysporzyć problemów. Zmienną możemy skleić nie tylko z własnym kodem, ale też z biblioteką standardową C. Ostrzega o tym Peter van der Linden w książce „Expert C Programming: Deep C Secrets”. Polecam lekturę.

lekarstwo

Jeżeli, jako świadomy programista wiesz, że nie chcesz w swoim projekcie niejawnie sklejać ze sobą wspólnych symboli, możesz się zabezpieczyć przed tym za pomocą ostrzeżenia linkera -warn-common. Wróćmy do pierwszego przykładu kodu z dwiema zmiennymi bool flag; w dwóch różnych modułach. Linkując z flagą -warn-common faktycznie dostajemy ostrzeżenie.

$ gcc -Wall -Wextra -c main.c foo.c
$ gcc -o test -Xlinker -warn-common main.o foo.o
foo.o: warning: common of `flag' overridden by definition
main.o: warning: defined here

Jeżeli jedna z tych zmiennych będzie poprzedzona extern (jawnie wyrażając nasze intencje), to ostrzeżenia nie dostaniemy.

podsumowanie

Artykuł mocno spuchł. Jeżeli dotarłeś aż tutaj, gratuluję. Zdaję sobie sprawę z tego, że dla może nawet większości osób problemy opisane powyżej nie są niczym tajemniczym. Jednak na pewno znajdą się osoby, które nie wiedzą o takim zachowaniu. Czy popełnią kiedyś taki błąd? Może. Czy spędzą pół dnia na jego debugowaniu? Najprawdopodobniej. Czy woleliby przeczytać o tym wcześniej i być świadomi ryzyka? Myślę, że zdecydowanie tak.

Miłego programowania

Hubert Melchert