Dzisiaj będzie chyba pierwszy tekst z cyklu podstawowo GO poświęconego, do napisania którego nie musiałem się wyjątkowo zmuszać. Nie chodzi bynajmniej o to, że funkcje to wyższy poziom koderskiego wtajemniczenia (choć poniekąd też), ale o sposób w jaki zostały one zaimplementowane w GO. Dla osoby, które nader skromne doświadczenia sprowadzają się do dyletanckiego programowania w Pythonie i trochę w Javascript, sposób podejścia do zagadnienia funkcji w przypadku GO był dużym zaskoczeniem czy raczej sporą odmianą w stosunku do tego, co było mi dotąd znane i jakoś tam przećwiczone. Ale teraz pora najwyższa przejść do rzeczy.
Gdyby ktoś jeszcze nie wiedział, funkcja to pewna zamknięta całość kodu, czy jeszcze inaczej sprawę stawiając: to rodzaj strategii programowania, która zakłada wydzielenie wspomnianej części kodu jako oddzielnej całości, po zdefiniowaniu której może ją następnie wielokrotnie wykorzystać w obrębie własnego programu. Właśnie dzięki faktowi, iż funkcje umożliwiają rozbijanie większych zadań na mniejsze składowe, które z powodzeniem mogą być pisane przez różnych ludzi, łatwiejsze jest utrzymywanie naszego kodu w miarę “puchnięcia” naszego projektu. To rozparcelowanie całości kodu na na mniejsze, ściśle określone zadania, czyni przy okazji nasz program – przynajmniej w teorii – również znacznie czytelniejszym i łatwiejszym do analizy dla osoby postronnej (lub dla nas samych po dłuższej przerwie od czasu jego napisania).
Jak już wspominałem w tekście otwierającym ten cykl, język GO jest dostarczany z naprawdę imponującą biblioteką standardową, która ma wiele predefiniowanych funkcji. Część z nich – jak chociażby funkcja Println z pakietu FMT – była już wykorzystywana w przykładowym kodzie, służącym za ilustrację pewnych zagadnień w ramach dotychczasowych wpisów. Dzisiaj jednakże będzie w końcu okazja przyjrzenia się sposobom tworzenie własnych rozwiązań w tym zakresie, co przy okazji powinno pozwolić zademonstrować różne aspekty ich “funkcjonowania” w ramach GO.
DEKLARACJA FUNKCJI
Deklaracja funkcji rozpoczyna się od słowa kluczowego func, po którym następuje jej nazwa, zaś dalej mamy opcjonalną listę parametrów i tak samo fakultatywną listę wyników. Dopiero po tym wszystkim mamy ciało naszej funkcji. W najbardziej ogólnym wydaniu wygląda to następująco:
func nazwaFunkcji (lista_parametrów) (lista_wyników) {
ciało_funkcji
}
To co rzuca się od razu w oczy osobie, która tak jak ja miała wcześniej wyłącznie do czynienia z języki skryptowymi takimi jak chociażby Python, to konieczność – o ile rzecz jasna wymaga tego natura naszej funkcji – wskazania wprost, jakiego rodzaju wyników się spodziewamy, ale o tym w swoim czasie.
PARAMETRY FUNKCJI
Zanim jednak do tego przejdziemy do listy wyników słów kilka na temat parametrów. O ile nie są one – jak już wspomniałem – wymagane, to mimo wszystko po określeniu nazwy naszej funkcji zawsze zobaczymy przynajmniej jedną parę nawiasów, które właśnie odpowiadają naszym parametrom. W przypadku funkcji, która nie będzie oczekiwała przy jej wywołaniu przekazania jakichkolwiek argumentów, spomiędzy nawiasów straszyć będzie pustka, tak jak w przykładzie poniżej (ta prosta funkcja ta ma za zadanie wyświetlić na ekranie komunikat stałej treści):
package main
import "fmt"
func witajSwiecie() {
fmt.Println("Witaj Świecie!")
}
func main() {
witajSwiecie()
}
Zanim przejdziemy do dalszego omawiania kwestii związanych z parametrami funkcji, dwa słowa dosłownie na temat tworzenie nazw dla naszej funkcji. W zasadzie tym zagadnieniem rządzą – zdaje się – dokładnie te same prawidła co w przypadku zmiennych, czyli możemy używać praktycznie dowolnej kombinacji liter i cyfr (oraz znaków podkreślenia), zaś wielkość liter ma znaczenie i to również w charakterystycznym dla GO sensie, czyli użycie dużej litery na początku nazwy powoduje, że nasza funkcja będzie dostępna/widoczna poza pakietem, w którym dokonaliśmy jej deklaracji (czyli można ją importować do innych pakietów).
To powiedziawszy możemy wrócić do parametrów. I tak: jeśli zakładamy, że przy wywołaniu naszej funkcji muszą być dostarczane pewne argumenty, to musimy nie tylko określić ile tych parametrów docelowo będzie, ale też wskazać ich typy (dla każdego z osobna, jeśli są one różne, lub dla wszystkich łącznie w przypadku jednorodnych parametrów). Co więcej są one następnie – czyli na etapie wywołania funkcji – obligatoryjnie, czyli musimy przekazać dokładnie wymaganą liczbę argumentów określonego typu i to we właściwej kolejności, w jakiej parametry zostały zadeklarowane. Dzieje się tak ponieważ w języku Go nie funkcjonuje pojęcie domyślnych wartości parametrów ani żaden mechanizm określania wartości argumentów przez przypisanie wartości do konkretnej nazwy parametru. Dlatego z tej perspektywy nazwy parametrów nie mają żadnego znaczenia dla podmiotu tę funkcję wywołującą w kodzie własnego programu – taka osoba po prostu musi wiedzieć jakich argumentów (jakiego typu) i w jakiej kolejności dana funkcja wymaga. Poniżej mamy przykład kodu, gdzie została zademonstrowana deklaracja oraz wywołanie prostej funkcji, która przyjmuje dwa parametry różnego typu (tutaj string oraz integer).
package main
import "fmt"
func kapownik(imie string, wiek int) {
fmt.Printf("%v ma lat: %v!", imie, wiek)
}
func main() {
kapownik("Borciugner", 44)
}
Gdybyśmy przekazali argumenty w odwrotnej kolejności kompilator GO zgłosiłby nam błąd polegający na braku zgodności typów.
LISTA WYNIKÓW
W tym momencie przechodzimy do najciekawszego – z mojej perspektywy – aspektu deklarowania funkcji, który zaplanowałem omówić w dzisiejszym wpisie. Tym niemniej to nie koniec “smaczków”, które ma do zaproponowania GO osobom, ale o tym innym razem.
Przechodząc jednak do meritum: jeśli zakładamy, że nasza funkcja w efekcie zaplanowanego przez nas działania ma na końcu zwracać jakiś wynik, to już w momencie deklaracji musimy to “coś” z góry określić. Pisząc “coś” nie mam oczywiście na myśli konkretnego wyniku, bo to nie miałoby większego sensu w przeważającej części przypadków. Musimy natomiast zadeklarować już na samym początku, że na końcu swojego działania nasza funkcja powinna zwrócić konkretną liczbę wyników o ściśle określonym typie. To właśnie było dla mnie największym wyzwaniem przy próbie opanowania podstaw GO w tym zakresie, czyli właśnie fakt, że na samym końcu funkcja może wypluć więcej niż jeden wynik i bynajmniej nie muszą to być wartości tego samego typu.
Owszem, w pewnym sensie z podobną sytuacją miałem już okazję spotkać się przy średnio udanej próbie opanowania podstaw Typescript. Mam tu na myśli przewidzianą w tym ostatnim języku możliwości wskazywania wyniku działania na samym początku deklaracji funkcji, czyli zanim jeszcze napiszemy pierwszą instrukcję w obrębie ciała funkcji. Tym niemniej uznałem to wówczas za rodzaj fanaberii typowej dla rodzinny języków wyrastających z JS, no bo przecież kompilator TypeScriptu potrafi lepiej lub gorzej ustalić typ zwracanego wyniku na podstawie kodu wewnątrz ciała funkcji, a w najgorszym wypadku, gdy funkcja może zwrócić kilka wyników różnego typu, będzie używał unii typów. Stety lub nie GO nie jest aż tak liberalny i lista wyników musi być świadomie oraz wprost zadeklarowane przez twórcę danej funkcji.
Taka listę wyników – jak zostało to pokazane na początku tekstu – wskazywana jest zaraz po liście parametrów, z tą jednak różnicą, iż nawiasy nie są wymagane, jeśli funkcja zwraca jeden wynik lub nie zwraca żadnych. W obu przypadkach są one zazwyczaj pomijane, ale nic nie stoi na przeszkodzie, by je dodać. Natomiast tak jak parametry, wyniki mogą być nazwane, choć i tutaj mamy pewną różnicę w stosunku do parametrów, ponieważ nie musimy podawać ich nazwy i tym samym ograniczyć się wyłącznie do wskazania oczekiwanych typów dla zwracanego wyniku czy wyników. Gdy jednak zdecydujemy się na podanie również nazwy dla naszego, w takim przypadku każda taka nazwa deklaruje lokalną zmienną (dostępną w ramach ciała funkcji), która na starcie inicjowana jest ona z wartością zerową dla swojego typu.
Tym niemniej chyba w większości przykładów kodu, na których próbowałem się uczyć GO, ich twórcy odpuszczali sobie taką możliwość, czyli po prostu wskazywali wyłącznie na typ danych, które powinny pojawić się na wyjściu funkcji. Niezależnie od tego na jakie podejście byśmy się ostatecznie zdecydowali, to w każdym przypadku na końcu ciała naszej funkcji – nie jest to ścisłe określenie, ale niech tak na razie zostanie – musimy umieścić słowo kluczowe return, po którym powinno się podać zwracane wartości. Napisałem “powinno się podać”, ponieważ w sytuacji, gdy już na etapie deklaracji zdecydowaliśmy się nazwać nasze wyniki, wówczas samo słowo return w zupełności wystarczy (kompilator zwróci wartości przypisane do tych zmiennych). Niżej przykład dwóch funkcji, gdzie w deklaracji raz użyliśmy nazwy zmiennych, a raz wskazaliśmy wyłączenie typ wyniku.
package main
import "fmt"
func dodawanieOdejmowanie(x, y int) (int, int) {
a := x + y
b := x - y
return a, b
}
func iloczynIloraz(x, y int) (a int, b int) {
a = x * y
b = x / y
return
}
func main() {
suma, roznica := dodawanieOdejmowanie(4, 1)
fmt.Printf("Nasza suma to %v, zaś różnica %v\n", suma, roznica)
iloczyn, iloraz := iloczynIloraz(10, 2)
fmt.Printf("Nasz iloczyn to %v, zaś iloraz %v\n", iloczyn, iloraz)
}
To na co jeszcze zwróciłbym uwagę, to fakt, że w funkcji “iloczynIloraz”, gdzie zmienne a i b są powoływane do życia na etapie deklaracji listy wyników, wówczas w ciele funkcji dokonujemy wyłącznie przypisania do nich odpowiednich wartości, w tym wypadku wynikających z obliczeń na argumentach przekazanych do funkcji. Jeśli byśmy nie przypisali do nich żadnych wartości, wówczas funkcja “iloczynIloraz” zwróciłaby dwa zera, ponieważ są to tzw. wartości zerowe dla typu integer, ale to już powinniśmy wiedzieć z lekcji dotyczącej zmiennych. W przypadku funkcji “dodawanieOdejmowanie”, gdzie wskazaliśmy wyłącznie na typ wyników, zwracane zmienne musieliśmy powołać do życia w ciele funkcji (w tym przypadku przy pomocy krótkiej deklaracji zmiennych).
Drugą dość charakterystyczna cechą związaną z GO, którą widać z resztą na powyższym przykładzie, to sposób przypisywania wyniku wywołania funkcji do zmiennych. A ściślej rzecz biorąc: w samym przypisaniu nie ma nic niezwykłego, tym niemniej ze względu na tę specyficzną właściwość GO, że funkcje mogą i bardzo często zwracają więcej niż jeden wynik, wówczas próbując przypisać efekt działania takiej funkcji do jakiejś zmiennej, której zamierzamy używać, nierzadko będziemy musieli użyć więcej niż tylko tej jednej zmiennej, która nas interesuje.
Właśnie za sprawą tej przypadłości funkcji w GO w powyższym przykładzie można zobaczyć, iż w momencie przypisania wyniku wywołania funkcji “dodawanieOdejmowanie” lub “iloczynIloraz” do zmiennej de facto musimy powołać do życia dwie zmienne, ponieważ w rezultacie działania tych funkcji otrzymamy na wyjściu dwie różne wartości (tutaj obie typu integer) i trzeba je wówczas obsłużyć. Rzecz jasna może być i tak, że niekoniecznie w równym stopniu będą nas wszystkie zwracane wyniki interesować (wiele funkcji w GO jako drugi wynika zwraca błąd, o ile coś poszło nie tak), dlatego twórcy tego języka przewidzieli mechanizmy, które pozwalają pominąć te mniej pożądane wartości. Zwłaszcza, że należy pamiętać, iż w GO zmienna zadeklarowana to zmienna następnie użyta w kodzie, więc czasem zwyczajnie szkoda na pozorowane jakiegoś działania na niechcianej zmiennej, tylko by zaspokoić “oczekiwania” kompilatora.
Z tym rozwiązaniem mieliśmy już wcześniej do czynienia przy omawianiu pętli, która przelatywała przez wartości utworzonego wycinka (slice). A dokładniej chodzi o coś co nazywa się “pustym identyfikatorem” (blank identifier), którego symbolem jest “_” (czyli podkreślnik lub jak mawia mój syn “podłoga”). Używa się go wszędzie tam, gdzie składnia wymaga nazwy użycia zmiennej do tego, by kod został “zaakceptowany” przez kompilator, ale niekoniecznie stoi to w zgodzie z logiką programu. W przypadku naszych funkcji jest to pewnie mniej intuicyjne, choć może się okazać, że z jakiego powodu interesuje nas tylko wynik sumy, a nie różnica dwóch liczby przekazanych jako argumenty do funkcji “dodawanieOdejmowanie”. Wówczas zamiast przypisania wywołania funkcji do dwóch zmiennych wystarczy coś takiego:
suma, _ := dodawanieOdejmowanie(4, 1)
W tym momencie wyczerpałam chyba temat w planowanym na dzisiej wpisie. Na koniec jeszcze wspomnę o jednej kwestii. Być może jest to coś oczywistego, ale nie ukrywam, że na początku zabawy z GO pojawiała się u mnie taka pokusa. Otóż nie można definiować funkcji w ramach innych funkcji (w ciele tych drugich), a przynajmniej nie w taki sposób jak dzisiaj to zostało zademonstrowane. Tym samym każda “normalna” funkcja będzie miała zasięg globalny w ramach naszego pakietu lub jeśli zrobimy go zgodnie z wszelkimi prawidłami (nazwa jest z dużej litery i powołamy ją do życia w pakiecie innym niż main) możemy ją nawet importować do innych paczuszek. Tym niemniej można tę “niedogodność” (brak możliwości deklaracji nowych funkcji w ciele innych funkcji ) obejść dzięki funkcjom anonimowym oraz przy wykorzystaniu faktu, że w GO funkcje są tak naprawdę wartościami klasy pierwszej. Co się za tym tajemniczym określeniem kryje, to już temat na inną opowieść i zajmiemy się tym zagadnieniem w kolejnym wpisie.