Typy wartościowe i referencyjne

Ostatania modyfikacja: 11 marca 2019, 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:

ValueType a = true;
ValueType b = 5;

Console.WriteLine(a.GetType()); //System.Boolean
Console.WriteLine(b.GetType()); //System.Int32

int c = (int)b; // wszystko ok

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

Istnieje pewien wyjątek, kiedy typ wartościowy zostanie umieszczony na stercie a nie na stosie. Dzieje się tak, jeżeli zostanie on opakowany w obiekt. Powód jest prosty: jeżeli typ prosty jest np. atrybutem klasy, nie możemy usunąć go ze stosu, gdy instancja klasy istnieje jeszcze na stercie i być może będzie używana. Pozostałe przypadki to np. tzw. boxing/unboxing, funkcje anonimowe, delegaty, iteratory oraz domknięcia. We wszystkich wymienionych przypadkach powód jest ten sam, czyli jawne (lub niejawne) opakowanie typu prostego w obiekt.

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:

int wiek1 = 20;
int wiek2 = wiek1;

Console.WriteLine(wiek1); // wyswietli 20
Console.WriteLine(wiek2); // wyswietli 20

wiek2 = 50;

Console.WriteLine(wiek1); // wyswietli 20
Console.WriteLine(wiek2); // wyswietli 50

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:

class Osoba
{
	public String imie { get; set; }
	public Osoba(string imie) { this.imie = imie; }
	public void Info() { Console.WriteLine("imie: {0}", imie); }
}

class Program
{
	static void Main(string[] args)
	{
		Osoba osoba1 = new Osoba("Karol");
		Osoba osoba2 = osoba1;

		Console.WriteLine(osoba1.imie); // wyswietli Karol
		Console.WriteLine(osoba1.imie); // wyswietli Karol

		osoba1.imie = "Karol";
		osoba2.imie = "Arek";

		Console.WriteLine(osoba1.imie); // wyswietli Arek
		Console.WriteLine(osoba1.imie); // wyswietli Arek

		Console.ReadKey();
	}
}

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:

class Osoba
{
	public String imie { get; set; }
	public Osoba(string imie) { this.imie = imie; }
	public void Info() { Console.WriteLine("imie: {0}", imie); }
}

class Program
{
	static void Main(string[] args)
	{
		Osoba osoba1 = new Osoba("Karol");
		osoba1.Info(); // imie: Karol

		Zmien(osoba1);
		osoba1.Info(); //imie: Arek

		Console.ReadKey();

	}

	public static void Zmien(Osoba o)
	{
		o.imie = "Arek";         // zadziała
		o = new Osoba("Maciek"); // nie zadziała
	}
}

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:

class Osoba
{
	public String imie { get; set; }
	public Osoba(string imie) { this.imie = imie; }
	public void Info() { Console.WriteLine("imie: {0}", imie); }
}

class Program
{
	static void Main(string[] args)
	{
		Osoba osoba1 = new Osoba("Karol");
		osoba1.Info(); // imie: Karol

		Zmien(ref osoba1);
		osoba1.Info(); //imie: Maciek

		Console.ReadKey();

	}

	public static void Zmien(ref Osoba o)
	{
		o.imie = "Arek";         // zadziała
		o = new Osoba("Maciek"); // także dzaiała!
	}
}

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.

Użytkownik L5k napisał:

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ł:

23 marca 2015


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

Użytkownik Dawid napisał:

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.

Użytkownik anon napisał:

28 czerwca 2017


ładnie i przejrzyści wytłumaczone, pozdrawiam serdecznie

Użytkownik Jarek napisał:

29 września 2017


Warto dodać, że string choć jest typem referencyjnym, to został w nim przeciążony znak przypisania i dzięki temu powstaje nowa kopia obiektu.Tak więc, zachowuje się nie jak typ referencyjny, lecz jak typ wartościowy.

string aa = "ala";
string bb = aa;
bb = bb + "ff";
Console.WriteLine("aa: {0}", aa); //wyświetli: ala
Console.WriteLine("bb: {0}", bb); //wyświetli alaff

Użytkownik Maciej napisał:

14 marca 2018


Świetny artykuł, zrozumiałem to co na studiach było tłumaczone w beznadziejny sposób. Najistotniejszą kwestią w celu zrozumienia było tutaj wytłumaczenie alokowania pamięci na stosie i stercie.

Użytkownik Bartek napisał:

14 grudnia 2018


Jak zwykle świetny artykuł. Doskonale ujmujący istote zagadnienia.

Użytkownik Janek napisał:

11 marca 2019


Witaj,

Niestety w artykule jest poważny błąd. To nie prawda że wartości typów „wartościowych” są zawsze przechowywane na stosie.
Co w sytuacji kiedy tworzymy pole typu int w klasie i tworzymy obiekt tej klasy? Gdzie ląduje ta wartość pola? Oczywiście że na stercie tam gdzie tworzony jest obiekt całej klasy. Proponuję poprawić to jak najszybciej bo wprowadza w błąd.
Proponuję sobie zerknąć tutaj: https://stackoverflow.com/questions/1932155/why-value-types-are-stored-onto-stacks

Pozdrawiam

Użytkownik Karol napisał:

11 marca 2019


@Janek
Cześć, dodałem wzmiankę o typach prostych w klasach.

Zachęcam Cię do zostawienia komentarza!

Ilość znaków: 0