Krótki tutorial
Ogromny procent tutoriali i przewodników po językach rozpoczyna się od napisania standardowego programiku wyprowadzającego napis "Hello World!" np. na ekran. My postąpimy nieco inaczej, w pierwszym programie wyświetlimy "Zażółć gęślą jaźń". Kompilator Go interpretuje pliki z założenia w kodowaniu UTF-8, zatem bez problemów możemy zapisać nasz program jak poniżej:
(Klikając w przycisk "go" wywołasz kod na serwerze a wynik pojawi się pod listingiem, możesz także na żywo eksperymentować z przykładami w tutorialu!)
Niedużo kodu prawda? Oczywiście w językach skryptowych było by jeszcze mniej, ale Go jest językiem kompilowanym, do tego z silnym typowaniem, a w tej kategorii chyba króluje.
Z powyższego przykładu wyłania się ogólna struktura programu. Każdy plik źródłowy rozpoczynamy deklaracją nazwy pakietu, następnie importujemy pakiety z których będziemy korzystać w tym pliku, a następnie całą resztę.
O pakietach powiemy sobie więcej później, w tym momencie ważne jest to że są, a
pakiet uruchomieniowy musi się nazywać main
i definiować funkcję main()
.
Tak, przy okazji: zauważyłeś, że definicję funkcji rozpoczynamy od słówka func
?
Zobaczmy jak się deklaruje zmienne. Aby nie komplikować, zmodyfikujemy pierwszy
program tak, by napis "Zazółć gęślą jaźń" został przypisany do zmiennej, zanim
przekażemy go do funkcji fmt.Println
.
Pełna deklaracja zmiennej zaczyna się od słowa var
po czym następuje nazwa
zmiennej, w naszym przypadku s
, a na końcu określamy typ tej zmiennej
var s string
. Nie pomyliłem się, typ deklarujemy zawsze po identyfikatorze
(lub liście identyfikatorów). W Javie czy C/C++ najpierw podajemy typ a potem
identyfikator, nie mniej przestawienie się nie jest trudne.
W linii poniżej deklaracji zmiennej s
przypisaliśmy do niej pożądaną wartość.
Nota bene: wartość zapisaną w kodzie programu nazywamy literałem. To rzadko spotykane słowo w codziennym słowniku programistów z mojego otoczenia. Często jednak będziesz spotykać się z tym terminem na stronach o Go, nie tylko w kontekście ciągów znaków, bo niemal wszystkie typy mają odpowiadające im literały. Literały pomagają skrócić i uczynić kod bardziej czytelnym.
W Go można zadeklarować zmienną i przypisać wartość w jednej linii:
Można w skracaniu kodu pójść jeszcze dalej, nie dość że deklarację i przypisanie
wartości zmiennej umieścimy w jednej linii, to jeszcze pozbędziemy się
określania typu i słówka var
:
Operator :=
oznacza deklarację zmiennych wylistowanych po lewej stronie, i
przypisanie wartości wyrażenia po prawej, przy czym typ zmiennej jest określany
na podstawie wyniku wyrażenia.
Równie dobrze, zamiast literału można po prawej stronie użyć wywołania funkcji, porównania, czy jakiejkolwiek innej instrukcji zwracającej wartość.
Jako ćwiczenie podziel napis na dwie zmienne i przekazaż je do
fmt.Println
, tak by w wyniku otrzymać napis "Zażółć gęślą jaźń".
Podpowiem tylko że fmt.Println przyjmuje dowolną ilość argumentów, więc poprawne
jest wywołanie fmt.Println(s1, s2)
.
Jeśli masz dość cierpliwości, przećwicz wszystkie możliwości:
- deklaracje i przypisania w oddzielnych liniach
- deklaracje obu zmiennych w jednej linii a przypisanie w kolejnej
- deklaracje i przypisania w jednej linii bez użycia operatora
:=
- deklaracje i przypisania w jednej linii z użyciem operatora
:=
Udało się? Jeśli nie, to może poniższe rozwiązanie jednego z punktów pomoże ci wykonać pozostałe?
Przyjrzyjmy się definicji zwykłej funkcji, na przykład takiej która dodaje
do siebie dwie liczby całkowite (int
):
Definicję funkcji zaczynamy od słowa func
, po czym nadajemy nazwę i deklarujemy
parametry oraz wartości jakie funkcja zwraca. Co ciekawe, funkcja może zwracać
wiele wartości! Jest to kolejna własność Go, która sprawia, że dobry kod powstaje
szybciej niż w innych tego typu językach.
Zwykle języki dopuszczają zwrot co najwyżej jednej wartości, aby zwrócić więcej wartości trzeba tworzyć mniej lub bardziej wysublimowane struktury danych w których te wartości przekażemy.
Zobaczmy jak to działa w Go i jak radzić sobie z funkcjami które zwracają
więcej wartości niż potrzebujemy, na przykładzie funkcji divMod(a, b int)
która
zwróci część całkowitą i resztę z dzielenia a
przez b
:
Jak widzimy by zwrócić wiele wartości wystarczy je wylistować rozdzielając
przecinkami po słowie kluczowym return
, a żeby je odebrać wystarczy wykonać
przypisanie listy zmiennych do wywołania funkcji.
Zwróć uwagę na poniższe mechanizmy:
- przypisanie wielu wartości do zmiennych w jednym wierszu (linia 10)
- zastosowanie
:=
do wyników z funkcji (linia 13) - działanie specjalnej zmiennej
_
, która "pochłania" wartości w przypadku gdy nie są potrzebne w przypisaniu (linia 16)
Funkcje można także przywiązać do typu. Wtedy nazywamy je metodami, choć ich
deklaracja różni się tylko jednym szczegółem. Jest to dodatkowy parametr,
którego deklarację umiejscawiamy między słowem kluczowym func
a nazwą funkcji.
Zobaczmy jak to wygląda, a potem opowiem o tym jak to działa.
Stworzymy typ bazując na int
i dodamy mu metodę abs(), która będzie zwracała
wartość bezwzględną liczby.
W deklaracji metody w func (a absint) abs() absint
kluczowy jest fragment
(a absint)
sygnalizuje, że do ciała funkcji zostanie skopiowana jeszcze wartość
zmiennej typu na którym chcemy wykonać metodę. Metodę wywołujemy podobnie jak w
innych językach po kropce ai.abs()
.
NOTA BENE wszystkie wartości przekazywane do funkcji są kopią wartości, która jest przekazywana. Ma to poważne konsekwencje, o których niebawem napiszę więcej.
Dodatkowo w przykładzie znalazło się wyrażenie warunkowe if
. Nie będę
szczegółowo tu omawiał konstrukcji warunkowych i pętli, zainteresowanych
odsyłam do opisu języka, nie mniej warto zauważyć, że w
Go nie potrzebujemy nawiasów otaczających warunek, co niewątpliwie zwiększa
czytelność kodu.
Drugą ważną cechą (zmniejszającą bałaganiarstwo w kodzie) jest to, że wyrażenie
w ifie MUSI zwracać prawdę lub fałsz - typ boolean
. Nie ma przyzwolenia na
wkładanie tam wyrażeń których typ wynikowy jest inny. Dzięki takiemu zabiegowi,
nie musimy się zastanawiać "czy pusta tablica/string to prawda czy fałsz?".
Metody nieodłącznie kojarzą się z klasami i obiektami, lecz w Go nie ma takich
konstrukcji. Jest jednak typ bazowy struct
, który pozwala na grupowanie
powiązanych danych w jednej wartości, a dzięki "przypinaniu" funkcji do typów
zyskuje nieco obiektowy charakter. Zobaczmy jak wygląda przykładowa pseudoklasa:
Zwróć uwagę na funkcję newRect
, która zwraca strukturę typu rectangle
tworząc
jej wartość za pomocą literału struktury. Literał ten przypomina nieco JSON i
literały słowników w pythonie. Za każdym wywołaniem takiej funkcji zostanie
zwrócona nowa wartość. Nie jest to jedyna metoda tworzenia wartości, zapraszam
do podręcznika po więcej informacji.
W tym momencie zróbmy sobie kolejną przerwę na zabawę z kodem Go. Rozwiązanie prostego zadania pozwoli Ci na przetworzenie nowo poznanych informacji.
Skróć poniższy kod, możliwie zwiększając czytelnośc i otrzymując taki sam wynik.
Kopiowanie argumentów do funkcji i metod przez wartość wiąże się ze zwiększonym zużyciem pamięci i mocy obliczeniowej, choć dzieje się to automatycznie. Możemy zminimalizować ten narzut przez przekazywanie wskaźników do wartości. Taka operacja opłaca się dla wartości, których rozmiar w pamięci jest większy niż rozmiar wskaźnika, czyli dla systemów 32bit to ~4 bajty dla 64bit ~8 bajtow.
Zrozumienie czym są wskaźniki, wymaga trochę wiedzy, ćwiczeń i czasu, którego nie mamy w tym tutorialu za dużo, więc tylko pokażę jak się ich używa.
Każdy typ ma swój wskaźnikowy odpowiednik, podobnie jak w C oznaczamy go znakiem
*
umieszczając przed nazwą typu. Wartości wskaźników są "bezpieczne", to
znaczy: nie będą wskazywały innej wartości niż ta, która została do nich
przypisana jako ostatnia (uwaga dla programistów c/c++: nie ma czegoś takiego
jak inkrementacja wskaźnika).
Poniższy listing pokazuje jak korzystamy ze wskaźników, zauważ że gdy przekazujemy liczbę a nie jej wskaźnik, to jej wartość się nie zmienia.
Implementując metody, w szczególności dla struct
, używaj wskaźnikowego
odpowiednika typu, ponieważ zazwyczaj chcemy zmieniać samą wartość
a nie jej kopię, poza tym oszczędzamy zasoby, kopiując tylko wskaźnik a nie
całą wartość.
Zobaczmy jak wprowadzić powyższą wiedzę w życie:
Dodano tylko jedną gwiazdkę przy deklaracji metody func (r *rectangle) area() int
.
Ciało metody może zostać po staremu (mimo iż typ zmiennej r
jest inny, bo Go
wykonuje tak zwaną derefencję - czyli odnosi się do wartości a nie do wskaźnika -
za nas. Zapis return r.x * r.y
powinien wyglądać tak: return (*r).x * (*r).y
,
gdybyśmy koniecznie nie chcieli "magii" w kodzie.
Aby przekonać się jaka jest różnica między przypinaniem metody do typu a przypinaniem metody do jego wskaźnikowego odpowiednika, proponuję poprawić poniższy kod tak, by zachowywał się zgodnie z oczekiwaniami.
W Go niemożliwe jest stworzenie odpowiednika hierarchii klas, jedyną możliwością tworzenia abstrakcyjnych typów jest definiowanie interfejsów. Podobnie jak w innych językach posiadających interfejsy są to zestawy metod, które musi implementować typ by go spełniać.
Np. wszystkie typy reprezentujące figury geometryczne powinny implementować
metodę Area() int
, która zwraca pole powierzchni (przy naiwnym założeniu, że
pole będzie zawsze liczbą całkowitą...). Zatem typ do którego możemy przypisać
dowolną figurę geometryczną mógłby być interfejsem, np. takim:
W funkcji printArea
zmienna f może przechowywać tylko wartości o typie
Figure
, co oznacza, że niezależnie jakiego typu wartość tam przekażemy
na zmiennej f możemy wykonać tylko metody przewidziane w interfejsie. Mówimy
że typ interfejsowy jest "dynamiczny", bo nie wskazuje faktycznej wartości
tylko eksponuje jej niektóre metody. Zaniepokojonych, że w Go nie da się
wyciągnąć prawdziwej wartości ze zmiennej typu interfejsowego, uspokajam:
da się za pomocą asercji typów
Tyle o języku jako takim, po więcej zapraszam do bardziej szczegółowych artykułów. Z istotnych konstrukcji, które nie zmieściły się w tutorialu, polecam zapoznać się z:
- clousure
- slices
Teraz przyjrzymy się mechanizmom współbierzności. Go nie wprowadza całkiem nowych rozwiązań, wręcz przeciwnie: wprowadza w życie modele znane już z CSP a datowane na rok 1978. Podobny model współbieżności znajdziemy w Erlangu i czasem w formie modułu lub rozszerzenia do innego języka (jak np. gevent dla Pythona). Nie mniej Go jest dużo bardziej przyswajalnym językiem niż Erlang, a fakt że był projektowany z myślą o współbieżności pozwala przypuszczać, że będzie zdobywał coraz większą popularność w miejscach, gdzie współbieżność jest sprawą gardłową (czyli niebawem wszędzie).
Go daje dwie konstrukcje, które są rdzeniem współbieżności:
- instrukcja
go
, która przekształca wywołanie funkcji w tzw. goroutine - typ
chan
, kanały które służą do komunikacji między goroutines
Przejdźmy przez kilka podstawowych przykładów zastosowania dla tych narzędzi. Napiszemy baaardzo naiwny program, który wykona pętle 40 miliardów razy, przyczym będą to 4 wywołania funkcji po 10 mld iteracji każda:
Po skompilowaniu na moim komputerze (4 x 2GHz), program się wykonywał około 20s,
przy czym wykorzystywał tylko jeden rdzeń. Brzmi to jak marnotrawstwo czasu
użytkownika, ten program mógłby się wykonywać 4 razy szybciej (bo mam 4 rdzenie).
Aby wykonać te cztery funkcje równocześnie w teorii wystarczy dodać przed wywołaniem
słowo go
to jest: go descr(10000000000)
.
Teoretycznie :-) W praktyce zaś, program nam się skończy tak szybko jak się
zaczął. Dzieje się tak ponieważ w Go program się zawsze kończy gdy wychodzi z
goroutyny głównej (czyli funkcji main
). Należy zatem wprowadzić czekanie na
zakończenie wszystkich zadań. Do tego posłuży nam zmienna typu chan
tzw. kanał.
Kanały służą do przekazywania informacji między goroutynami, w naszym przypadku,
chcemy by tą informacją było zakończenie wykonania pętli. W tym celu z każdej
goroutyny wyślemy przez kanał wartość true
tuż przed zakończeniem wykonywania,
zaś w funkcji main (goroutynie głównej) będziemy starali się ją wyciągnąć.
To będzie działać, dlatego że pobieranie wartości z kanału blokuje wykonanie goroutyny pobierającej, do momentu pozyskania danych. Ponieważ uruchomiliśmy 4 goroutyny, a na zakończenie każdej z nich przekazujemy do kanału jakąś wartość, aby być pewnymi, że wszystkie goroutyny się zakończyły pobierzemy 4 wartości z kanału.
package main
func decr(n uint64 , c chan bool ) {
for n > 0 {
n--
}
c <- true //koniec wykonywania goroutyny
}
func main() {
c := make (chan bool, 4)
go decr(10000000000, c) // wszystkie
go decr(10000000000, c) // goroutyny piszą
go decr(10000000000, c) // do jednego
go decr(10000000000, c) // kanału
<-c //z kanału
<-c //wyciągnijmy
<-c //cztery
<-c // wartości
} //i zakończmy program (co jest równoznaczne z zakończeniem wykonywania funkcji main)
To wystarczyło by z czasu wykonania ~20s zejść do ~5s. Przyśpieszyliśmy program 4 razy, wcale się nie męcząc. Oczywiście to był baardzo prosty problem, ale mam nadzieje przybliżył podstawy zrównoleglania obliczeń i przekazywania wyników.
Niestety, tego programu nie uda się uruchomić za pomocą webowego kompilatora (zbytnio obciąża procesor), dlatego zachęcam do zainstalowania środowiska Go u siebie.