Blog

Golang dla hobbystów. Struktury.

W ostatnim wpisie z cyklu "Golang dla hobbystów" na warsztat weźmiemy struktury.

Golang dla hobbystów. Struktury.

Jak już wspomniałem w poprzednim moim wpisie, w Go nie ma klas ani de facto obiektów, a tym samym nie ma również tematu dziedziczenia jako takiego. Tym niemniej Go mimo wszystko zapewnia w mniejszym lub większym stopniu wszystko, co jest niezbędne do wcielania w życie idei kojarzonych z programowaniem obiektowym. W przypadku GO te wszystkie kwestie są zwyczajowo są najbardziej kojarzone z zagadnieniem struktur (choć oczywiście nie tylko).

Tym niemniej dzisiejszy tekst nie ma ambicji rozłożenia tej problematyki na czynniki pierwsze poprzez wskazanie różnic i podobieństw w stosunku GO do "tradycyjnych" implementacji tego paradygmatu w programowaniu. Główną przeszkodą w tym zakresie jest fakt, iż nie czuję się - i to jest bardzo delikatne określenie - ekspertem w dziedzinie. Rzecz jasna w moich dyletanckich koderskich wprawkach nie raz i nie dwa korzystałem z praktycznych dobrodziejstw związanych z tym sposobem tworzenie programów, choć zazwyczaj bardziej korzystając z gotowych rozwiązań niż tworzącą własne. Tym samym nie chcę się narazić na śmieszność “kopiując” czyjeś uwagi oraz przemyślenia na temat praktycznej realizacji problemu hermetyzacji kodu czy polimorfizmu  w GO (tak po prawdzie ta ostatnia kwestia bardziej dotyczy zagadnienia interfejsów niż struktur). Zamiast tego chciałbym zaproponować - zgodnie z ideą przewodnią tego cyklu - omówienie absolutnych podstaw kwestii struktur w GO. Tym niemniej postaram się poruszyć wszystkie elementarne kwestie z tym związane, których opanowanie powinno pozwolić na swobodne korzystaniem w praktyce ze struktur w większości podstawowych przypadków. Dlatego w dalszej części niniejszego wpisu spróbuję zaprezentować sposoby tworzenia struktur, odwoływania się do ich pól czy - już na sam koniec - dowiązania do nich nowych metod.

Tworzenie struktur

Zanim przejdziemy do omówienia dwóch sposobów, w jaki zwyczajowo tworzy się struktury w GO, słowo przypomnienia - wspominałem już o tym przy innej okazji - czym różnią się one od takich kolekcji jak mapy czy wycinki/tablice. Te ostatnie są zbiorami homogenicznych danych, czyli ich konkretne implementacji mogą przechowywać wartości jednego, tego samego typu. Rzecz jasna mogą to być łańcuch znaków, liczby całkowite czy zmiennoprzecinkowe albo zbiory innych map czy tablic. Tym niemniej w ramach - dajmy na to - jednej tablicy nie możemy żonglować różnymi typami danych. Jeśli określona tablica przechowuje jako elementy liczby całkowite, to nie możemy dodać do niej na ten przykład żadnego ciągu znaków czy nawet licz zmiennoprzecinkowych, ponieważ typ przechowywanych danych są nierozłączną częścią ich deklaracji. To samo z resztą dotyczy map. Natomiast struktury są wolne od tych ograniczeń, czyli umożliwiają pogrupowanie ze sobą różnych wartości czy też elementów składowych w jednej kolekcji danych.

Do tworzenie struktur trzeba używa się słowa kluczowego struct, po którym w nawiasach klamrowych podawane są nazwy poszczególnych pól wraz ze wskazaniem jakiego rodzaju wartości mogą być do tego konkretnego pola przekazane. Niżej przykład kodu, gdzie została utworzona zmienna “Osoba” o typie struktura:

package main

import "fmt"

var Osoba struct {
	imie     string
	mężczyna bool
	wiek     int
}

func main() {
	fmt.Println(Osoba)
}

Ponieważ w tym przypadku jedynie zadeklarowaliśmy naszą zmienną bez jednoczesnego przekazania do niej konkretnych wartości, kompilator GO dla każdego z trzech pól przypisał odpowiadające im - ze względu na typ danych - wartości zerowe, czyli w efekcie użycia funkcji fmt.Println otrzymaliśmy kolejno: pusty łańcuch znaków (dla typu string), wartość false (dla typu bool) oraz zero (dla typu integer).

Jeśli chcielibyśmy dla tak utworzonej zmiennej przekazać wartości do odpowiednich pól należy użyć notacji "kropkowej" (analogicznie jak chociażby przy wywoływaniu metod, o czym była mowa w poprzednim wpisie). Tym samym należy podać nazwę naszej zmiennej, po której dodajmy kropkę i dalej nazwę interesującego nas pola. W poniższym przykładzie widać dwukrotne skorzystanie z takiej notacji: raz by przypisać do pola “imię” nową wartość, zaś drugi raz w wywołaniu funkcji fmt.Println, by wyświetlić interesującą nas wartość w terminalu:

package main

import "fmt"

var Osoba struct {
	imie     string
	mężczyna bool
	wiek     int
}

func main() {
	Osoba.imie = "Rafał"
	fmt.Println(Osoba.imie)
}

Jeśli naszym zamiarem jest częstsze używanie jakiejś struktury w kodzie naszego programu, znacznie lepszym rozwiązaniem wydaje się zdefiniowanie nowego typu, który będzie odpowiadał pożądanej charakterystyce danych. Ma to  nie tylko ten niewątpliwy plus, iż przy tworzeniu kolejnych zmiennych, które mają zachować “definicję” takiej struktury, nie musimy bez końca dokonywać deklaracji wszystkich pól. Takie podejście pozwala na używanie literałów struktury dla inicjalnego uzupełnienia wartości dla poszczególnych pól. Takie podejście jest znacznie bardziej ergonomiczne niż dokonywanie takich przypisywań za pomocą notyfikacji kropkowej.

package main

import "fmt"

type Osoba struct {
	imie     string
	mężczyzna bool
	wiek     int
}

func main() {
	Borciugner := Osoba{"Rafał", true, 44}
	potomekBorciugnera := Osoba{
		imie:     "Mateusz",
		mężczyzna: true,
		wiek:     12,
	}

	fmt.Println(Borciugner.imie)
	fmt.Println(potomekBorciugnera.imie)
}

W powyższym przykładzie najpierw zdefiniowaliśmy nowy typ o nazwie “Osoba”, któremu odpowiada struktura zawierająca trzy pola. W ramach funkcji main powołaliśmy do życia dwie zmienne (“Borciugner” oraz “potomekBorciugnera”) na bazie właśnie tego nowo utworzonego typu. Dla każdej z tych dwóch zmiennych "uzupełniliśmy" wartości pól przy użyciu tzw. literału struktury. Tym niemniej w przypadku każdej ze zmiennych wyglądało to nieco inaczej. Za pierwszy razem (dla zmiennej "Borciugner") po prostu wymienione zostały w odpowiedniej kolejności pożądane wartości, zaś w drugi przypadku mamy do czynienia z rozwiązaniem przypominającym to, co powinniśmy znać, gdy używamy map. Dla zmiennej  “potomekBorciugnera” w nawiasach klamrowych podana została podana za każdym razem para “klucz” (nazwa pola) oraz powiązana z nią wartość. Preferencje co do wyboru sposobu używania literałów, to w jakieś części kwestia indywidualna. Należy zaznaczyć jednak, że druga metoda (używanie pary pole-wartość) wydaje się być bardziej elastyczna. Po pierwsze nie musimy się martwić kolejnością przekazywanych danych, co akurat wydaje się dość oczywiste, ale też nie ma wówczas potrzeby przekazywania wartości dla wszystkich pól naszej struktury, czego z kolei wymaga pierwszy sposób, gdzie musimy przekazać zawsze komplet wartości.

Osadzanie struktur i anonimowe pola

W programowaniu obiektowym podobnie jak w życiu większe struktury nierzadko składają się z mniejszych. Dlatego też nic nie stoi na przeszkodzie, by wewnątrz jednej struktury umieścić inną. Warunek jest jeden: musimy mieć zadeklarowany typ odpowiadający takiemu obiektowi, którą następnie zagnieździmy w tej nadrzędnej strukturze. Dalej wystarczy w deklaracji tej ostatniej podać nazwę pola oraz typ, który będzie odpowiadał temu podrzędnemu obiektowi. Trzeba przy tym pamiętać, iż odwołanie się do pól przypisanych do tego podrzędnego bytu będzie wymagało, by w notacji z kropką wskazać najpierw nazwę pola odpowiadającego tej strukturze, po której dopiero podajmy już konkretne pole.

W przykładzie kodu widocznym niżej utworzone zostały dwie struktury (a właściwie dwa nowe typy na bazie struktury) o nazwach “Osoba” oraz “Gabaryty” przy czym ta druga została użyta do budowy pierwszej.

package main

import "fmt"

type Osoba struct {
	imie     string
	mężczyna bool
	wiek     int
	rozmiar  Gabaryty
}

type Gabaryty struct {
	wzrost int
	waga   float32
}

func main() {

	Borciugner := Osoba{
		imie:     "Rafał",
		mężczyna: true,
		wiek:     44,
		rozmiar: Gabaryty{wzrost: 180,
			waga: 90},
	}

	fmt.Println(Borciugner.imie)
	fmt.Println(Borciugner.rozmiar.wzrost)

}

Oczywiście to bardzo banalny przykład, ale o pożytku z takich "modułowych" konstrukcji pewne nie trzeba przekonywać nikogo, kto ma jakiekolwiek doświadczenia z programowaniem obiektowym. To co jedynie może martwić - o ile w ogóle stanowi to dla kogoś faktycznie poważny problem - to nieco bardziej skomplikowany sposób docierania do poszczególnych pól (wartości z nimi powiązanymi), które przynależą do tej zagnieżdżonej struktury. W dobie inteligentnych edytorów, które wykonują dużą część pracy przy klepaniu kodu, pewnie ma to znaczniej mniejsze znaczenie niż przed laty. Tym niemniej istnieją sposoby, by w tym zakresie optymalizować naszą pracę. Służą do tego tzw. anonimowe pola, które powstają poprzez pominięcie nazwy i pozostawienie jedynie typu pola. Wystarczy spojrzeć niżej jak może to wyglądać:

package main

import "fmt"

type Osoba struct {
	imie     string
	mężczyna bool
	wiek     int
	Gabaryty
}

type Gabaryty struct {
	wzrost int
	waga   float32
}

func main() {

	Borciugner := Osoba{
		imie:     "Rafał",
		mężczyna: true,
		wiek:     44,
		Gabaryty: Gabaryty{wzrost: 180,
			waga: 90},
	}

	fmt.Println(Borciugner.imie)
	fmt.Println(Borciugner.wzrost)
}

Jak widać w ten sposób pole “wzrost” dostępne jest dla nas i kompilatora GO od razu z poziomu zmiennej “Borciugner” bez konieczności wskazywania pośrednika w postaci struktury o typie “Gabaryty”. Rzecz jasna wówczas musimy uważać na problem kolizji nazw pól, ponieważ przy “schodkowej” strukturze naszego obiektu ryzyko wystąpienia takiego scenariusza wzrasta, zwłaszcza jeśli dopuszczamy możliwość modyfikowania w przyszłości elementów składowych struktury podrzędnej. Tym niemniej w jednym i drugim przypadku wszystko należy zaplanować z rozwagą, a w razie czego kompilator GO dość szybko postara nas naprostować.

Dowiązywanie metod

Nie da się właściwie pisać o programowaniu obiektowym bez chociaż napomknięcia o metodach, dlatego na koniec dzisiejszego wpisu o strukturach nie obędzie się bez kilki słów w tym zakresie. Tym niemniej już z poprzedniego tekstu powinniśmy wiedzieć, że w GO metody nie są przyspawane do jakiegoś jednego konkretnego bytu czy też typu danych. Tym niemniej to właśnie w połączeniu ze strukturami tworzą coś zbliżonego (przynajmniej wizualnie) dla typowych sposobów implementacji obiektowości w programowaniu. Natomiast wszystko co chciałem o metodach napisać, zamieściłem w poprzednim wpisie i za bardzo nie widzę tutaj niczego specjalnego, co przy okazji struktur należałoby koniecznie dodać. Dlatego zamiast powtarzać rzeczy wówczas napisane od razu przejdźmy do przykładu:

package main

import "fmt"

type Osoba struct {
	imie string
	waga float32
}

func (os Osoba) prezentacja() {
	fmt.Println("Nazywam się", os.imie)
}

func (os *Osoba) dietaCud() {
	os.waga *= 0.8
}

func main() {

	Borciugner := Osoba{
		imie: "Rafał",
		waga: 90,
	}

	Borciugner.prezentacja()
	fmt.Println("Moja startowa waga to:", Borciugner.waga)
	Borciugner.dietaCud()
	fmt.Println("Moja waga po diecie cud to:", Borciugner.waga)

}

Obie metody utworzone w zaprezentowanym wyżej przykładzie zostały powiązane z tym samym typem “Osoba”, który bazuje na pewnej strukturze. Tym niemniej już w momencie deklaracji każdej z tych dwóch metod inaczej odwołaliśmy się do tego samego odbiorcy metody. W przypadku metody prezentacja jako typ naszego parametru “os” została wskazana wprost struktura “Osoba”. Dla metody dietaCud użyty został typ wskaźnikowy. W tym drugim przypadku musieliśmy się posłużyć typem wskaźnikowym, ponieważ metoda dietaCud ma za zadanie zmodyfikować wartość pola “waga” w przypadku struktury, dla której tę metodę wywołamy i dlatego musi mieć bezpośredni dostęp do wartości zapisanych w zmiennej, dla którego została ona użyta.

Stąd bierze się konieczność skorzystania z wskaźników, które odwołują się do miejsca w pamięci, gdzie przechowywana jest wartość naszej zmiennej. Ta konieczność - gwoli przypomnienia - wynika z faktu, że przekazując do funkcji czy metody jakąś zmienną w roli argumentu dla wymaganego parametru tak naprawdę w ramach ciała tej funkcji czy metody tworzymy kopię tej zmiennej. Zasięg tej "kopii" ogranicza się wówczas do ciała tej funkcji, czyli w żaden sposób nie modyfikujemy pierwotnej wartości przypisanej do zmiennej, której użyliśmy jako argumentu. Coby nie być gołosłowny wystarczy uruchomić następujący fragment kodu:

package main

import "fmt"

type Osoba struct {
	imie string
	waga float32
}

func (os *Osoba) dietaCud() {
	os.waga *= 0.8
}

func (os Osoba) dietaLipa() {
	os.waga *= 0.8
}

func main() {

	Borciugner := Osoba{
		imie: "Rafał",
		waga: 90,
	}

	
	fmt.Println("Moja startowa waga to:", Borciugner.waga)
	Borciugner.dietaLipa()
	fmt.Println("Moja waga po lipnej diecie to:", Borciugner.waga)
	Borciugner.dietaCud()
	fmt.Println("Moja waga po diecie cud to:", Borciugner.waga)

}

W efekcie jego działania możemy się przekonać, że metoda dietaLipa - jak sama nazwa wskazuje - nie przyniosła oczekiwanego skutku i Borciugner pozostał tak samo gruby jak dotychczas.

Na zakończenie

Dzisiejszym wpisem zamykam cykl tekstów podstawom GO poświęconych. Z całą pewnością nie jest to równoznaczne z porzuceniem tej tematyki na łamach mojego bloga. Zakładam z resztą, iż jeden z pierwszych wpisów po migracji - o czym za chwilę - będzie właśnie tego obszaru dotykał, czyli praktycznego zastosowania GO w jakimś micro projekcie. Tym niemniej kończąc chciałbym podkreślić fakt, że moja dotychczasowa pisanina o tym języku nie wyczerpuje całości zagadnień, które powinien opanować każdy aspirujący do miana eksperta GO. Przypomnę, że chociażby kwestia wskaźników została ledwie zasygnalizowana, zaś tematyka interfejsów czy współbieżności w ogóle nie pojawiła się w dotychczasowych tekstach. Być może jak już nabiorę większej pewności w tym zakresie, to do tych zagadnień w przyszłości wrócę na łamach tego bloga. Tym niemniej przedstawiony materiał jest - tak mi się wydaje - niezłym punktem wyjścia do pogłębiania wiedzy i samodzielnych prób z GO.

Niestety czasu mi nie przybywa, a jest kilka bardziej palących kwestii, którymi muszę się obecnie zająć, w tym wspomnianą wcześniej migracją. Ta ostatnia stała się dla mnie obecnie priorytetem (być może dzisiaj lub jutro poświęcę temu osobny wpis), przynajmniej jeśli chodzi o niniejszą stronę. Niestety do czasu zakończenia tej "przeprowadzki" - zapewne niezbyt rychłego - nie ma co oczekiwać kolejnych wpisów nie tylko w przypadku tematyki GO. W najbliższych kilku tygodniach nie będę w stanie poświęcić się czemukolwiek, co wymaga jakiś większych nakładów czasu oraz energii. Tym niemniej mam nadzieję, że ta wymuszona przerwa będzie okazją, by jakiś mniej lub bardziej praktyczny projekcik w GO popełnić i w efekcie móc się później podzielić tymi doświadczeniami na łamach niniejszego bloga.