Blockchain

Podobieństwa i różnice pomiędzy Solidity a Javą

W czasie tylko jednego roku wartość wszystkich kryptowalut skoczyła z 17 mld USD w styczniu 2017 roku aż do 830 mld w styczniu 2018 roku, co daje wzrost o prawie 5000%. Skok w zainteresowaniu tą technologią był bardzo nagły, biorąc pod uwagę, że wzrost od początku do poziomu 17 mld dolarów trwał prawie 8 lat (2009 – 2017). Ale w kryptowalutach nie chodzi tylko o pieniądze i nowe waluty — ważny jest także blockchain, który pod względem technologicznym jest nawet ciekawszy. Obserwując ciągły wzrost zainteresowania, zdecydowałem się bliżej przyjrzeć co stoi za blockchainem, jak tworzyć swoje zdecentralizowane aplikacje oraz czym to się w ogóle różni od dewelopowania w Javie i Kotlinie, w których piszę kod codziennie od kilku lat.


Dominik Hys. Android Developer w firmie inFullMobile, która jest międzynarodowym software housem i digitalowym studiem projektowym z siedzibą w Warszawie. Absolwent wydziału Matematyki, Informatyki i Mechaniki na Uniwersytecie Warszawskim. Z programowaniem zawodowo związany od 3 lat. Entuzjasta technologii opartych na blockchainie i autor artykułów o kryptowalutach.


Aplikacje na blockchainie Ethereum

Razem z uruchomieniem platformy opartej na blockchainie Ethereum tworzenie własnych zdecentralizowanych aplikacji (ang. dapp) oraz pisanie tzw. smart contractów (dosłownie inteligentne kontrakty, a w praktyce to kawałki kodu źródłowego osadzonego na blockchainie) stało się dużo prostsze. Najpopularniejszym narzędziem do tego służącym został nowy język stworzony specjalnie w tym celu — Solidity. Dzięki temu ułatwieniu i usprawnieniu procesu, ludzie na całym świecie zaczęli tworzyć setki zdecentralizowanych aplikacji. Jedne miały mieć realne zastosowania, inne były zrobione tylko dla nauki i zabawy. Bezsprzecznie najpopularniejszą z nich została CryptoKitties, która pozwalała na zakup i hodowlę wirtualnych kotków. W jednym momencie ruch stał się tak duży, że zwolnił całą sieć Ethereum. Ludzie kupowali i sprzedawali swoje wirtualne zwierzaki tak szybko, że suma wszystkich transakcji osiągnęła 27 mln USD, a najdroższy został sprzedany za ponad 170 tys. USD. Widząc ten nagły wzrost zainteresowania, postanowiłem nauczyć się Solidity. W tym celu tymczasowo zawiesiłem swoją karierę Android Developera i zacząłem pisać smart contracty na pełny etat.

Programowanie Obiektowe vs Programowanie Kontraktowe

Solidity to język stworzony, aby postępować według zasad programowania kontraktowego (ang. Design by contract) podczas tworzenia oprogramowania. Sam koncept nie jest nowy — został stworzony przez Bertranda Meyera w 1986 r. podczas prac nad nowym językiem programowania o nazwie Eiffel. Zachęca on programistów do formalnego zdefiniowania warunków wstępnych, końcowych i niezmienników każdej składowej oprogramowania. Te formalne specyfikacje nazywają to “kontrakty”. W Solidity nazywają się tak samo i są bardzo podobne do klas w Javie. Obie te rzeczy mogą mieć konstruktor, metody publiczne i prywatne, zmienne lokalne i globalne oraz mogą być inicjalizowane. Jednakże kontrakty w Solidity mają także publiczne adresy w blockchainie po wypuszczeniu ich tam oraz mogą przechowywać i przesyłać wartość.

Podobieństwa i różnice pomiędzy Solidity a Javą

Największymi różnicami w wyglądzie kodu pomiędzy tymi językami są modyfikatory (modifiers) oraz zdarzenia (events). Obydwie te rzeczy są zazwyczaj deklarowane na początku kontraktu.

  • Modyfikatory (modifiers)

Modyfikatory są używane do ograniczenia kto może wprowadzać zmiany w stanie kontraktu oraz wywoływać jego funkcje. Dodatkowo modyfikatory mogą być użyte wielokrotnie w kilku funkcjach. Są one także narzędziem do formalnego egzekwowania warunków początkowych i końcowych.

contract NumberContract {
  address private contractIssuer;
  uint private number;
  
  modifier onlyContractIssuer {       
      require(contractIssuer == msg.sender);
      _;
  }
  
  function NumberContract() public {
      contractIssuer = msg.sender;    
  } 
  
  function setNumber(uint newNumber) onlyContractIssuer public {
      number = newNumber;
  }
}
  • Zdarzenia (events)

Zdarzenia pozwalają na wykorzystanie mechanizmów logowania maszyny wirtualnej Ethereum (EVM) oraz mogą być użyte do wywoływania callbacków, które ich nasłuchują. Są to fragmenty kontraktów, które mogą być dziedziczone. Kiedy są wywołane, ich argumenty są wpisywane i przechowywane w dzienniku transakcji (transaction’s log), czyli specjalnej struktury danych na blockchainie. Dzienniki te są związane z adresem kontraktu i są włączone do blockchaina, gdzie zostaną tak długo, jak długo dany blok będzie dostępny. Warto też zauważyć, że dane dziennika ani zdarzeń nie są dostępne z wewnątrz kontraktów (nawet kontrakt, który je utworzył nie ma do nich dostępu).

contract NumberContract {
  address private contractIssuer;
  uint private number;
  
  event NumberSet(uint number);
  
  modifier onlyContractIssuer {       
      require(contractIssuer == msg.sender);
      _;
  }
  
  function NumberContract() public {
      contractIssuer = msg.sender;    
  } 
  
  function setNumber(uint newNumber) onlyContractIssuer public {
      number = newNumber;
      NumberSet(newNumber);
  }
}
  • Dziedziczenie wielokrotne (multiple inheritance)

Solidity wspiera dziedziczenie wielokrotne kopiując kod (włączając w to polimorfizm). Wszystkie wywołania funkcji są wirtualne, co znaczy, że wywołana jest najbardziej pasująca funkcja poza przypadkami kiedy nazwa kontraktu jest wyraźnie podana. Kiedy kontrakt dziedziczy po wielu kontraktach, tylko jeden kontrakt jest stworzony na blockchainie, a cały kod z kontraktów bazowych (nadkontraktów) jest kopiowany do nowopowstałego kontraktu. Cały system dziedziczenia jest więc bardzo podobny do tego znanego z języka Python (szczególnie fragmenty dotyczące dziedziczenia wielokrotnego)

  • Widoczność funkcji – external

Większość typów widoczności funkcji w Solidity jest bardzo intuicyjna oraz podobna do odpowiedników w Javie (private, public, internal). Jest jednak jeden nowy typ — external. Funkcja tak zadeklarowana może być wywołana jedynie spoza klasy, do której należy — nie można jej zawołać z wewnątrz.

  • Modyfikatory funkcji – pure, view, payable

Funkcja zadeklarowana jako pure nie ma dostępu ani nie może modyfikować stanu (zmiennych, map, tablic itd.). Jest to najbardziej restrykcyjny modyfikator, ale dzięki temu jest najbardziej bezpieczny i pozwala zachować najwięcej gasu (czyli małych cząstek Ethereum, które są spalane podczas obliczeń i operacji).

Modyfikator view działa bardzo podobnie do pure, jednak pozwala na dostęp do stanu (ale nie na jego modyfikowanie).

Jeśli chcemy, żeby funkcja była w stanie otrzymywać Ethereum podczas wywołania jej, musimy ją zadeklarować z modyfikatorem payable. Pozwala to na wykonywanie wszystkich operacji związanych z pieniędzmi (m. in. przelewy czy deponowanie).

contract NumberContract {
  address private contractIssuer;
  uint private number;
  mapping(address => uint256) public availableWithdrawals;
  
  modifier onlyContractIssuer {       
      require(contractIssuer == msg.sender);
      _;
  }
  
  modifier hasPositiveBalance(address user) {
    require(availableWithdrawals[user] > 0);
    _;
  }
  
  function NumberContract() public {
      contractIssuer = msg.sender;
  } 
  
  function deposit() public payable {
      availableWithdrawals[msg.sender] = 
            safeAdd(availableWithdrawals[msg.sender], msg.value)
  }
  
  function withdraw() public payable hasPositiveBalance(msg.sender) {
    uint256 amount = availableWithdrawals[msg.sender];
    availableWithdrawals[msg.sender] = 0;
    msg.sender.transfer(amount);
  }
  
  function getAmountToWithdraw() public view returns (uint256) {
    return availableWithdrawals[msg.sender];
  }
  
  function safeAdd(uint256 a, uint256 b) internal pure returns (uint256) {
    uint256 c = a + b;
    assert(c >= a);
    return c;
  }
  
  function setNumber(uint newNumber) onlyContractIssuer public {
      number = newNumber;
  }
}
  • Dodatkowe dane

Kiedy funkcja zostaje wywołana przez transakcję, ma ona dostęp do dodatkowych danych o wywołującym, które są dołączane automatycznie. Są to m.in. adres wywołującego (msg.sender), suma Ethereum, która została przekazana (msg.value) oraz pozostały gas (msg.gas) — w powyższym fragmencie kodu znajdują się przykłady użycia tych danych

  • Struktury danych

W Javie mamy wiele wbudowanych struktur danych — zarówno skomplikowanych, jak i prostszych. W Solidity są dwie — mapa (mapping) oraz tablica (array). Pierwsza z nich jest uproszczoną wersją mapy z Javy — może przechowywać pary klucz-wartość oraz sprawdzać wartość dla danego klucza, ale niewiele więcej (nie można po niej iterować, nie można pobierać zbiorów kluczy ani wartości). O tablicach napiszę więcej w następnym podpunkcie.

  • Optymalizacje

Pisanie kodu w Solidity wymaga zwracania uwagi na wiele więcej detali niż w Javie. Każda transakcja jest wywołaniem funkcji wewnątrz kontraktu, które wymagają pewnych obliczeń i operacji. Każda operacja zużywa gas, za który musimy zapłacić Ethereum (dodatkowo poza sumą, którą chcemy przesłać odbiorcy). Jednak na każdą transakcję nałożony jest limit gasu, który może ona zużyć — jeśli zostanie on przekroczony, cała funkcja zostanie cofnięta, a wszystko zostanie przywrócone do stanu sprzed jej wywołania. Jest to drastycznie inne podejście do optymalizacji kodu w stosunku do Javy, gdzie każdy poprawny kod się wykona — wydłużyć się może tylko czas trwania programu.

Pułapki do uniknięcia

Jeśli jesteś przyzwyczajony do pisania kodu w Javie, w Solidity może Cię zaskoczyć wiele innych i nie do końca intuicyjnych rzeczy. Jednak musisz o nich wiedzieć, jeśli chcesz uniknąć błędów, które są trudne do wykrycia i zreprodukowania. Opiszę teraz kilka z nich:

  • Błędy przepełnienia (overflow errors)

W Solidity wszystkie operacje na liczbach całkowitych mogą powodować błędy przepełnienia (tzw. “Przekręcenie licznika”). Co więcej, nie ma żadnych wbudowanych w język operacji odpornych na te błędy, co znaczy, że za każdym razem programista musi ręcznie sprawdzać czy operacja się powiedzie. Służy do tego komenda require, która działa jak assert –– nie dopuszcza do wykonania funkcji jeśli dany warunek nie jest spełniony.

  • Różnica między ‘Byte’ i ‘Bytes’

Typ Byte ma 256 bitów, co oznacza, że tablica byte (byte[]) zajmuje 32x więcej pamięci niż mogłoby się wydawać, co jest dużym problemem biorąc pod uwagę limit gasu, który jest relatywnie niski i nie powinno się go marnować na niepotrzebne zużywanie pamięci. Jeśli chcemy zadeklarować zwyczajną tablicę bajtów, powinniśmy użyć konstrukcji bytes.

  • Stringi

Solidity domyślnie nie wspiera żadnych operacji na ciągach znaków. Kiedy piszę ten artykuł nawet wydawałoby się podstawowe operacje, takie jak łączenie dwóch stringów, muszą być zaimplementowane ręcznie po zrzutowaniu ich do tablicy bajtów (byte arrays). Nawet sprawdzenie długości ciągu znaków nie obejdzie się bez powyższego rzutowania. Co więcej, nie ma żadnych wbudowanych funkcji, które są w Javie — np. indexOf(). Muszą one być zaimplementowane ręcznie, skopiowane z innego projektu albo wywoływać funkcję z biblioteki, która została wcześniej opublikowana na danym blockchainie.

  • Tablice (arrays)

Składnia dostępu do elementu tablicy wygląda dokładnie jak w Javie, jednak w praktyce jest ona odwrócona — int8[][5] stworzy 5 dynamicznych tablic bajtów. Niestety istnieje także obostrzenie, przez które funkcje mogą zwracać tylko statyczne tablice i nie ma sposobu na zwrócenie ich dynamicznych odpowiedników. Dodatkowo nie da się stworzyć dynamicznych tablic wielowymiarowych, przez co np. nie istnieje coś takiego jak tablica stringów (które są dynamicznymi tablicami znaków).

  • Pętle for

Solidity zostało stworzone, aby jak najbardziej przypominać JavaScript, jednak stała 0 domyślnie jest typu byte, a nie int. Przez to jeśli napiszemy:

wejdziemy w nieskończoną pętlę jeśli tablica a ma więcej niż 255 elementów (iterator dojdzie do 255, a potem wróci do 0 i zacznie liczyć od początku, więc nigdy nie będzie równy długości tablicy a). Dzieje się tak nawet pomimo faktu, że ukryta pod spodem maszyna wirtualna zużyje 256 bitów do przechowywania tego licznika. Z tego powodu powinniśmy go zadeklarować jako uint zamiast var.

  • Semantyka operatorów

Operatory mają różną semantykę w zależności od tego czy argumenty są literałami, czy nie. Przykładowo 1/2 to 0.5, ale x/y dla x == 1 i y == 2 to 0. Dodatkowo precyzja operacji jest ustalana w zależności od tego warunku — literały mają arbitralną precyzję, a inne wartości są ograniczone przez ich typ.

  • Mapa (Mapping)

Główną różnicą map w Solidity od ich odpowiedników w Javie jest fakt, że nie rzucają wyjątkiem dla kluczy, które nie istnieją. Zamiast tego, zwracają one domyślną wartość w zależności od zadeklarowanego typu klucza (np. dla intów jest to 0). Dodatkowo nie ma żadnego sposobu, żeby sprawdzić czy do danego klucza jest przypisana jakaś wartość (odpowiednik metody contains() w Javie) — w momencie, gdy mapa zwraca wartość 0, to nie wiemy czy taka wartość rzeczywiście została dodana, czy jest to tylko domyślnie zwracana stała reprezentująca brak klucza w mapie. Jak wspomniałem wcześniej, nie ma także żadnego sposobu, aby wyekstraktować zbiór kluczy lub wartości z mapy, przez co nie da się po nich także iterować.

Podsumowanie

Solidity przedstawia unikalne i jedno z pierwszych podejść do pisania kontraktów na blockchainie. Język ten jest cały czas w bardzo wczesnej fazie rozwoju i bardzo dużo mu brakuje do bycia kompletnym i w pełni funkcjonalnym. Jednakże rozwija się on w bardzo szybkim tempie, więc jestem pewny, że w niedalekiej przyszłości duża część błędów zostanie naprawiona, a nowe funkcjonalności będą stopniowo dodawane. Z drugiej strony w czasie projektowania tego języka zostały podjęte decyzje, które prawdopodobnie nie są całkowicie odwracalne. Najważniejsze błędy tego rodzaju mogą zaważyć na bezpieczeństwie całej aplikacji, a język ten powinien właśnie na bezpieczeństwo stawiać największy nacisk jako, że programy w nim pisane operują naszymi pieniędzmi.

Pomimo tego, wszystkie funkcje domyślnie są publiczne, operacje na liczbach mogą się przepełnić, pętle mogą się nigdy nie skończyć, a wprowadzanie nowej wersji kodu nastręcza wielu problemów. Najbardziej podstawowym błędem jest to, że Solidity jest na pierwszy rzut oka bardzo podobne do znanych nam wcześniej języków programowania, przez co ludzie powinni być w stanie intuicyjnie domyślić się jak wszystko działa pod spodem w maszynie wirtualnej. Niestety w rzeczywistości bardzo często jest zupełnie odwrotnie, co może zmylić programistów, a także spowodować wycieki pamięci oraz dziury w bezpieczeństwie. Każdy nowopowstały język (a szczególnie taki, który ma zarządzać transakcjami pieniężnymi) powinien być zaprojektowany tak, żeby jego nauka była jak najprostsza i najbardziej intuicyjna. W obecnej formie jest dokładnie odwrotnie — bardzo łatwo jest się nauczyć coś zepsuć, a trudno jest zrobić coś dobrze.


baner

Click here for English version. Photo by André François McKenzie on Unsplash.

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://geek.justjoin.it/podobienstwa-i-roznice-pomiedzy-solidity-a-java/" order_type="social" width="100%" count_of_comments="8" ]