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:

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.