Symbol w Ruby – wyjaśniam
💡

Symbol w Ruby – wyjaśniam

Niby każdy wie jak używać, niby każdy wie gdzie, ale dlaczego i jakie to ma podłoże? Czym jest symbol w Ruby – wyjaśniam sobie i wszystkim, którzy do tej pory używali go bezwiednie. Kilka lat temu, gdy przechodziłem z .Net i C# w świat Ruby miałem początkowo problemy z właściwym zrozumieniem tego, o co tak naprawdę chodzi z tymi symbolami. Na jednej z rozmów kwalifikacyjnych dostałem pytanie o różnicę między Symbolem a Stringiem i nie potrafiłem tego wytłumaczyć. Po totalnym zbłaźnieniu się postanowiłem zbadać temat i teraz dzielę się z Tobą wiedzą, którą zdobyłem raz na zawsze.

Co to jest Symbol?

  • niezmienny obiekt będący nazwą innego obiektu
  • unikatowy dla każdej nazwy
  • niepowiązany z konkretnym instancjami danej nazwy w trakcie działania programu
  • to taki string, którego nie można zmienić

Symbol vs String

W Ruby wszystko jest obiektem, symbole i stringi są obiektami, instancjami klasy Symbol i klasy String.

String deklaruje się za pomocą cudzysłowów lub apostrofów.

"string" 'string'

Symbol definiuje się przez dwukropek przed literałem.

:symbol

Symbole mogą też zawierać cudzysłowy i apostrofy

:"symbol" :'symbol'

Pamiętaj, że taki zapis oznacza Symbol, a nie Stringa.

Jeśli używamy powyższego zapisu Symbol może zawierać spacje lub inne znaki specjalne. Przy tradycyjnej notacji, jako separatora używa się underscore.

Nie można modyfikować symboli

Stringi można modyfikować, symboli już nie. Symbole nie posiadają bang methods, zmieniających obiekt, który je wywołuje.

"string".upcase #=> "STRING"

:symbol.upcase #=> :SYMBOL

"string".upcase! #=> "STRING"

:symbol.upcase!
NoMethodError: undefined method 'upcase!' for :symbol:Symbol

str = "string"
str = str << " sample" #=> "string sample"

sym = :symbol
sym = sym << " sample" #=> NoMethodError: unfined method '<<' for :symbol:Symbol
sym = sym << :sampel #=> NoMethodError: unfined method '<<' for :symbol:Symbol

Rola symboli i stringów

Symbol identyfikuje coś ważnego, to obiekt dla unikalnych identyfikatorów.

String jest przewidziany do przechowywania słów lub kawałków tekstu, to typ dla danych tekstowych.

Symbole i stringi można wzajemnie konwertować

"string".to_sym #=> :string

:symbol.to_s #=> "string"

Symbol a pamięć

image

Ten sam string za każdym razem ma inny object_id, podczas, gdy ten sam symbol wywołany trzykrotnie zwraca tę samą wartość.

To jest key feature, dorodne mięso, słodziutki miąższ, cały bajer symboli w Ruby.

Każdy string, mimo że nosi tę samą wartość jest oddzielną instancją, ma własny object_id i własne miejsce w pamięci. Symbol natomiast jest unikatem, tworzonym tylko raz w trakcie działania programu. Za każdym razem, gdy wywołujemy symbol o tej samej wartości wywoływana jest jedna i ta sama instancja.

Ma to przełożenie na wydajność kodu. Każdy taki string zajmuje dodatkowe miejsce w pamięci.

Ciekawostka historyczna! Od Ruby 2.2.0 symbolami zajmuje się Garbage Collector. Wcześniej kwestia wydajnościowa pomiędzy Stringiem a Symbolem była podchwytliwa. Mianowicie symbole były przechowywane w pamięci przez całą długość działania programu, co w przypadku dynamicznego generowania symboli np. z odpowiedzi z zewnętrznych serwisów w aplikacji działającej bez restartu dłuższy czas prowadziło do wycieku pamięci. Od Ruby 2.2.0 nie ma potrzeby martwić się kwestią pamięci przy znacznej ilości symboli. W przypadku stringów sytuacja pozostaje bez zmian.

Aby zrozumieć w jaki sposób Garbage Collector zajmuje się symbolami, należy zrozumieć różnicę między symbolami jawnie deklarowanymi a dynamicznie tworzonymi.

Symbole deklarowane jawnie vs Symbole tworzone dynamicznie

:symbol #=> deklaracja
"symbol".to_sym #=> dynamiczna kreacja

Garbage Collector czyścił jedynie dynamicznie tworzone symbole.

image

Implementacja Garbage Collector w Ruby 2.2.0 i wyższym pozwala bronić się przed zjawiskiem zwanym symbol DoS – symbol denial of service attack.

Symbol Dos ma miejsce, gdy system dynamicznie tworzy dużo symboli, np. obrabiając jsonową odpowiedź z API. Dochodzi do wycieku pamięci.

Mimo usprawnienia mechanizmu Garbage Collectora tworzenie symboli z otrzymanych danych nigdy nie będzie do końca bezpiecznie np. przy dynamicznym tworzeniu metody z parametrów:

definie method(params[:method].to_sym) do
end

Ten symbol został dynamicznie utworzony, aby wyzwalać metodę. Gdy będzie on dalej używany, po zniszczeniu przez Garbage Collector, nie będzie już możliwości wywołania tej metody.

Ten sam String, użyty kilka razy będzie za każdym razem instancjonowany i niszczony, a Symbol, ponieważ zajmuje jedno, stałe miejsce w pamięci będzie używał tej samej instancji przez całą długość działania programu. Podobnie jest w przypadku Symbolu kreowanego dynamicznie, do momentu, gdy nie zostanie oznaczony do zniszczenia przez Garbage Collector.

Wydajność przy operatorach równości

Kwestie pamięciowe uwidoczniają się w momencie porównywania Stringów i Symboli. Ponieważ stringi są zmienne i każdy z nich ma swój object_id, Ruby tak naprawdę nigdy nie wie, jaką dany String ma wartość, dlatego musi sprawdzić znak po znaku. Przy porównywaniu dwóch Stringów Ruby musi sprawdzić obie sekwencje znaków i porównać czy mają te same wartości oraz, czy są ułożone w tej samej kolejności. Porównanie identyfikatorów obiektów da przekłamany wynik, ponieważ dwa takie same stringi mające tę samą wartość mają różne object_id.

Porównywanie Symboli wygląda zupełnie inaczej. Symbole są niezmienne. Dwa symbole mające tę samą wartość, mają ten sam object_id. W związku z tą niezmiennością Ruby jest pewien, że Symbol nie zmienił się w trakcie trwania programu, dlatego nie ma potrzeby porównywania sekwencji znaków. Wystarczy szybkie porównanie identyfikatorów obiektów. Cała operacja dzieje się o wiele szybciej niż w przypadku stringów.

Niech przemówią liczby.

require 'benchmark'

str = Benchmark.measure do
 1_000_000.times do
		"string"
	end
end.total #=> 0.07

sym = Benchmark.measure do
	1_000_000.times do
		:symbol
	end
end.total #=> 0.39999999999999994

string_comparison = Benchmark.measure do
	1_000_000.times do
		"string" == "string"
	end
end.total #=> 0.13

symbol_comparison = Benchmark.measure do
	1_000_000.times do
		:symbol == :symbol
	end
end.total #=> 0.4999999999999999

Przechowywanie Symboli

Jest jeszcze jeden aspekt wydajnościowy wart uwagi. Wszystkie symbole są przechowywane w pamięci w jednej tablicy.

Mamy do niej dostęp za pomocą:

Symbol.all_symbols

Jakie to ma przełożenie na wydajność? Przy każdorazowym wywołaniu tego samego Stringa jest on instancjonowany, po czym zostaje zniszczony po użyciu.

Przy wywołaniu symboli wygląda to tak, że Ruby najpierw sprawdza wspomnianą globalną tablicę i jeśli znajdzie tam żądany symbol zwraca go natychmiast. Jeśli go nie znajdzie, dopiero wtedy tworzy nową instancję, umieszczaną zarazem w tablicy symboli i zwróci ją następnym razem szybciej, bo będzie już tam dostępna. Symbole są wydajne jeśli chodzi o porównywanie, przechowywanie i używanie.

Symulacja Symboli

Ruby daje możliwość zasymulowania na Stringu zachowania Symbolu przez wywołanie metody freeze. Sprawia to, że string staje się niezmienną stałą. Nie ma to jednak przełożenia na pamięć. Dwa zamrożone stringi, to ciągle dwie różne instancje. Obiekt zamrożony, podobnie jak Symbol, nie może być modyfikowany. Jest instancjonowany tylko raz dla każdego indywidualnego egzemplarza, następnie jest wywoływany do użytku.

Ciekawostka!

Symbol, fixnum, bignum, float są domyślnie zamrożone. Przy użyciu zamrożonych stringów jako kluczy w Hashu możemy uzyskać lepszą wydajność kodu niż przy symbolach.

Gdzie używać Symboli?

Nie ma jasnej definicji, kiedy należy używać Stringów, a kiedy Symboli.

image

Najpopularniejszym przykładem użycia symboli są klucze w Hash’ach.

Dozwolona notacja to:

old_hash = { :symbol_key => "some value" }

lub

cool_hash = { symbol_key: "some value" }

Drugi przykład prezentuje obecnie przyjętą nową notację.

Dlaczego lepiej używać symboli jako kluczy?

Ponieważ prezentują unikalne wartości, które nie są statyczne, podczas gdy stringi stanowią domyślne kontenery dla danych. Wobec tego Symbol lepiej wpisuje się w rolę identyfikatora. Tak jak ma to miejsce w przypadku kluczy w Hashu. Jeżeli używamy Stringa jako klucza, to za każdym razem, gdy będzie wywoływany, Ruby utworzy nową instancję. W przypadku symboli wykorzystany będzie jeden i ten sam obiekt.

Jeśli mamy zadeklarowany Hasz z symbolami jako kluczami, nie możemy się do nich dostać za pomocą stringów, na odwrót jest to samo.

W Ruby on Rails istnieje obiekt, który umożliwia dostanie się do wartości używając symboli i stringów na jednym Hashu.

HashWithIndifferentAccess

Ten obiekt to HashWithIndifferentAccess. Używanie symboli z wykorzystaniem tego obiektu jest o wiele szybsze niż używanie zwykłego Hasha.

hash = {} #=> {}
hash[:symbol] = 1 #=> 1
hash[:symbol] #=> 1
hash['symbol'] #=> nil

hash = HashWithIndifferentAccess.new #=> {}
hash[:symbol] = 1 #=> 1
hash[:symbol] #=> 1
hash['symbol'] #=> 1

Symbole, to także słowa kluczowe dla argumentów metod

def method_name(name:, age:)end

method(name: "John", age: 12)

Symboli używa się do definiowania atrybutów w klasach

attr_accessor :name, :age

Symbole oznaczają instancję klas lub metod

W momencie deklaracji klasy, metody albo zmiennej Ruby automatycznie tworzy jego nazwę w postaci Symbolu, stąd możliwe poniższe zapisy:

my_array.send(:pop)
my_array.respond_to?(:map)

Co ciekawe, jeśli mamy klasę, która nazywa się Controller, metodę controller, i zmienną controller i mimo, że w różnych kontekstach, ta nazwa będzie się odwoływała do innych wartości, to za każdym razem będzie wywoływany ten sa obiekt.

Symbole w enum w Ruby on Rails

W Ruby on Rails istnieje obiekt enum, w którym używa się symboli dla określenia dopuszczalnych wartości dla poszczególnych pól.

O czym warto pamiętać?

Spotkałem się z przypadkiem mylenia symboli ze zmiennymi, co jest karygodnym błędem. Symbol to nie jest zmienna, ale nazwa, lub inaczej, nazwany obiekt.

Koniecznie trzeba pamiętać, że Symbol to specjalna klasa w Ruby, której używa się do definiowania niezmiennych, unikalnych nazw.

Zmienne natomiast to nazwy, które odnoszą się do obiektów. W momencie, kiedy obiekt przestaje istnieć – zmienna także przestaje istnieć.

Inaczej z Symbolem. Istnieje on nawet, jeśli obiekt, który nazywa nie istnieje.

Symbol to nie zmienna – nie można przypisać mu wartości.

Kolejną ciekawą rzeczą jest to, że mimo, iż Symbol nie jest Stringiem, to ma stringową reprezentację, która jest stałą. W przypadku, gdy mamy Symbol zapisany z cudzysłowem, to jego stringowa reprezentacja również będzie miała spacje.

:"to jakiś symbol":"to jakiś symbol".to_s #=> "to jakiś symbol"

Jednak symbole ze spacjami nie są popularnie wykorzystywane i, gdy nie ma innej możliwości zapisu, są używane w ostateczności.

Kilka prostych zasad

Na koniec jeszcze kilka prostych zasad, kiedy używa się Stringów a kiedy Symboli, gdyby to jeszcze sprawiało problem. Zatem, oprócz powyższych przykładów należy pamiętać, że:

  • kiedy chcemy coś wyświetlić należy wybrać String
  • jeśli chcemy nazwę jakoś obrabiać, używać na niej modyfikujących metod, wiadomo, że używamy String
  • gdy nazwa ma byś unikalnym identyfikatorem, używamy Symbol
  • gdy ta sam nazwa ma być wielokrotnie użyta, używamy Symbol

Symbole to bardzo zacne obiekty w świecie Ruby. Ich umiejętne używanie daje same plusy, od czytelności kodu począwszy, aż na wydajności i oszczędności pamięci skończywszy.

Po więcej szczegółów odsyłam do dokumentacji klasy Symbol: https://ruby-doc.org/core-2.7.1/Symbol.html

image