Programowanie w Go - wzmacniamy systemów typów dla większej poprawności kodu
System typów w językach programowania to gruby temat. Jedni, często początkujący, ich wręcz nienawidzą, bo utrudniają im życie, z kolei inni nie wyobrażają sobie bez nich pisania najmniejszego choćby programu. W założeniu system typów ma pozwolić na wyłapanie części prostych błędów, na etapie automatycznej analizy kodu (np. kompilacji) które potem mogą zakraść się na naszą produkcję, aby niepostrzeżenie ugryźć nas w tyłek w najmniej sprzyjającej ku temu okazji.
Czy obecność statycznego i silnego typowania nam pomoże w uniknięciu błędów? Jeśli chodzi o proste błędy - tak, ale w tym artukule pokażę pewien rodzaj błędów, który lubi nawet przechodzić nawet przez testy jednostkowe, jeśli się do nich niespecjalnie przyłożymy. Go właśnie uchodzi za język z silnym typowaniem, jednak niech naszej czujności to nie usypia, o czym właśnie w tym artykule.
Na początek wyjaśnienie kwestii silnego typowania, jest wiele tego definicji, ale skupmy się na bardziej uproszczonym wyjaśnieniu: jeżeli mamy liczbę, to jest to liczba, a nie tekst. Jeśli nasza funkcja oczekuje liczby, to jak podamy jej tekst, to będzie to błąd niezgodności typów. Nawet jeśli naszym tekstem jest liczba, np "5"
to jest to wciąż inny typ niż “surowa” liczba (typu int przykładowo) 5
.
Początkujący mogą tu spytać: skąd takie ograniczenia i po co nam one? Najprościej tłumacząc: jak dodać do siebie 1
i Ala ma kota
- na pierwszy rzut oka, nawet Ty możesz nie wiedzieć, czy dopisać 1
do tekstu Ala ma kota
czy co innego zrobić. Skoro człowiek ma problemy by w pierwszej chwili to odgadnąć, to co dopiero automat i po to jest właśnie system typów - dodawanie liczb, dodaje liczby, a dodawanie stringów (czyli przykładowa Ala
+ ma
+ kota
) zwyczajnie łączy je. Oczywiście to mocno uproszczone wyjaśnienie z mojej strony, ale mniej więcej oddaje sens tego, o co tu chodzi.
Po tym szybkim wprowadzeniu wiemy już, że w Go mamy system typów, nie tak zaawansowany i rozbudowany jak np. w języku Rust, ale mamy i co z tym teraz możemy zrobić? Używać go, tylko tu pojawia się pytanie jak to robić?
Spis treści
- Spis treści
- Problem: ten sam typ dla różnoznacznych wartości
- Rozwiązanie
- Problem podobny: kolejność argumentów do funkcji (i rozwiązanie)
- Problemy z wartością domyślną (głównie dotyczy typów liczbowych oraz string)
- Rozwiązanie problemu domyślnych wartości
- Podsumowanie
- Linki
Problem: ten sam typ dla różnoznacznych wartości
Wyobraźmy sobie, że mamy prostą aplikację, która operuje na Users (na użytkownikach), Groups (grupy, do których użytkownicy przynależą) oraz Companies (firmy, do których użytkownicy mogą należeć, w sensie w nich pracować)
Spiszmy proste zasady, które nam pomogą określić przykład:
- Każdy użytkownik (Users) musi należeć do jakiejś ogólnej grupy (Groups)
- Użytkownicy mogą należeć do jakichś firm (Companies) - jednej
- Każdy rekord w każdej z wymienionych baz (Users, Groups, Companies) ma swój własny
unikalny identyfikator, czyli jak mamy użytkowników
Janusz Kowalski
orazGrażyna Kowalska
, to nawet jeśli należą do tej samej rodziny, grupy i nawet pracują w tej samej firmie (po znajomości), to i tak, w aplikacji i bazie danych są identyfikowani różnymi, unikalnymi dla nich identyfikatorami (np. numerem rekordu w bazie, losowy identyfikatorem ciągiem-znaków, nieważne). Dla uproszczenia niech te identyfikatory to są liczby typuint
.
Z tych prostych zasad wynika, że użytkownicy zawsze należą do jakiejś ogólnie grupy, niekoniecznie jednak należą do jakiejś firmy. Przykład jak to w życiu: każdy jest mieszkańcem jakiegoś miasta (to traktujemy jako Groups), ale niekoniecznie każdy ma zatrudnienie w jakiejś firmie (Companies).
Przyjmijmy w tym przykładzie, że każdy użytkownik, grupa, firma i departament są identyfikowane po jakimś unikalnym identyfikatorze, który jest typu int
jak wcześniej założyliśmy - unikalny numer w bazie. Załóżmy, że chcemy mieć funkcje, które nam wyświetlają informacje o danych rekordach z tych 4 podstawowych zbiorów danych, np:
1func GetUserDetail(UserID int) string
2func GetGroupDetail(GroupID int) string
3func GetCompanyDetail(CompanyID int) string
Jak widać funkcje są proste do zrozumienia, w sensie to co robią. Wszystkie zwracają string
, ponieważ zakładamy, że w formie stringu właśnie zwracane są jakieś informacje typu:
To jest Janusz Kowalski, mieszka w Warszawie, ma lat 50 i nosi brodę
Na razie wszystko wydaje się proste i jasne, ale niektórzy doświadczeni programiści mogą tu już zauważyć problem, który chcę naświetlić.
Nasze funkcje przyjmują konkretne typy danych, w tym przypadku to typy int
(integer - liczba całkowita). Zatem, nie możemy do funkcji
podać ciągu znaków, jakiejś tablicy, jakiegoś structa czy nawet floata, czyli liczby zmiennoprzecinkowej. To jest to zabezpieczenie, które daje nam silny system typów w tym przypadku - funkcja przyjmuje int
i tylko taki typ możesz podać.
Jednakże problem, który chcę przedstawić wcale nie dotyczy tego, że ktoś poda zły typ danych zamiast int
. Problemem będzie to, że istotnie my będziemy podawać do funkcji int
, ale… nie ten, o który nam chodzi.
Problem jaki z tego może wyniknąć, jest następujący: jak mogę rozróżnić, czy do właściwej funkcji podałem właściwą liczbę reprezentowaną przez typ int
? Pod pojęciem “właściwy” rozumiem tutaj: Jeśli chcę informacje o użytkownikach, muszę podać ID użytkownika, a nie ID grupy
.
Widzisz teraz ten problem? Zarówno ID użytkownika oraz ID grupy są typu int
więc jakby nie było, w moim kodzie mogę się zwyczajnie pomylić i podać je do niewłaściwej funkcji, bo system typów tego już nie wyłapie w tej sytuacji. Przecież do funkcji podam int
, więc o co chodzi?
To jednak problem “małego” kalibru, że pomylę sobie ID użytkownika, z ID grupy lub firmy i ostatecznie, zamiast Janusza
aplikacja poda mi dane Mirka Handlarza
. Dlaczego uważam to za problem małego kalibru? Bo w tym przypadku nie bawiłem się w modyfikowanie danych.
A co się stanie, gdy będziemy mieć do czynienia z takimi funkcjami:
1func SetUserDetail(UserID int) error
2func SetGroupDetail(GroupID int) error
3func SetCompanyDetail(CompanyID int) error
Tu jak widać, mamy znowu podobne funkcje, ale sprawa jest grubszego kalibru, bo one już wprowadzają modyfikacje w naszych danych. Nie są więc pasywnie działającymi funkcjami, które jedynie zwracają co otrzymają, a uprawiają już konkretne grzebactwo, np. w bazie danych, które może się skończyć zepsuciem danych.
Znowu jak na dłoni widać tu problem, że mogę podrzucić identyfikator grupy lub firmy do funkcji, która pobiera identyfikator użytkownika i licho tylko wie co się stanie - jak będziemy mieć sporo szczęścia, to rekordu użytkownika o błędnie podanym identyfikatorze, który okazał się być identyfikatorem grupy, nie będzie w bazie i dostaniemy błąd na twarz - to optymistyczna wersja wydarzeń. Pesymistyczna niestety spowoduje, że baza przyjmie ten identyfikator i wykona jakąś modyfikacje na rekordzie, na którym w rzeczywistości nie chcieliśmy nic robić.
To jak wciągnięcie kawałka szmatki do wnętrza pracujące silnika spalinowego przez niefiltrowany kolektor dolotowy - widzisz to i już wiesz, że będzie problem, nie wiesz tylko kiedy i jak się objawi. I to wszystko w języku programowania z silnym typowaniem.
Rozwiązanie
Jak możemy rozwiązać ten problem? Zobaczmy:
1type (
2 TypeUserID int
3 TypeGroupID int
4 TypeCompanyID int
5)
6
7
8func SetUserDetail(UserID TypeUserID) error
9func SetGroupDetail(GroupID TypeGroupID) error
10func SetCompanyDetail(CompanyID TypeCompanyID) error
To co teraz tutaj się stało, to wprowadzenie “starych” typów int
w ich nowych przebraniach nazwijmy to tak. Teraz zobaczmy jakie to będzie niosło dla nas konsekwencje, przedstawię uproszczony przykład.
1package main
2
3import (
4 "errors"
5 "fmt"
6)
7
8type (
9 TypeUserID int
10
11func SetUserDetail(UserID TypeUserID) error {
12
13 if UserID == 0 {
14 return errors.New("it's zero, that's wrong!")
15 }
16
17 return nil
18}
19
20func main() {
21 var userID int = 1
22 fmt.Printf("userID type: %T\n", userID)
23 SetUserDetail(userID)
24}
Powyższy kod się nie skompiluje, dostaniemy na twarz komunikat:
cannot use userID (type int) as type TypeUserID in argument to SetUserDetail
Rozchodzi się o to, że SetUserDetail()
chce typu TypeUserID
(który jest jakby int
, ale w “przebraniu”), a dostaje zmienną
userID
, która jest typu int
. Jak więc widać, podaliśmy int
, ale w zupełnie innej nazwie, zmiana wydaje się być zupełnie kosmetyczna, ale powoduje, że teraz nieskompilujemy programu z powodu zwyczajnej niezgodności typów.
Dzięki tej prostej zmianie widać, że TypeUserID
, TypeGroupID
, TypeCompanyID
to mogą być z punktu widzenia kompilatora zupełnie inne liczby, pomimo, że tak naprawdę to wszystko są typy int
pod maską.
No dobra, ale ktoś obeznany w typach może teraz powiedzieć:
Zaraz, zaraz, przecież możemy łatwo typ “skonwertować” na inta, w taki sposób:
1SetUserDetail(TypeUserID(userID))
W ten sposób nie mamy już błędu!
I będzie to prawda, ponieważ TypeUserID
to pod spodem zwyczajny int
. Ktoś widząc to, może uznać te “płaszcze” dla naszego int
a jako niepotrzebne komplikowanie sprawy, jednak właśnie tu kryje się największa tajemnica tego podejścia.
Otóż robiąc TypeUserID(userID)
mówimy do kompilatora coś w tym stylu:
Słuchaj kompilator, mamy taki deal: Ty wiesz, że ten
TypeUserID
to tak naprawdęinteger
, ja wiem, że to tak naprawdęinteger
, czyli jakby nie było, obaj to wiemy, w związku z tą sytuacją ja daję Ci jawnie znać, że będę robił świadomy przekręt i proszę byś mi w tym przekręcie nie przeszkadzał teraz!
Przypomnijmy zatem nasz problem naświetlony na początku: jeśli użyjemy w naszych funkcjach uniwersalnego typu int
, to narażamy się, że niewłaściwe funkcje otrzymają niewłaściwe identyfikatory, bo wszystkie identyfikatory w tych przykładach będą liczbami.
Wprowadzając nowe typy będące tak naprawdę intem pod przykrywką minimalizujemy ryzyko takiego błędu, chyba, że świadomie przeprowadzamy machloje w stylu jawnej konwersji typu wiedząc, że zmieniamy typ - ale wtedy będziemy wiedzieć, że “robimy coś źle”. Nie ma tutaj teraz znaczenia czy spowoduje to jakiś błąd w logice naszego programu czy, chodzi o to, że zostaliśmy zmuszeni do tego “przekrętu”, więc bierzemy za niego odpowiedzialność.
Problem podobny: kolejność argumentów do funkcji (i rozwiązanie)
Następny problem jaki możemy natrafić jest niejako rozszerzeniem pierwszego i potrafi pięknie zepsuć funkcję:
1func SetDetails(UserID, GroupID, CompanyID int) error
Pięknie tutaj widać co się może stać: można pomylić kolejność i np. pomieszać między sobą UserID
, GroupID
czy
CompanyID
. Znowu, w tej sytuacji kompilator nie uratuje nam tyłka, bo przecież podajemy wszędzie int
Jeśli jednak zdecydujemy się wykorzystać rozwiązanie z poprzedniego rozdziału, to taki problem już nie ma prawa wystąpić:
1func SetDetails(UserID TypeUserID, GroupID TypeUserID, CompanyID TypeCompanyID) error
Możemy się zgodzić, że znacznie więcej tu pisania teraz, ale benefit oczywisty: typów i ich kolejności nie będzie się dało pomylić, chyba, że znowu chcemy się sami sabotować, ale nie po to wprowadzamy takie rozwiązania do kodu, aby sobie robić niepotrzebnie pod górę.
Problemy z wartością domyślną (głównie dotyczy typów liczbowych oraz string)
Zajmijmy się teraz typem int
i string
. Pisząc o int
, mam również na myśli wszystkie jego odmiany, np int32
itp.
Generalnie to dosyć często używane typy w programach, powiedziałbym, że wręcz są bardzo popularne. Otóż obydwa te typy cierpią na pewną przypadłość języka Go: tzw. koncept zero value. Chodzi o to, że przykładowo domyślną wartością dla typów int
jest 0
, a domyślną wartością string
jest pusty string - ""
.
W większości przypadków nie będzie to problem, ale czasem możemy pisać aplikację, w której te wartości domyślne, czyli 0
lub ""
są wartościami użytecznymi w logice programu. Np. pomyślmy o odległości, jeśli mamy typ int
, to wartość 0
może oznaczać, że jesteśmy we wskazanym miejscu. Weźmy ceny produktów (gdy nie używamy floatów i zewnętrznych bibliotek do decimali), w takiej sytuacji cena 0
również jest poprawna.
Więc naszym problemem będzie fakt, że możemy w aplikacji mieć użyteczną wartość, która ma swoje miejsce w logice aplikacji, a jednocześnie ta wartość jest wartością tzw. domyślną dla jakiegoś typu danych w języku Go.
1var price int
2
3// some program logic
4
5if price == 0 {
6 // price is zero?
7}
Powyższy kod, bardzo prosty, idealnie pokazuje ten problem - czy nasz warunek sprawdzi, czy cena rzeczywiście wynosi zero czy może została zainicjowana typu int
z domyślną wartością czyli 0
? Takiego rodzaju zagwozdkę będziemy tutaj mieć i ta zagwozdka jest ściśle związana z tym jak działa system typów w języku Go.
Rozwiązanie problemu domyślnych wartości
Niestety, ale rozwiązanie tego typu problemu zmusza nas do komplikacji naszego kodu (jeśli nie chcemy używać zewnętrznych bibliotek). Podaję tylko przykład koncepcji, nie mówię, że użyte tutaj nazwy pól są optymalne:
1type Price struct {
2 Value int
3 IsZero bool
4}
5
6var PriceDefault Price{} // default field values
7var PriceFree Price{0, true} // we explicitly set price to 0
8
9// some program logic
10
11if PriceFree.IsZero {
12 // here the price is indeed zero because IsZero=true
13}
14
15if !PriceDefault.IsZero {
16 // here price is not set - Value=0 but IsZero=false
17}
Jak widać, tutaj nasz z pozoru prosty typ int
przerodził się aż w strukturę z dwoma polami, z czego jedno będzie określać, czy wartość 0
jest jego prawdziwą wartością - tzn. została ustawiona w logice programu, a nie przyplątała się jako domyślna wartość typu. To wydaje się wprost overkillem na pierwszy rzut oka, ale podobne podeście spotkamy w wielu znanych bibliotekach
Podsumowanie
Jak zatem widać w obu zaprezentowanych przykładach, mamy pewne możliwości wpływania na wbudowany system typowania w języku, aby dostosować go do naszych potrzeb, tutaj skupiłem się na najprostszych przykładach z int
. Podobne zasady tyczą się przemycania pod płaszczykiem innych typów w języku Go.
Można również zadać sobie pytanie, kiedy to podejście stosować. Jak widać przy 3 dodatkowych typach pojawia się sporo dodatkowego kodu, a przy rozwiązywaniu problemu domyślnej wartości mamy jeszcze więcej takiego kodu. Być może zasadne będzie takie właśnie pytanie czy zawsze powinno stosować takie podejście?
Jeśli mamy prostą aplikację, która albo nie modyfikuje danych, albo nie operuje na wielu podobnych identyfikatorach. Takie podejście daje nam głównie możliwą ładną nazwę dla typa pod spodem (zamiast generycznego i nic nie znaczącego np. int
).
Również warto się zastanowić czy stosować takie podejście, jeśli wartości domyślnie niektórych używanych typów tzw. “zero value” mogą mieć znaczenie w logice naszej aplikacji. Wówczas powinniśmy się upewnić, że wartość domyślna danego typu nie spowoduje jakiegoś niespodziewanego błędu w naszej logice.
Jeśli jednak powyższe założenia/problemy nas nie dotyczą oraz nie chcemy ładnych nazw dla naszych typów danych, wówczas w takiej sytuacji problemy związane z tym artykułem możemy zupełnie pominąć i skupić się po prostu na używaniu standardowych typów danych, które oferuje język Go.