P-Programowanie

Typy wartościowe i referencyjne

16 stycznia 2015, kategoria: C#

W języku C# istnieje kilka podstawowych typów danych. Na pierwszy rzut oka nie widać między nimi żadnej różnicy, jednak pojawia się w charakterystycznych sytuacjach takich jak przekazywanie parametrów do funkcji czy kopiowanie wartości zmiennych. Dokładne zapoznanie się z typami danych pozwoli Ci unikać błędów charakterystycznych dla początkujących programistów.

Czym jest stos i sterta?

Poruszając temat typów danych warto wspomnieć kilka słów o stosie i stercie. Po pierwsze nie należy tych terminów mylić ze strukturami do przechowywania danych, możliwych do implementacji w wielu językach. Zarówno stos jak i sterta są częścią pamięci wirtualnej jaka jest przydzielona aplikacji podczas uruchamiania – są odrębne dla każdej aplikacji.

Każdy utworzony wątek danej aplikacji korzysta z osobnego stosu, więc jedna aplikacja może mieć ich kilka. Stos jest o wiele szybszy od sterty, lądują na niego wszelkie zmienne oraz parametry przekazywane do funkcji. Na stos zostaje wrzucany także adres powrotu.

Z programistycznego punktu widzenia „stos nas nie obchodzi”. Porządek na stosie w pewnym sensie utrzymuje system. Deklarując zmienną trafia ona na stos i zostaje z niego zdjęta (usunięta), wtedy gdy wypadnie poza klamry zasięgu (blok kodu {}). Generalnie programista nie musi o nic dbać, ma dbać tylko o poprawny, czysty kod.

Sterta jest miejscem w pamięci wirtualnej procesu, gdzie trafiają wszelkie klasy i ich instancje. Szczególnie rozpatrując język C# na stertę trafiają także interfejsy, tablice, delegaty. Sterta jest zarządzana nie przez system operacyjny, a przez wirtualną maszynę .NET. Elementy trafiające na stertę są tworzone operatorem new (nie jest to żelazną zasadą) którego zasada działania jest bardziej skomplikowana niż zwykła deklaracja zmiennych lądujących na stos.

W innych językach programowania (C++) o porządek na stercie musimy zadbać sami zwalniając pamięć. W C# pamięć sterty kontroluje Garbage Collector, wyłapuje puste referencje i zwalnia miejsce.

Ciężko mi na temat sterty/stosu napisać coś więcej, ponieważ nie można ich porównywać bezpośrednio między dwoma różnymi językami i nie chcę popełnić gafy. W C++ programista wszystko robi sam, dlatego ma świadomość tego co robi. C# jest językiem bardzo zautomatyzowanym i wygodnym, lecz wiele rzeczy dzieje się za naszymi plecami i bez naszej wiedzy.

Typy wartościowe

Podstawowym typem danych występującym w języku C# jest typ wartościowy. Jest to typ występujący powszechnie we wszystkich językach programowania.

Typ wartościowy jest podstawowym typem danych występującym w C#. Podczas deklaracji typu wartościowego kompilator alokuje odpowiednią ilość miejsca w pamięci, w której będzie przechowywana wartość zmiennej. Typy wartościowe zawsze umieszczane są na stosie.

Wszystkie typy wartościowe dziedziczą niejawnie z klasy System.ValueType. To właśnie ta klasa zapewnia nas, że obiekt zostanie umieszczony stosie. Dzięki temu program ma do nich bardzo szybki dostęp. Typy wartościowe są domyślnie przekazywane przez wartość, oznacza to, że do funkcji przekazywana jest ich kopia.

Typami wartościowymi w C# są: int, double, float, longbyte, char, bool, typy wyliczeniowe enumstruktury. Przykładowo, deklarując zmienną int deklarujemy typ wartościowy o wielkości 4b. Klasą pochodną jest klasa System.ValueType a więc zmienna będzie znajdować się na stosie. Mamy pewność, że znajduje się ona poza działaniem Garbage Collectora.

Ponieważ System.ValueType jest klasą  bazową wszystkich typów wartościowych poprawny będzie poniższy zapis:

Kopiowanie wartości typów wartościowych jest takie samo jak we wszystkich innych językach. Kopię osiągamy poprzez operator przypisania =. Przekazując parametr do funkcji operujemy na jego kopii, więc po wyjściu z bloku funkcji oryginalna zmienna nie zostaje zmieniona.

Podsumowanie typu wartościowego:
  • wszystkie niejawnie rozszerzają klasę System.ValueType
  • dzięki temu wszystkie trafiają na stos
  • zmienne typów wartościowych są po prostu miejscami w pamięci
  • domyślnie przekazywane przez wartość (kopia)
  • zmienna przestaje istnieć kiedy wyjdzie poza klamry zasięgu
  • mogą mięć konstruktory niestandardowe
  • nie są w żadnym wypadku zależne od Garbage Collectora

Typy referencyjne

Drugim rodzajem typu danych są typy referencyjne. Występują one także w języku C++. Na omówieniu tego typu danych skupimy się bardziej szczegółowo, ponieważ istnieją w nim charakterystyczne mechanizmy, które należy zapamiętać.

Typ referencyjny jest typem umieszczanym na stercie programu. Konkretniej referencja do pamięci umieszczana jest na stosie a obszar pamięci do jakiego prowadzi referencja znajduje się na stercie.

Typami referencyjnymi w C# są elementy rozszerzające klasę System.Object oraz System.String, a więc są to np.: klasydelegacje, interfejsytablice, zmienne string itd. Typów referencyjnych nie da się kopiować korzystając z operatora przypisania.

Główną różnicą między typami wartościowymi a referencyjnymi jest niezmienność wielkości typów wartościowych w przeciwieństwie do typów referencyjnych. Nigdy nie wiemy ile miejsca w pamięci będzie zajmować klasa, w przeciwieństwie do prostych zmiennych np. typu int, które zajmują 4b.

Dlatego też, tworząc zmienną liczbową kompilator przydziela jej po prostu odpowiednią ilość miejsca. Tworząc klasę (typ referencyjny), kompilator wrzuca na stos referencję, która wskazuje na obszar pamięci znajdujący się na stercie. Obiekt klasy może się zmieniać w trakcie działania programu. Nad wszystkim czuwa Garbage Collector działający w osobnym wątku naszej aplikacji (sterta jest wspólna dla wszystkich wątków).

typy referencyjne C#

Podsumowanie typu referencyjnego
  • wszystkie niejawnie dziedziczą z klasy System.Object lub System.String.
  • dzięki temu wszystkie trafiają na stertę (ale referencja do obiektu na stos)
  • są domyślnie przekazywane do funkcji przez wartość
  • są usuwane z pamięci za pomocą Garbage Collectora
  • mogę mieć konstruktory

Kopiowanie wartości typów referencyjnych

Jaki jest problem? Typów referencyjnych nie można kopiować zwykłym operatorem przypisania. Dlaczego? Rozważmy dwa poniższe przykłady. W pierwszej kolejności prosty przykład z typem wartościowym:

W tym kodzie chyba nic nas nie dziwi. W 2 linijce następuje skopiowane wartości zmiennej wiek1 do zmiennej wiek2. Obie zmienne ciągle znajdują się w osobnych miejscach w pamięci, więc gdy w linijce 7 zmieniamy wartość zmiennej wiek2 to zmienna wiek1 się nie zmienia.

Teraz ten sam przykład, z wykorzystaniem typów referencyjnych:

Mamy niezgodność. W linijce 13 skopiowaliśmy imię osoba1 do osoba2, więc dwa razy zobaczyliśmy napis „Karol”. Jednak w linijce 18 i 19 ustawiliśmy dwa osobne imiona. Mimo tego dwa razy zostało wyświetlone imię „Arek”. Co jest nie tak?

Operator przypisania kopiuje wartość zmiennej z prawej strony do zmiennej z lewej strony. Ponieważ klasy są typami referencyjnymi dlatego zmienne osoba1 i osoba2 także są referencjami.

Referencja jest to zmienna, której wartością jest adres do miejsca w pamięci, gdzie przechowywana jest jakaś wartość lub instancja obiektu.

Skoro wartością referencji jest adres, to operator przypisania skopiował właśnie adres na jaki referencja wskazuje. Nie należy mylić adresu referencji w pamięci, z adresem na jaki wskazuje referencja (czyli wartość referencji).

referencja C#

Powyższy obrazek ukazuje sedno problemu. Teraz dokładnie widać, że dwie referencje na stosie wskazują na to samo miejsce (na tę samą instancję klasy osoba) na stercie. Jest to ewidentny błąd.

Aby skopiować wartości pól poszczególnych obiektów, trzeba to robić pojedynczo pole po polu. Można też kopiować całe obiekty. Są z tym związane pojęcia kopii płytkiej oraz kopii głębokiej, jednak jest to materiał na osobny artykuł.

Przekazywanie typów referencyjnych do funkcji przez wartość

Istnieje prosta zasada jaką trzeba zapamiętać, przesyłając typy referencyjne do funkcji poprzez wartość. Wewnątrz funkcji możemy zmienić stan obiektu na jaki wskazuje przesłana referencja, jednak nie można tej referencji zmienić. Przeanalizujmy poniższy program:

Tworzymy instancję klasy Osoba i wypełniamy ją danymi. Przekazujemy referencję do obiektu do funkcji poprzez wartość. Do funkcji powędrowała kopia referencji, ponieważ jest to charakterystyczne dla przekazywania przez wartość. Mimo, że przesłaliśmy kopię referencji, jej wartość wskazuje na stercie na oryginalny obszar pamięci zawierający instancję klasy Osoba.

Dlatego przypisanie nowego imienia w linijce 24 zadziała. Mimo że przesłaliśmy kopię referencji, operujemy na oryginalnym bloku pamięci na stercie. W przypadku gdybyśmy przesyłali typ wartościowy, sytuacja wyglądała by całkiem inaczej! Mielibyśmy wewnątrz funkcji kopię wartości, a więc jakiekolwiek operacje wewnątrz funkcji nie zmieniały by obiektu z poza niej.

W linijce 25 nie jesteśmy w stanie zmienić wartości referencji, ponieważ tak samo jak w przypadku przesyłania typów wartościowych, zmiany nie będą widoczne poza ciałem funkcji.

Przekazywanie typów referencyjnych do funkcji przez referencję

Łatwo przewidzieć efekt jaki uzyskamy, przesyłając argumenty typu referencyjnego poprzez referencję. Osiągniemy pełną możliwość modyfikacji instancji obiektu jak i wartości przekazanej referencji. Zmodyfikujmy poprzedni kod dodając ref przed nazwą argumentu:

Tym razem w linijce 25 nadpisujemy referencję nową instancją klasy Osoba. Uzyskujemy pełną kontrolę zarówno nad wartością referencji jak i instancją klasy, na którą wskazuje.

Po drodze w linii 25 gubimy referencję do jednej z instancji. Zostanie to wykryte przez Garbage Collector a obiekt zostanie usunięty ze sterty.

Podsumowanie

Omówione przykłady rozjaśniają nieco koncepcję zarządzania pamięcią w języku C#. Wnioski są następujące:

Przekazywanie typów referencyjnych do funkcji
  • Jeżeli typ referencyjny jest przekazywany przez wartość, możemy zmieniać tylko stan obiektu na który wskazuje
  • Jeżeli typ referencyjny jest przekazywany przez referencję, możemy zmieniać stan obiektu na który wskazuje, oraz wartość referencji.

W kolejnych wpisach poruszę kwestię kopii płytkiej i głębokiej oraz konwersji boxing/unboxing.

Komentarze:

Użytkownik L5k napisał/a:

23 marca 2015


Typy referencyjne przekazywane są do funkcji domyślnie poprzez WARTOŚĆ, a nie referencję (o ile nie użyje się słowa kluczowego „ref”). Masz błąd w podsumowaniu typu referencyjnego – trochę niżej już opisujesz właśnie przekazywanie typów referencyjnych przez wartość (domyśle zachowanie).

Najlepiej widać to obrazując sobie sytuację na stosie i stercie. Tak samo jak w przypadku typów wartościowych podczas wejścia w ciało metody której przekazanym argumentem jest obiekt, na stosie tworzy się kopia zmiennej i przypisywana jest jej wartość czyli de’facto referencja. Dlatego też w momencie inicjalizacji nowego obiektu i przypisaniu go do zmiennej (wewn. metody) nie zmieniamy tej oryginalnej (przekazywanie przez wartość a nie referencję). Po wykonaniu ciała metody stos jest czyszczony i tracimy naszą referencję do utworzonego obiektu w ciele metody.

(odnośnik usunięty)

Użytkownik Karol napisał/a:

23 marca 2015


@L5k zgadza się. Błąd w sztuce, poprawię ten akapit :).

Użytkownik Dawid napisał/a:

23 lutego 2016


Co dzieje się ze zdjętymi danymi ze stosu, kiedy potrzeba dostać się do nich ponownie? Czy jest jakiś drugi stos, na który trafiają? Czy też wracają z powrotem na ten pierwszy, ale w jakiej kolejności?

Chodzi mi o taką sytuację:

int a = 1; //Na stos trafia pierwszy element a=1
int b = 2; //Analogicznie drugi element b=2, który teraz jest wierzchołkiem
a++;

Aby dostać się do zmiennej a, trzeba zdjąć ze stosu zmienną b. Gdzie ona trafia? Przecież w programie można się teraz odwołać również do zmiennej b, której teoretycznie już nie ma.
Pozdrawiam.

Zachęcam Cię do zostawienia komentarza!

Ilość znaków: 0

Zachęcam Cię do polubienia bloga na facebooku! Dając lajka wspierasz moją pracę - wszystkie artykuły na blogu są za darmo!