Zanim przejdziemy do omówienia zagadnienia związanego z tworzeniem i używaniem metod w języku GO, dwa słowa komentarza a pros struktury dzisiejszego tekstu.
W ramach niniejszego cyklu tekstów nie planowałem omawiać zagadnienia wskaźników (pointers), czy bliżej prawdy tę kwestię stawiając: w pewnym momencie postanowiłem zrezygnować z przygotowania osobnego tekstu, który by właśnie o wskaźnikach traktował. Była to decyzja wynikająca głównie z braku przekonania, iż będę potrafił to lepiej lub gorzej zaprezentować własnymi siłami, czyli bez kopiowania cudzej pracy “dydaktycznej” w tym zakresie. O ile bowiem – podobnie jak w przypadku interfejsów – ogarniam na poziomie teorii ideę za wskaźnikami stojącą i praktycznie potrafię wykorzystać to rozwiązanie w wielu sytuacjach, to na poziomie intuicyjnego rozumienia (czyli subiektywnie narzucającej się oczywistości) nie jest już tak różowo.
Tym niemniej omawianie zagadnienia posługiwania się metodami w GO z całkowitym pominięciem wskaźników jest wprawdzie możliwe, ale będzie to tak niekompletne podejście, że aż poniekąd bezcelowe. Dlatego na sam koniec dodałem fragment na temat tego jak buduje się – w wielu przypadkach – metody właśnie przy wykorzystaniu wskaźników, tak by osiągnąć oczekiwany efekt ich działania. Tym niemniej nie będę próbował bardziej szczegółowo wyjaśniać czym wskaźniki i co jeszcze można z nimi zrobić. Natomiast na pewno przy omawianiu struktur (struct) na pewno do tego tematu jeszcze wrócę.
DEFINIOWANIE WŁASNYCH TYPÓW
Chyba nie pomylę się za nadto, gdy napiszę, iż w powszechnym odbiorze przyjęło się uważać, iż metody w programowaniu są nierozłączną częścią klas, a przynajmniej są typowym czy nawet wyłączonym elementem dla paradygmatu programowania obiektowego. W Go jednak nie ma klas, ale podobnie jest w Javascript, gdzie inaczej podchodzi się do praktycznej realizacji obiektowości (nawet jeśli ECMAScript 2015 próbowało to nieco ukryć wprowadzając rodzaj składniowej “zasłony” dla tego co się faktycznie dzieje “pod maską”). Natomiast w GO nie ma również sensu stricto obiektów jako takich, ale mimo to są metody. Jak to jest możliwe?
Otóż twórcy tego języka podeszli bardziej elastycznie do tego zagadnienie i uznali, że w odpowiednie metody można wyposażyć dowolny typ danych. Tym samym metodę w GO można uznać za rodzaj funkcji powiązaną z dowolnym zadeklarowanym typem danych. Dlatego zanim przejdziemy do omówienia samych metod musimy na przyswoić sposoby tworzenie własnych, autorskich typów. Do tego celu potrzebujemy dwóch rzecz:
- słowa kluczowego type,
- bazowego typu (np. string), który będzie podstawą naszego.
Załóżmy, że chcemy utworzyć nowy typ o niezbyt wyszukanej nazwie “mójTyp”, który będzie bazował na typie string, wówczas wystarczy napisać:
type mójTyp string
Tym sposobem powołaliśmy do życia nowy typ, który do którego możemy przypisywać dowolny łańcuch znaków, ale nie jest to bynajmniej coś w rodzaju aliasu dla typu string (o praktycznych konsekwencjach tej różnicy za chwilę), tylko nowy typ pełną gębę. By to zobaczyć proponowałby uruchomić poniższy kod:
package main
import "fmt"
type mójTyp string
func main() {
var imię mójTyp
imię = "Borciugner"
ksywka := "Borciugner"
fmt.Printf("Zmienna imię ma wartość %v a jej typ to %T\n", imię, imię)
}
Do zmiennych “imię” oraz “ksywka” przypisany został dokładnie ten sam literał, ale kompilator widzi te zmienne jako różnego typu, ponieważ w wyniku wykonania tego kodu w terminalu powinniśmy zobaczyć następujący rezultat:
Zmienna imię ma wartość Borciugner a jej typ to main.mójTyp
Zmienna ksywka ma wartość Borciugner a jej typ to string
Ma to ten praktyczny skutek, że przy takim rozwiązaniu nie da się przypisać zmiennej “imię” do zmiennej “ksywka” i na odwrót, choć przechowują ten sam literał, ponieważ kompilator nie pozwoli na takie działanie ze względu na różnicę typów. Na pierwszy rzut oka może to wydawać się rodzajem pewnej fanaberii (zwłaszcza opierając się na tak prostym przykładzie), ale czasem może to mieć niebagatelne znaczenie z punktu widzenia logiki działania programu niż mogłoby się wydawać. Tym niemniej nie miejsce tutaj na takie rozważania, ważne by załapać zasadę, dzięki czemu możemy iść dalej.
DEFINIOWANIE I WYWOŁYWANIE METOD
Skoro mamy już za sobą utworzenie pierwszego autorskiego typu, poraz przejść do właściwego tematu dzisiejszego wpisu, czyli do omówienia metod. Zaczniemy od sposobu deklaracji tych ostatnich.
Metody deklaruje się w bardzo podobny sposób jak ma to miejsce w przypadku zwykłej deklaracji funkcji. W pewnym sensie występuje tu tylko jedna różnica: przed nazwą metody trzeba podać w nawiasie nowy parametr, który fachowo nazywa się “odbiorcą metody”. Ten parametr ma za zadanie wskazać, do którego z istniejących typów chcemy przypisać naszą metodę. Oczywiście jak w przypadku każdy parametru GO twórca musi podać jego nazwę, a następnie odpowiadający mu typ. Jeśli chodzi o samą nazwę parametru odbiorcy metody w jej definicji nie ma ona większego znaczenia, ponieważ istotny jest wyłącznie typ, ponieważ to on określa powiązanie metody z wszystkimi wartościami (zmiennymi) danego typu. Tym niemniej – bazując na tym co udało mi się dotychczas uświadczyć przeglądając różnego rodzaju przykłady kodu – najczęściej jako nazwę parametru podawana bywa pierwsza litera powiązanego typu.
Jeśli chodzi o sposób wywoływania takiej metody, to używa się tutaj tak zwanej notacji kropkowej. Przypomina to bardzo mocno użycie dowolnej funkcji importowanej z jakiegoś pakietu, z tą różnicą, że zamiast nazwy paczki (na przykład “fmt”) podawana jest nazwa zmiennej, która jest odpowiedniego typu, czyli w tym wypadku obsługująca daną metodę.
By to zilustrować użyję dość banalnego przykładu, w którym użyjemy wspomnianego wcześniej typu “mójTyp”, który bazuje na łańcuchach znaków (string). Dodamy do niej metodą “powitanie”, której rolą będzie wyświetlenie komunikatu – jak sama nazwa wskazuje – powitania z użyciem wartości do zmiennej tego typu przypisanej. W tym celu tworzymy zmienną “imię” właśnie o typie “mójTyp”. Tak to wszystko by mogło wyglądać:
package main
import "fmt"
type mójTyp string
func (m mójTyp) powitanie() {
fmt.Println("Witaj " + m)
}
func main() {
var imię mójTyp
imię = "Borciugner"
imię.powitanie()
}
WSKAŹNIKI
Wydaje mi się, że wszystko, co dotychczas zostało napisane w temacie tworzenia typów oraz przypisywanie do nich zmiennych wydaje się dość proste i raczej intuicyjnie zrozumiałe. Pora jednak dorzucić do tej sielanki przysłowiową łyżkę dziegciu.
Dajmy na to, że chcemy stworzyć nowy typ o nazwie “Number”, który będzie bazował na liczbach całkowitych. Następnie uzbroimy go w metodę, której zadaniem będzie podwojenie wartości przypisanej do zmiennej tego typu. Na bazie tego, co dotychczas zostało powiedziane całość mogłaby wyglądać następująco:
package main
import "fmt"
type Number int
func (n Number) podwojenie() {
n *= 2
}
func main() {
var Liczba Number
Liczba = 4
fmt.Println("Początkowa wartość zmiennej \"Liczba\" wynosi:", Liczba)
Liczba.podwojenie()
fmt.Println("Wartość dla zmiennej \"Liczba\" po wywołaniu metody \"podwojenie\" wynosi:", Liczba)
}
I wszystko byłoby cudownie, gdyby nie fakt, że rezultat będzie daleki od zamierzonego, ponieważ w obu wypadkach na terminalu pojawi się komunikat mówiący, że wartość zmiennej wynosi 4. Czemu tak się dzieje?
Otóż do ciała naszej metody (dokładnie tak samo to wygląda w przypadku funkcji) przekazywana jest nie sama zmienna (tutaj “Liczba”), tylko tworzona jest jej kopia. Dlatego operacja wykonywana wewnątrz metody “podwojenie” nie ma żadnego wpływu na samą zmienną. Oczywiście moglibyśmy to obejść tworząc metodę, która zwraca wartość tej operacji, która zapisana jest w ciele metody, a następnie przypisać ją ponownie do zmiennej “Liczba”. Tylko wówczas to trochę mija się z celem tworzenia metod, które powinny operować bezpośrednio na wartościach przypisanych do zmiennej danego typu. Tutaj z pomocą przychodzą wskaźniki. Na początek jednak proponuję uruchomić ten kod, by przekonać się, że otrzymamy wynik zgodnie z naszymi oczekiwaniami:
package main
import "fmt"
type Number int
func (n *Number) podwojenie() {
*n *= 2
}
func main() {
var Liczba Number
Liczba = 4
fmt.Println("Początkowa wartość zmiennej \"Liczba\" wynosi:", Liczba)
Liczba.podwojenie()
fmt.Println("Wartość dla zmiennej \"Liczba\" po wywołaniu metody \"podwojenie\" wynosi:", Liczba)
}
Jedyna różnica między pierwszym a drugim przykładem kodu (oprócz efektu działania rzecz jasna) polega na użyciu symbolu gwiazdki w deklaracji metody “podwojenie” (w parametrze odbiorcy metody przed typem “Number”, a później w ciele przed zmienną “n”). Jest to poręczny sposób, by poinstruować kompilator GO, że w tym konkretnym wypadku tak naprawdę nie interesuje nas dana wartość jako taka, ale miejsce (komórka) w pamięci komputera, gdzie ona jest zapisana. W ten sposób – czyli mając dostęp do odpowiedniego adresu pamięci, gdzie przechowywana jest wartość powiązana ze zmienną – możemy dokonywać modyfikacji pierwotnej wartości przechowywanej w zmiennej, a nie kopii. Z powodu tej właściwości użycie typów wskaźnikowych to podstawowy sposób tworzenie metod, jeśli mają one dokonywać modyfikacji wartości.
Na ten moment tyle wiedzy powinno wystarczyć, by w prawidłowy sposób (zgodnie z oczekiwaniami) tworzyć i używać metody. Być może do tematu wskaźników wrócimy jeszcze przy okazji omawiania struktur.
A jeśli już przy strukturach jesteśmy: to kolejny wpis zostanie im poświęcony temu zagadnieniu. Będzie to jednocześnie ostatni tekst z tego cyklu, choć zapewne z językiem GO nie pożegnamy się zbyt szybko na tym blogu.