Skip to content
← Wszystkie wpisy
6 min czytania Dawid Skłodowski

Ruby 2.1: refinements, wymagane argumenty nazwane i mądrzejszy GC

Ruby 2.1.0 ukazało się w Boże Narodzenie, jak nakazuje tradycja. Nagłówek to generacyjny garbage collector, ale to zmiany w języku — wymagane argumenty nazwane, refinements, Exception#cause — odmienią sposób, w jaki piszemy kod.

Ruby 2.1.0 wylądowało w Boże Narodzenie, podtrzymując uroczy zwyczaj projektu, by dostarczać wydanie pod choinkę. To wyważona aktualizacja, nie rewolucja — ale takie ujęcie jej nie docenia. Dwie funkcje języka zmieniają codzienny kod, jedna zmiana w runtime po cichu czyni większość aplikacji Rails szybszymi za nic, a garść mniejszych dodatków usuwa drobne uciążliwości, z którymi żyłeś latami. Oto czego naprawdę użyjemy i dlaczego.

Generacyjny garbage collector (RGenGC)

Nagłówkiem dla produkcji jest nowy generacyjny GC. Wykorzystuje on starą i gruntownie sprawdzoną obserwację: większość obiektów umiera młodo. Żądanie webowe alokuje tysiące krótko żyjących obiektów — łańcuchy, hashe, pośrednie tablice — które niemal natychmiast stają się śmieciem, podczas gdy znacznie mniejszy zbiór (załadowane klasy, długowieczne cache, pula połączeń) przeżywa całe życie procesu.

Przed 2.1 kolektor mark-and-sweep w Ruby przechodził każdy żywy obiekt przy każdym zbieraniu, płacąc raz za razem za ponowne badanie długowiecznych obiektów, których i tak nigdy nie zwolniono. RGenGC dzieli stertę na pokolenie młode i stare oraz uruchamia tanie mniejsze GC, które skanują tylko młode obiekty, awansując ocalałych do starego pokolenia i potem w większości zostawiając ich w spokoju. Kosztowne duże GC, które skanują wszystko, wciąż się zdarzają, tyle że znacznie rzadziej.

Naprawdę imponującą inżynierią jest to, że jest to wstecznie zgodne na poziomie rozszerzeń C. Kolektor generacyjny normalnie potrzebuje „write barrier” — powiadomienia, ilekroć stary obiekt zaczyna wskazywać na młody — a istniejące rozszerzenia C nic o barierach nie wiedzą. Rozwiązanie zespołu traktuje obiekty bez bariery jako „shady” i obchodzi się z nimi zachowawczo, więc rozszerzenia, które się nie zgłoszą, pozostają poprawne (tracą tylko część przyspieszenia). Efekt: aktualizujesz się, a typowe żądanie Rails — będące strumieniem alokacji młodych, skazanych obiektów — widzi realną poprawę opóźnień bez żadnych zmian w kodzie.

Jeśli chcesz zobaczyć to w działaniu, GC.stat ujawnia liczniki (liczby mniejszych vs większych GC, sloty sterty, liczbę starych obiektów), a zmienne środowiskowe RUBY_GC_HEAP_* pozwalają dostroić początkowy rozmiar i wzrost sterty dla długodziałającego serwera. Większość zespołów nie będzie musiała ich dotykać — domyślne zachowanie jest wygraną.

Wymagane argumenty nazwane

Ruby 2.0 wprowadziło argumenty nazwane, ale każdy potrzebował wartości domyślnej — nie było sposobu, by zadeklarować obowiązkowy. 2.1 domyka lukę: argument nazwany bez wartości domyślnej jest teraz wymagany, a jego pominięcie podnosi ArgumentError.

# Ruby 2.0 — default był kłamstwem; nil nie był tak naprawdę poprawną kwotą
def transfer(from:, to:, amount: nil)
  raise ArgumentError, "amount required" if amount.nil?
  # ...
end

# Ruby 2.1 — mów dokładnie to, co masz na myśli
def transfer(from:, to:, amount:)
  # wszystkie trzy są obowiązkowe ORAZ nazwane w miejscu wywołania
end

transfer(from: alice, to: bob, amount: 50)
transfer(from: alice, to: bob)   # => ArgumentError: missing keyword: amount

To większa sprawa, niż z początku wygląda. Argumenty pozycyjne to podatek od czytelności w miejscu wywołania: charge(account, 50, true, false) nic ci nie mówi o tym, co znaczą true i false, i nie da się ich przestawić ani pominąć. Stara obejściówka — hash opcji z ręcznym fetch/walidacją — to boilerplate, który każda baza kodu wynajdywała na nowo. Wymagane argumenty nazwane dają samodokumentujące się wywołania oraz wymuszoną obecność oraz swobodną kolejność, bez żadnego kodu walidacji. Dla każdej metody z więcej niż dwoma parametrami albo z dowolną flagą boolean to nowy styl domyślny i już sięgamy po niego wszędzie.

Refinements: monkey-patching z ograniczonym zasięgiem rażenia

Refinements wychodzą ze stanu „eksperymentalnego” z jaśniejszym, bezpieczniejszym projektem. Rozwiązują problem ciemnej strony otwartości Ruby — monkey-patchingu. Otwórz ponownie String, by dodać metodę, a zmieniłeś String dla całego procesu: twojego kodu, każdego gemu, frameworka, na zawsze. Tak dwie biblioteki kończą, każda definiując String#truncate inaczej i po cichu psując się nawzajem, a wygrywa ta załadowana później.

Refinement to monkey-patch z przełącznikiem. Definiujesz zmianę wewnątrz modułu, gdzie leży uśpiona, dopóki plik (albo, nowość w 2.1, moduł) jawnie jej nie aktywuje przez using — i co kluczowe, aktywacja jest zasięgowana leksykalnie:

module Squishing
  refine String do
    def squish
      strip.gsub(/\s+/, " ")
    end
  end
end

# tylko kod, który się zgłosi, widzi #squish:
using Squishing
"  hello   world  ".squish   # => "hello world"

Bez using String#squish nie istnieje. Żaden inny plik, gem ani komponent frameworka nie jest dotknięty — zmiana jest zawężona do zasięgu leksykalnego, który o nią poprosił. To długo obiecywana odpowiedź na „chcę dodać metodę do klasy rdzennej bez zatruwania globalnej przestrzeni nazw”. Ergonomia w 2.1 wciąż ma ostre krawędzie — reguły zasięgu leksykalnego są subtelne, nie można robić refine wewnątrz bloku, a wyszukiwanie metod przy aktywnych refinements zaskakiwało ludzi — więc to nie hurtowe zastępstwo dla Module#prepend czy kompozycji. Ale kierunek jest dokładnie właściwy: wygoda monkey-patchingu z opanowanym zasięgiem rażenia, i to narzędzie, po które sięgasz, gdy inaczej otworzyłbyś ponownie klasę rdzenną.

Exception#cause: łańcuch jest zapisywany za ciebie

Cichy faworyt. Gdy łapiesz jeden błąd i podnosisz inny, Ruby 2.1 automatycznie zapisuje oryginalny jako cause nowego wyjątku — bez ręcznego owijania:

begin
  parse_config
rescue SyntaxError
  raise ConfigError, "plik konfiguracji jest nieprawidłowy"  # oryginalny SyntaxError zachowany
end

ConfigError#cause zwraca leżący u podstaw SyntaxError, a dobrze zachowujący się wypisywacz backtrace przejdzie łańcuch. Koniec z połykaniem prawdziwej porażki, gdy podnosisz przyjaźniejszą — ścieżka z powrotem do przyczyny źródłowej jest zachowana za ciebie.

Mniejsze przyjemności

Garść zmian dopełnia obraz, każda usuwając drobną codzienną uciążliwość:

  • def zwraca nazwę metody jako symbol. To czyni dekoratory metod czystymi: private def helper; end po prostu działa, koniec z private :helper w osobnej linii po definicji.
  • Array#to_h i Enumerable#to_h. Budowanie hasha z par nie potrzebuje już gimnastyki Hash[...]: [[:a, 1], [:b, 2]].to_h albo items.map { |i| [i.id, i] }.to_h.
  • String#scrub zastępuje nieprawidłowe bajty UTF-8 znakiem zastępczym — czysta poprawka na błędy invalid byte sequence, które trapią aplikacje obsługujące niechlujny tekst z zewnątrz.
  • Zamrażanie literałów łańcuchowych jest zoptymalizowane. "text".freeze jest teraz traktowane specjalnie i zwraca cache’owaną zamrożoną instancję zamiast alokować, co zapowiada nadchodzący świat frozen-string-literal i warto tego używać dla kluczy hashy i stałych na gorących ścieżkach już dziś.
  • Literały Rational i Complex zyskują przyrostki: 42r to Rational, 2i to Complex — przydatne dla każdego, kto robi dokładną arytmetykę.

Czy aktualizować?

Tak, i bez wielkich ceremonii. Usprawnienie GC to rodzaj darmowej wygranej, którą bierzesz od razu: zaktualizuj na stagingu, popatrz, jak wykresy pamięci i opóźnień się uspokajają, nie rób nic więcej. Funkcje języka są czysto addytywne, więc istniejący kod dalej działa — wymagane argumenty nazwane adoptujesz, dotykając metod, po refinements sięgasz, gdy następnym razem skusisz się na monkey-patch klasy rdzennej, a Exception#cause oraz wygody to_h/scrub dostajesz za darmo.

Myślą przewodnią 2.1 jest dojrzałość, nie nowość. Ruby jest strojony pod duże, długo działające aplikacje, które ludzie naprawdę uruchamiają na produkcji: szybszy collector dla serwera, jaśniejsze kontrakty argumentów dla bazy kodu, zdyscyplinowana alternatywa dla własnej najgroźniejszej funkcji i lepsze pochodzenie błędów na sesję debugowania o trzeciej nad ranem. Nic z tego nie jest efekciarskie i wszystko to rzecz, za którą po cichu jesteś wdzięczny pół roku później. Dobry prezent gwiazdkowy.