40-783: Wprowadzenie do Reference Types

W ostatnim tygodniu udało mi się dokończyć trzeci rozdział książki MCSD Certification Toolkit. Wyszło mi tyle notatek, że na ich podstawie powstaną trzy wpisy. Dzisiejszy stanowi wstęp do Reference Types. Następny będzie o typach generycznych. Ostatni będzie zawierać opisy słów kluczowych związanych z Reference Types.

Poza tym postanowiłem zmienić koncepcję publikacji wpisów i nie będę robić tygodniowych podsumowań. Wpisy będą się teraz pojawiać w miarę przerabiania poszczególnych (ale mniejszych niż do tej pory) zagadnień.

Wstęp do Reference Types

O ile Value Types są wartościami na stosie, o tyle Reference Types żyją na stercie (heap). Nie operujemy na nich bezpośrednio – posługujemy się referencjami, które wskazują na miejsce w pamięci gdzie dany “byt” jest zaalokowany. W przeciwieństwie do Value Types, może istnieć wiele referencji wskazujących na ten sam “byt”.

object firstObject = new object();
object otherReferenceToFirstObject = firstObject;

Jeśli chodzi o same referencje, to do metod przekazuje się je tak samo jak w Javie, czyli przez kopię. Oczywiście pamiętamy, że kopia referencji a kopia obiektu to dwie zupełnie inne rzeczy.

Z grubsza rzecz biorąc do “królewstwa” Reference Types należą:

  • Klasy
  • Intefejsy
  • Delegaty

O delegatach na pewno będzie jeszcze okazja porozmawiać. Na ten moment warto tylko zapamiętać, że jest to referencja na metodę. Najbliższym Javowym “krewnym” byłby functional interface i to co możemy z nim robić, ale nie jest to dokładnie to samo co delegat.

Properties

Istnieje w Javie standard zwany Java Beans. Definiuje on specyficzną konwencję dostępu do pól wewnątrz klasy. Polega to na tym, że definiujemy prywatne pole w klasie oraz spełniające odpowiednią konwencję nazewniczą metody nazywane setterem i getterem. Metody te, zwane razem akcesorami, służą nam do manipulowania tym prywatnym polem. W C# odpowiednikiem Java Beans jest mechanizm propertiesów.

Property jest czymś co wygląda i z czego składniowo korzysta się tak jak ze zwykłej, publicznej, zmiennej. Tak naprawdę jednak, pod spodem, jest to wywołanie metod akcesorów. Ponieważ properties nie są traktowane jako zmienne, nie możemy ich przekazywać z użyciem ref lub out (o tych dwóch słowach kluczowych kiedy indziej).

Properties stosujemy jako regulator dostępu do prywatnego pola (nazywanego “backing field”). Nie musi to być oczywiście dostęp bezpośredni – property może być jakimś widokiem na stan wewnątrz obiektu, może dokonywać kalulacji, itp.

Oto przykład deklaracji property wewnątrz klasy:

public class Person
{
	private string firstName;

	public string FirstName
	{
		get
		{
			return firstName;
		}
		set
		{
			firstName = value;  
		}
	}
}

A tak możemy go użyć:

Person person = new Person();
person.FirstName = "John"; // wartość "John" będzie przekazana do settera jako zmienna "value"
string a = person.FirstName;

Properties mogą być użyte z następującymi modyfikatorami dostępu:

  • public
  • private
  • protected
  • internal
  • protected internal

Property może być statyczne. Może być też abstrakcyjne – a co za tym idzie – później możemy je nadpisać z użyciem override, lub też zablokować nadpisywanie za pomocą sealed. Jeśli już jesteśmy przy nadpisywaniu propertiesów, to property można zdefiniować w interfejsie – oto przykład:

public interface InterfaceWithProperty
{
	public string Foo { get; set; }
}

Wracając do modyfikatorów dostępu – domyślnie metody get i set mają tą samą widoczność co cały property. Jest możliwość aby zmienić widoczność akcesora. Ale, żeby to zrobić musimy spełnić dwa warunki:

  1. Property musi deklarować oba akcesory
  2. Widoczność możemy zmienić tylko jednemu z akcesorów

Nie mamy też pełnej dowolności w zmianie widoczności gettera lub settera. Po pierwsze akcesor nie może “poluźnić” modyfikatora całego property. Po drugie nie możemy zmienić widoczności nadpisywanych akcesorów z property, które odziedziczyliśmy z base klasy. Z drugiego punktu wynika generalnie, że nie możemy zmienić widoczności akcesorów, które odziedziczyliśmy z interfejsu. Jedynym wyjątkiem od tej reguły jest sytuacja, gdy interfejs definiował tylko jeden akcesor – wtedy my możemy w naszej klasie zdefiniować drugi i zmienić mu widoczność.

Oto prosty przykład zmiany widoczności akcesora typu setter na private

public class PrivateSetter
{
	int someValue;

	public int SomeValue
	{
		get
		{
			return someValue;
		}

		private set
		{
			someValue = value;
		}
	}
}

Różnica pomiędzy prywatnym setterem a brakiem settera jest taka, że prywatny setter jest dostępny wewnątrz wszystkich metod klasy ale nie poza nią. Brak deklaracji settera sprawia, że przypisanie możemy zrobić tylko i wyłącznie w konstruktorze tej klasy.

Jeżeli property ma służyć jedynie do prostego zapisania i odczytania backing field’u, to możemy skorzystać z automatycznie generowanych akcesorów przy użyciu poniższej składni:

public string Property { get; set; }

Składnia ta spowoduje wygenerowanie ciała akcesrów oraz prywatny, anonimowy, backing field do którego dostęp mamy tylko po przez property. W C# 6 doszła konstrukcja dzięki której taki backing field możemy odrazu zainicjalizować:

public string Property { get; set } = "test";

Jeżeli property ma być read-only, to możemy też lambdę:

public string Property => "A" + "B";

Indexers

O ile składnia propertiesów daje się przełożyć na Javową koncepcje setterów i getterów, o tyle nie mamy w Javie czegoś zbliżonego do tzw. Indekserów. Indexer, jest to coś takiego, co pozwala nam traktować obiekt jakby był tablicą, tj. pozwala nam wejść w interakcje z wenętrzntm stanem obiektu za pomocą operatora nawiasów kwadratowych.

Definicja indeksera wygląda następująco:

public class LikeAnArray
{
	private int[] arr = new int[10];

	public int this[int index]
	{
		get
		{
			return arr[index];
		}

		set
		{
			arr[index] = value;
		}
	}
}

Jak widać składnia jest całkiem podobna do deklaracji propertiesów. Obiekt takiej klasy możemy używać tak jakby to była tablica:

LikeAnArray notReallyArray = new LikeAnArray();
notReallyArray[1] = 10; 
Console.WriteLine(notReallyArray[1]); 

Jeśli chodzi o inne podobieństwa do propertiesów to jest ich kilka. Po pierwsze indekserów nie uznaje się za zmienne – odpada więc ich przekazywanie jako argumenty ref/out. Po drugie indekser również może być częścią interfejsu. Po trzecie podobnie jak w przypadku propertiesów, jeżeli chcemy mieć tylko getter, to możemy użyć zwięzłej, opartej o lambdy, składni:

public class LikeAnReadOnlyArray
{
	private int[] arr = new int[10];

	public LikeAnReadOnlyArray()
	{
		for (int i = 0; i < 10; ++i) { arr[i] = i; } 
	} 

	public int this[int i] => arr[i];
}

Z drugiej strony indeksery nie posiadają składni autogenerowanych metod akcesorów. Indekser nie może być też statyczny

Wcześniejsze przykłady pokazywały indeksery przyjmujące argument w postaci inta. Nic jednak nie stoi na przeszkodzie, żeby użyć innego typu – to już nasza sprawa jak to sobie obsłużymy. Co więcej – indeksery można przeciążać, tak więc jeden obiekt może obsługiwać indeksy pod wieloma postaciami. Oto przykład:

public class LikeAnArray
{
	private int[] arr = new int[10];

	public int this[int index]
	{
		get
		{
			return arr[index];
		}

		set
		{
			arr[index] = value;
		}
	}

	public int this[string index]
	{
		get
		{
			return arr[Int32.Parse(index)];
		}

		set
		{
			arr[Int32.Parse(index)] = value;
		}
	}
}

Problem pojawi się w momencie, gdybyśmy chcieli, żeby nasze przeciążone indeksery zwracały różne typy danych. Jak wiadomo typ zwracany nie jest częścią sygnatury metody, więc nie da się zdefiniować, przykładowo, dwóch indekserów przyjmujących inta ale zwracających różne typy. Musielibyśmy rozróżnić je typem parametrów wejściowych.

Jeśli możliwość przeciążania indeksera to wciąż dla was mało, to co powiecie na to, że indeksery, mogą definiować wiele parametrów na raz? Tym sposobem nasze obiekty mogą “udawać”, że są tablicami wielowymiarowymi:

public class LikeA2DArray
{
	private int[,] arr2d = new int[10, 10];

	public int this[int i, int j]
	{
		get
		{
			return arr2d[i, j];
		}

		set
		{
			arr2d[i, j] = value;
		}
	}
}

 

Named arguments, optional arguments

Kolejne składnie, których próżno szukać w Javie to named arguments oraz optional arguments.

C# pozwala nam na przekazywać argumenty do metody na dwa sposoby. Standardowo możemy to zrobić tak, że przekazujemy argumenty w takiej samej kolejności w jakiej są zdefiniowane parametry w sygnaturze metody. Nazywamy to “positional arguments”. Druga składnia zakłada, że każdy argument poprzedzimy nazwą parametru. Nazywamy to “named arguments”. Składnia ta pozwala na zmianę kolejności parametrów zadeklarowanej w sygnaturze metody. Przy metodach, które przyjmują bardzo dużą liczbę argumentów możemy w ten sposób zyskać na czytelności. Język pozwala nam na mieszanie obu składni w jednym wywołaniu metody, ale warunek jest taki, że positional arguments zawsze muszą być wcześniej.

Dla następującej metody:

public static double Multiply(double a, double b)
{
	return a * b;
}

Wywołanie z użyciem named arguments mogłoby wyglądać tak:

Multiply(a: 20, b: 30);

Optional arguments to, jak sama nazwa wskazuje, argumenty które mogą być pominięte przy wywołaniu metody. Dzieje się tak dlatego, że w sygnaturze, oprócz nazwy i typu, podajemy też domyślną wartość, która zostanie użyta, jeśli podczas wywołania nic nie przekazaliśmy.

Przykład tej samej metody co przed chwilą, ale tym razem oba parametry mają zdefiniowaną domyślną wartość:

public static double Multiply(double a = 0.0, double b = 0.0)
{
	return a * b;
}

I wywołanie z pominięciem obu argumentów.

Multiply();

Argumenty opcjonalne muszą być zawsze zdefiniowane po argumentach wymaganych (required arguments).

Jeżeli wywołujemy metodę używając positional arguments, to istnieje dodatkowe ogranicznenie. Jeśli podaliśmy wartość dla argumentu opcjonalnego, to wszystkie inne opcjonalne argumenty, które występują w sygnaturze metody wcześniej, również muszą mieć podane wartości.

Jeżeli wywołujemy metodę używając named arguments, to powyższa reguła nie obowiązuje. Wtedy widać prawdziwą siłę named i optional arguments, ponieważ możemy dowolnie pomijać argumenty i zmieniać ich kolejność podczas wywoływania metody.

Występowanie named i optional arguments ma wpływ na to, która overloadowana wersja metody zostanie wybrana podczas kompilacji. Z mojego punktu widzenia najważniejsza zasada jest taka, że jeżeli do wywołania pasują dwie wersje metody – jedna z argumentem opcjonalnym a druga bez, to zawsze wybrana będzie ta, która ma mniej parametrów.

Extension methods

Extension methods to konstrukcja w Javie nieznana. Jej celem jest danie nam możliwości dopisania metod do już istniejącego typu, bez konieczności ingerencji w jego kod. Możemy rozszerzać dowolne typy, w tym value types. Prawdopodobnie najbardziej znanym przykładem wykorzystania tego mechanizmu jest LINQ, który dodaje w ten sposób wiele metod do standardowych kolekcji (o LINQ kiedy indziej).

Mimo, że metody te wywołuje się tak jakby to były zwykłe instance methods, to tak naprawdę są to metody statyczne. Z tego wynika, że nie mamy dostępu do wnętrza typu, który rozszerzamy. Wynika z tego również, że mechanizm przeciążania nie ma tu zastosowania. Dodatkowo nasza extension method nigdy nie zostanie wykonana jeśli w typie już istnieje metoda z taką samą sygnaturą.

Aby dodać extension method musimy sobie stworzyć statyczną klasę ze statyczną metodą, której pierwszy parametr będzie poprzedzony słowem kluczowym this. Typ tego parametru to typ do którego “dopisujemy się” z metodą.

public static class MyIntExtension
{
	public static int square(this int value)
	{
		return value * value;
	} 
}

Aby móc użyć naszej nowej metody, musimy importować namespace w którym znajduje się nasza extension method (robi się to słowem kluczowym using). Oczywiście nasza statyczna klasa (i metoda wewnątrz) musi być widoczna z punktu widzenia kodu, który chce użyć naszej extension method.

Kod źródłowy

Na dzisiaj wystarczy. Oto projekt na githubie gdzie można znaleźć przykłady, rzeczy które zostały omówione w artykule.

https://github.com/mprzybylak/minefields-csharp/tree/master/typesystem

Źródła

  1. T. Covaci – MCSD Certification Toolkit (Exam 70-483): Programming in C# – rodział 3: „Working with the Type System”
  2. Reference Types (C# Reference)
  3. Properties (C# Programming Guide)
  4. Using Properties (C# Programming Guide)
  5. Auto-Implemented Properties (C# Programming Guide)
  6. How to: Implement a Lightweight Class with Auto-Implemented Properties (C# Programming Guide)
  7. out (C# Reference)
  8. ref (C# Reference)
  9. Passing Arrays Using ref and out (C# Programming Guide)
  10. Passing Reference-Type Parameters (C# Programming Guide)
  11. Indexers (C# Programming Guide)
  12. Comparison Between Properties and Indexers (C# Programming Guide)
  13. Indexers in Interfaces (C# Programming Guide)
  14. Named and Optional Arguments (C# Programming Guide)
  15. Extension Methods (C# Programming Guide)
  16. How to: Implement and Call a Custom Extension Meethod (C# Programming Guide)
  17. How to: Create a New Method for an Enumeration (C# Programming Guide)