Przyspieszony kurs C# pod Unity3d dla leniwych i opornych. Dzisiaj: Dziedziczenie i specyfikatory dostępu

Poprzednie lekcje – Spis Treści

Dziedziczenie

Czym jest dziedziczenie? W normalnym życiu jest to otrzymanie czegoś po swoich rodzicach czy dziadkach. W programowaniu… jest dokładnie tak samo. Możesz sobie nawet nie zdawać sprawy z tego, że z dziedziczenia w Unity korzystasz praktycznie cały czas. Pamiętasz ten fragment: class NazwaKlasy : MonoBehaviour { ? Ten dwukropek i MonoBehaviour to właśnie jest dziedziczenie!

Czyli teoretycznie wiesz, jak już je zapisać: class KlasaPotomna : KlasaRodzica { . Dokładnie to jest cała składnia, mówiąca, że: KlasaPotomna dziedziczy po klasie KlasaRodzica. Następuje wtedy relacja rodzic – dziecko. Klasa potomna jest dzieckiem (ang. child), a klasa nadrzędna to klasa rodzica (ang. parent). A co właściwie to oznacza? Że klasa potomna dziedziczy pewne klasy i funkcję po klasie rodzica. Które klasy i funkcję? To zależne jest od specyfikatorów dostępu, które omówimy nieco później.

No dobra, ale do czego nam w ogóle dziedziczenie? Najprościej można to ująć tak: Do redukcji i zwiększenia czytelności kodu. Klasyczny jest tu teraz przykład z pojazdami lub figurami geometrycznymi.

Zaczniemy od pojazdów. Mamy sobie auto, motor, ciężarówkę. Wszystkie można zapisać do grupy pojazdów. Czemu? Bo wszystkie możemy opisać pewnymi parametrami. Np.: Ciężar, liczba kół, prędkość maksymalna, marka, model itp. Pasuje to do każdego. Ale, ciężarówka będzie miała parametr ładowności, auto pojemność bagażnika, gdzie motor nawet nie ma bagażnika, więc ta informacja jest zbędna. Może nie jest to bardzo klarowne, dlatego wyjaśnię teraz na figurach z przykładem.

Weźmy sobie koło, prostokąt i trójkąt. To co je łączy to fakt, że wszystkie są figurami geometrycznymi. Więc w naszym dziedziczeniu, klasa koło dziedziczyłaby po klasie figura, klasa prostokąt, również dziedziczyłaby po klasie figura, tak samo trójkąt. No dobra, ale powiedzenie, że wszystkie są figurami geometrycznymi jest takie mgliste. No bo czemu tak jest? Wynika to z prostego faktu, że koło, prostokąt i trójkąt, mają pewne cechy wspólne: liczbę wierzchołków, pole, obwód, ale własności tych parametrów są nieco inne – tzn. inny jest wzór na pole koła, a inny na pole prostokąta. Po za tym, np. koło ma promień, a prostokąt i trójkąt niekoniecznie.

Prosty przykład. Załóżmy, że chcemy to wszystko upchnąć w jedną klasę, wtedy będzie ona wyglądać mniej więcej tak:

Przykład 1.

using UnityEngine;
using System.Collections;

public class Lekcja_05 : MonoBehaviour {
	
	class Figures {
		
		public double side;
		public double height;
		public double radius;
		public double area;
		public int figType;
		
		public Figures(int f, double a, double h, double r) {
			side = a;
			height = h;
			radius = r;
			figType = f;
		}
		
		private double countArea()
		{
			switch(figType) {
			case 1:
				// Prostokat
				area = side * height;
				break;
			case 2:
				// Trojkat
				area = side * height * 0.5;
				break;
			case 3:
				// Kolo
				area = 3.14 * radius * radius;
				break;
			}
			return 0;
		}

		public double getArea()
		{
			if (area != null) {
				countArea();
			}
			return area;
		}
	}
	
	void Start()
	{
		Figures f1 = new Figures(1, 3, 2, 0);
		Figures f2 = new Figures(2, 2, 2, 0);
		Figures f3 = new Figures(3, 0, 0, 5);
		
		Debug.Log("Prostokat: " + f1.getArea());
		Debug.Log("Trojkat: " + f2.getArea());
		Debug.Log("Kolo: " + f3.getArea());
	}
}

Wygląda nieco potwornie. Jakie to ma wady? Po pierwsze nie jest skalowalne. Skalowalność to cecha kodu, która mówi o tym czy kod jest łatwo rozszerzyć. Tutaj niby się da, ale zauważmy, że dołożenie nowej figury to kolejny case w funkcji pola, a jeśli takich funkcji będzie więcej, w końcu plik będzie miał miliony linijek. Po drugie jest redundatne. Redundancja to inaczej nadmiarowość. Czyli jeśli mamy za dużo zbędnych lub powtarzających się danych, mówimy o redundancji. Tutaj jak widać, koło nie potrzebuje wysokości czy długości boku, więc niepotrzebnie na te dane zajmuje przestrzeń. Po trzecie, prędzej czy później pogubimy się w tych cyferkach określających, która figura to jaki numerek. Mało tego, jeśli chcemy umieścić wszystkie obiekty w jednej tablicy… nie zrobimy tego.

Jednak jeśli zastosujemy dziedziczenie, możemy to rozpisać tak:

Przykład 2.

using UnityEngine;
using System.Collections;

public class Lekcja_05 : MonoBehaviour {
	
	class Figure {
		public double area;
		public string figureName;
		
		public Figure(string fn) {
			figureName = fn;
		}

		public Figure() { }

		public virtual void countArea() { }

		public string getName()
		{
			return figureName;
		}

		public double getArea()
		{
			if (area != null) {
				countArea();
			}
			return area;
		}
	}

	class Circle : Figure {
		public double radius;

		public Circle(string fn, double r) {
			figureName = fn;
			radius = r;
		}

		public override void countArea()
		{
			area = radius * radius * 3.14;
		}
	}

	class Rectangle : Figure {
		public double sideA;
		public double sideB;

		public Rectangle(string fn, double a, double b) {
			figureName = fn;
			sideA = a;
			sideB = b;
		}
		
		public override void countArea()
		{
			area = sideA * sideB;
		}
	}
	
	void Start()
	{
		Figure[] arr = new Figure[3];
		Circle c1 = new Circle("Kolo1", 3);
		Rectangle r = new Rectangle("Prostokat", 2, 5);
		Circle c2 = new Circle("Kolo2", 4);

		arr[0] = c1;
		arr[1] = r;
		arr[2] = c2;

		foreach(Figure f in arr) {
			Debug.Log(f.getName() + ": " + f.getArea());
		}
	}
}

Co nam to daje? Dopisanie nowej figury jest banalnie proste i nie musimy się martwić, że zepsujemy coś w innej. Modyfikacji cech wspólnych dokonujemy w jednym miejscu. Nie mamy masy zbędnych informacji w innych klasach.

Dodatkowo obiekt typu Koło jest dalej obiektem typu Figura, co za tym idzie, jeśli zrobimy sobie tablicę obiektów Koło, możemy w niej upchnąć tylko Koła, ale jeśli zrobimy tablicę obiektów typu Figura, to możemy w niej umieszczać zarówno koła jak prostokąty. Gdzie takie coś będzie miało zastosowanie? Np. przy tworzeniu inwentarza. Każdy przedmiot bez względu na typ, będzie miał cenę, wagę, nazwę, opis. Więc każdy przedmiot w grze, może dziedziczyć po jakiejś nadrzędnej klasie, a nasz inwentarz będzie tablicą obiektów tego typu. Tym sposobem upchniemy tam miecz, chleb i roślinkę alchemiczną, bez względu na to, że mają inne zastosowanie.

Dwie rzeczy do zapamiętania. Raz, jeśli będziemy robić sobie takie dziedziczenie i wykorzystywać konstruktory, klasa nadrzędna (rodzic) musi dysponować konstruktorem bezargumentowym, czyli w naszym wypadku tym:

public Figure() { }

Jeszcze jedna dziwna rzecz jaką można zobaczy to te dwie linijki:

public virtual void countArea() { }
(...)
public override void countArea()

Co tu się dzieje? W klasie figura stworzyliśmy sobie metodę wirtualną. Jest to funkcja z grubsza jak każda inna, z pewną różnicą. Można ją przysłonić (nadpisać). Czyli, gdy w funkcji Figura, mamy sobie naszą funkcję wirtualną będzie sobie ona ładnie działać. Ale jeśli najdzie nas ochota, to w klasie dziedziczącej, możemy ją przysłonić dodając słówko override. Czyli jak to działa?

Gdy w kodzie wywołamy funkcję countArea dla obiektu klasy Koło, to jeśli ma on swoją przysłoniętą wersję funkcji countArea, to ona się wykona. Jeśli nie ma, to wykona się ta z klasy Figura, czyli z klasy rodzica.

Lepszym rozwiązaniem byłoby wykorzystanie klas abstrakcyjnych, ale to omówimy w następnej lekcji.

Jednak jak patrzysz na kod, widzisz pewnie powtarzające się słowa private, public i zachodzisz w głowę, o co chodzi?

Specyfikatory dostępu

Wspomniane słówka private i public, mają jeszcze trzeciego kolegę o nazwie protected. Ich zastosowanie jest wręcz banalne:

  • Public – Jeśli funkcja/zmienna jest publiczna, dostęp do niej mają wszyscy. Tzn. możemy z niej korzystać wewnątrz tego obiektu, jak i na zewnątrz. Przykładowo gdy mamy funkcję getName(), i jest ona publiczna, to jak teraz zrobimy sobie instancję naszej klasy: MojaKlasa mk = new MojaKlasa(), to dzięki temu, że funkcja jest publiczna, można ją wywołać przez: mk.getName(). Analogicznie ze zmienną. Publiczne zmienne i funkcję są dziedziczone.
  • Private – Przeciwieństwo public. Funkcja/Zmienna jest dostępna tylko wewnątrz klasy, w której została zadeklarowana. Czyli wewnątrz klasy możemy z nich korzystać normalnie. Ale wykonanie takie mk.getName() zwróci błąd. Prywatne zmienne i funkcję nie są dziedziczone.
  • Protected – To jest coś pomiędzy. Dalej zachowuje się jak private, z tą różnicą, że chronione zmienne i funkcję są dziedziczone. Czyli potem ma do nich dostęp. W potomku są już traktowane jak zmienne prywatne. Czyli nie wyjdą na zewnątrz, ale da się z nich korzystać.

Przykład3.

Tan kawałek kodu polecam przepisać i zwracać uwagę na to co podpowiada MonoDevelop, bo już wtedy bardzo widać zależności.

using UnityEngine;
using System.Collections;

public class Lekcja_05 : MonoBehaviour {
	
	class Parent {
		private float a = 1;
		protected float b = 2;
		public float c = 3;
	}

	class Child : Parent {

		private float ac = 4;
		protected float bc = 5;
		public float cc = 6;

		public float getB()
		{
			return b;
		}
	}
	
	void Start()
	{
		Parent p = new Parent ();
		Child c = new Child ();

		Debug.Log(p.c);
		Debug.Log(c.c);
		Debug.Log(c.cc);
		Debug.Log(c.getB ());
	}
}

Tak jak opisywałem. Do zmiennych publicznych mamy łatwy dostęp i są one dziedziczone. Do zmiennych prywatnych nie ma dostępu z zewnątrz. Do zmiennych typu protected, dostęp mamy tylko wewnątrz klasy która tego typu zmienne dziedziczy, dla klasy w której takie zmienne zostały zdeklarowane, traktowane są jak zmienne typu private.

W sumie tyle. Myślę, że przykład dobrze pokazuje o co chodzi.

Teraz zostaje pytanie, kiedy jaki typ stosować? W Unity3d mamy tak, że zmienne publiczne można edytować z poziomu Unity, więc czasami decyduje o tym to, jakimi parametrami level designer może manipulować. Przykładowo może mu się przydać szybkość poruszania postaci czy maksymalny poziom życia, ale nie obecny. Z drugiej strony w czasie testów, opcja wymuszenia sytuacji gdzie gracz ma np. 1 punkt życia może być pomocna. Więc tutaj dużo zależy od sytuacji.

Jednak jeśli chodzi o samo dziedziczenie, jako programiści zawsze dążymy do enkapsulacji. Czyli inaczej hermetyzacji kodu. Zasada sprowadza się do tego, że jeśli inne klasy nie muszą mieć dostępu do jakiejś zmiennej lub funkcji to go nie powinny dostać. Naszym domyślnym selektorem powinno być private. Na public decydujemy się wtedy, kiedy jakieś dane mogą wyjść na zewnątrz. Częstą praktyką jest ustawianie wszystkich zmiennych na private i  umożliwianie dostępu do nich przez mutuatory, czyli popularne getery i seterey – przy czym czasami narobi nam to więcej kodu niż to potrzebne, więc warto stosować to rozważnie.
[stextbox id=”info” defcaption=”true”]Geter i Setery to funkcję zbudowane: getNazwaZmiennej() i setNazwaZmiennej(value). Służą one jedynie do ustalenia i pobrania wartości zmiennej. Przykładowo:

private int myValue;

public void setMyValue(int val)
{
    myValue = val;
}

public int getMyValue()
{
    return myValue;
}

Tak to wygląda w większości języków programowania, ale w C# jest nieco śmieszniej:

private string myValue;  
public string MyValue 
{
	get 
	{
		return myValue; 
	}
	set
	{
		myValue = value;
	}
}

Trochę pokrętnie, bo najpierw deklarujemy zmienną jako prywatną, a potem tworzymy coś na kształt jej konstruktora. Oczywiście po za samym zwróceniem wartości czy jej ustawieniem, możemy zrobić sobie coś jeszcze w kodzie. Ciekawa jest zmienna value, która istnieje, ale nigdzie jej nie deklarujemy. Jest to wartość, która jest przypisywana:

Parent p = new Parent ();
p.MyValue = "Jakis Tekst";
Debug.Log(p.MyValue);

W tym przypadku “Jakis Tekst” jest zmienną value.[/stextbox]

Rzutowanie typów

Z racji, że te tematy były raczej proste i przyjemne, dodaje jeszcze jedno zagadnienie, które często się przydaje. Rzutowanie typów, to nic innego jak zamiana jednego typu zmiennej na drugi. Np. int na float, float na string etc. Nie zawsze jest to możliwe, bo np. stringa: “jakis tekst”, na liczbę nie zmienimy. Ale stringa “80”, jak najbardziej.

Jak to się robi? Głównie zależy to od tego jaki typ zmiennej chcemy przekształcić na jaki. Najprościej przekształca się każdy z typów na typ stringowy.

using UnityEngine;
using System.Collections;

public class Lekcja_05 : MonoBehaviour {

	void Start()
	{
		string txt = "Jakis Tekst";
		int i = 5;

		Debug.Log (txt + " " + i);
	}
}

Wystarczy dowolny string połączyć plusem ze zmienną innego typu i dostajemy… dłuższego stringa.

Dość prosto jest w przypadku przekształcania jednego typu liczbowego na drugi.

using UnityEngine;
using System.Collections;

public class Lekcja_05 : MonoBehaviour {

	void Start()
	{
		int a = 5;
		float b = 2.3f;
		double c = 7.8;

		// Int na float i double
		float b1 = a;
		double c1 = a;

		// float na double i int
		int a2 = (int)b;
		double c2 = b;

		// double na int i float
		int a3 = (int)c;
		float b3 = (float)c;


		Debug.Log (a + " =  f: " + b1 + "  d: " + c1);
		Debug.Log (b + " =  i: " + a2 + "  d: " + c2);
		Debug.Log (c + " =  i: " + a3 + "  f: " + b3);
	}
}

Jak widać, czasami musi się pojawić rzutowanie czyli nazwa typu na który chcemy zmienić w nawiasie. Kiedy tak się dzieje? Kiedy typ na który rzutujemy jest mniejszy od tego, z którego rzutujemy. Jeśli chodzi o zajmowaną pamięć i zakres liczb, wygląda to tak: int < float < double. Więc gdy rzutujemy z double na int musimy jawnie określić rzutowanie.

Dodatkowo, jeśli dokonamy rzutowania na typ o innej precyzji (float to pojedyncza precyzja, double podwójna, a int nie nie ma liczb po przecinku), to tracimy część danych. Gdy zrzutujemy float o wartości 4.2 na inta, to w incie będzie już tylko: 4, bo nie obsługuje liczb po przecinku.

Najtrudniejsza sprawa to przekształcenie stringa na inny format, właśnie przez to o czym wspominałem. W stringu, może być dowolna treść, a nie chcemy zrobić z tekstu liczby.

using UnityEngine;
using System.Collections;

public class Lekcja_05 : MonoBehaviour {

	void Start()
	{
		int a = 3;
		string b = "5";
		int b1;
		int.TryParse (b, out b1);
		int c = a + b1;

		Debug.Log (c);
	}
}

Wykorzystujemy tutaj funkcję TryParse z obiektu danego typu. Tak samo działa to dla double czy float. Ważne, żeby pamiętać o słówku out, oraz posiadaniu zmiennej do której przekażemy wynik parsowania.

Ciekawe jest to, że da się też rzutować typy złożone na typy proste. Tzn. możesz zrzutować swoją klasę np. do inta.

public static implicit operator int(MyClass instance) 
{
    if (instance == null) 
    {
        return -1;
    }
    return instance._underlyingValue;
}

Po co to robić? Możesz np. ustawić sobie rzutowanie przedmiotów na inty, wtedy pisząc kod handlu nie musisz wyciągać z broni wartości przez funkcję, tylko rzutujesz całą klasę do zmiennej typu int.

Zadanie domowe

  1. Wymyśl i zaprojektuj 5 własnych klas, gdzie:
    • Jedna klasa będzie nadrzędna
    • Trzy klasy będą dziedziczyć po tej klasie nadrzędnej
    • Ostatnia klasa, będzie dziedziczyć po jednej z trzech klas
    • Idea istnienia klas powinna być realna, sensowna i użyteczna – tzn. nie mogą to być klasy: A, B, C, D, E, tylko coś w rodzaju: Figugry -> Kwadrat -> Prostokąt, Koło, Trójkąt. Oczywiście ten wariant też odpada.
    • Skorzystaj ze wszystkich poznanych specyfikatorów dostępu oraz metod wirtualnych.
    • Zbierz wszystkie klasy w pętli i wypisz ich jakieś wspólne parametry.

Klasa i Funkcja <- Poprzednia lekcja

Następnya lekcja -> Klasy abstrakcyjne i zmienne statyczne