Any fool can make things bigger, more complex, and more violent. It takes a touch of genius and a lot of courage to move in the opposite direction. Albert Einstein

o Ruby

Nowości i zmiany w Ruby 1.9 #7 - obsługa kodowań znaków

8 komentarzy | Kategorie: Ruby, Techblog | trackback
Tagi:
ruby 1.9 changes approved - logo

Wpis ten jest jedną z części cyklu pt "Nowości i zmiany w Ruby 1.9". Pełną listę wpisów znajdziesz pod adresem http://radarek.jogger.pl/2008/11/30/nowosci-i-zmiany-w-ruby-1-9/.

Disclaimer

Muszę przyznać, że nosząc się z zamiarem napisania tego artykułu miałem blade pojęcie o kodowaniach znaków, choć wcześniej nie zdawałem sobie z tego sprawy. Wydawało mi się, że znając takie pojęcia jak ASCII, Unicode i umiejąc z nich korzystać na co dzień, wiem sporo o kodowaniach. Trudno się jednak dziwić takiemu myśleniu, gdyż większość z Was, zapewne tak jak i ja, korzysta z 1-2 kodowań i póki działa nie musi wiedzieć dlaczego. Wertując ogromną ilość wiadomości na liście mailingowej ruby-core sporo dowiedziałem się, chociaż wciąż nie czuję się ekspertem w tej dziedzinie. Okazało się, że temat ten jest bardzo złożony i dotyczy wielu kwestii. Proszę mi zatem wybaczyć ewentualne niedociągnięcia, niezbyt dokładne opisanie niektórych z nich oraz dosyć chaotyczny styl.

Ruby 1.9 haz encodingz!

Kolejne zmiany jakie zaszły w wersji 1.9 Rubiego dotyczą obsługi kodowań znaków. Można bez przesady stwierdzić, że to najważniejsza ze zmian. Bądź co bądź mamy XXI wiek i brak natywnej obsługi kodowań jest niewybaczalny, a już na pewno nie przez środowisko "enterprise".

Jak zapewne wszyscy wiedzą, łańcuchy znaków w Ruby 1.8 to ciągi bajtów i nic więcej. Prowadzi to do oczywistych zachowań, jednak bardzo niewygodnych. Obrazuje to poniższy program:

s = "ąęć"
puts s.size
Wyjście:
6

Powyższy program, odpalony z Ruby 1.8.* wypisze na ekran liczbę "6". Także pozostałe metody klasy String pracują na bajach, zatem używając each_char iterujemy po bajtach, reverse odwracamy kolejność bajtów itd. Nie jest jednak prawdą (jak można wnioskować po wypowiedziach co poniektórych programistów), że bez natywnego wsparcia, nie można obsłużyć utf-8 czy innego kodowania znaków. Oczywiście można, co też pokazał railsowy gem activesupport.

Wracając jednak do wersji 1.9, od tej pory wszystkie bolączki typowe dla 1.8 zniknęły (oczywiście pojawiły się nowe, ale o tym za chwilę...). Spróbujmy odpalić ten sam program w 1.9:

Wyjście:
code/ruby1.8_string.rb:1: invalid multibyte char (US-ASCII)
code/ruby1.8_string.rb:1: invalid multibyte char (US-ASCII)

Ha! Założę się, że chciałeś krzyknąć "przecież to logiczne, że wypisze na ekran liczbę 3", a tu mała niespodzianka. By program zadziałał, musimy podpowiedzieć interpreterowi jakiego kodowania użyliśmy do edycji naszego pliku (inaczej założy, że użyto kodowanie ASCII). Ponieważ użyłem kodowania utf-8, to dopiszę w pierwszej linii pliku # encoding: utf-8 (gdyby w pierwszej linii miał tzw. shebang line to umieściłbym ten wpis w linii nr 2). Po dokonanej zmianie odpalam program i dostaję oczekiwaną liczbę 3.

# encoding: utf-8

s = "ąęć"
puts s.size
puts s.bytesize
Wyjście:
3
6

Dodatkowo została wypisana wartość zwrócona przez metodę String#bytesize, której znaczenia nie trzeba chyba tłumaczyć nikomu. Panie i Panowie, od dzisiaj myślimy o obiektach klasy String jako o ciągu znaków, a nie bajtów.

Jak można się domyślić, powyższa zmiana wpływa na inne metody klasy String. Np. odwołanie str[5] dotyczy szóstego znaku a nie bajtu, str[3, 2] to pobranie dwu znakowego podłańcucha począwszy od czwartego znaku i tak dalej...

Jeszcze o # encoding: utf-8

Jak już pokazałem, nagłówek ten służy by poinformować interpreter Rubiego o użytym kodowaniu. Jak na Rubiego przystało, mamy dosyć sporą dowolność w tej kwestii. Tak na prawdę Ruby szuka ciągu "coding", następnie ":" lub "=" i na końcu podany (aż do białego znaku, bądź nowej linii) ciąg uznaje za nazwę kodowania. Między każdą z części może pojawić się dowolna ilość spacji, wielkość liter nie ma znaczenia. Zatem wszystkie poniższe deklaracje zostaną poprawnie zinterpretowane:

# coding: UTF-8
# encoding: UTF-8
# -*- coding: UTF-8 -*-
# vim:set fileencoding=UTF-8:

Ma to wielką zaletę ponieważ część z nich jest rozpoznawana przez edytory, np. emacs (trzecia deklaracja), vim (czwarta deklaracja). Jeśli plik nie zawiera takiej deklaracji (lub jest niepoprawna) zostanie użyte domyślne kodowanie ASCII.

Klasa Encoding

Encoding jest nową klasą, która reprezentuje kodowanie znaków. Każdy łańcuch znaków ma przyporządkowane kodowanie, do sprawdzenia którego należy posłużyć się metodą encoding, zwracającą odpowiednią instancję ten klasy.

# encoding: ascii

s = "Hello World!"
puts s.encoding.class
puts s.encoding.name
Wyjście:
Encoding
US-ASCII
# encoding: utf-8

s = "Hello World!"
puts s.encoding.class
puts s.encoding.name
Wyjście:
Encoding
UTF-8

Powyższy przykład pokazuje, że kodowanie znaków dla łańcuchów, które pochodzą wprost z kodu źródłowego, jest takie samo jak kodowanie znaków ustawione w nagłówku pliku.

Jakie kodowania są obsługiwane?

Jeśli chciałbyś sprawdzić, które kodowania Ruby potrafi obsłużyć, to użyj do tego metod Encoding#list, Encoding#name_list, Encoding.aliases. Ta pierwsza zwraca tablicę obiektów kodowań, druga tablicę nazw wszystkich kodowań o których wie Ruby, ostatnia zwraca hash z aliasami kodowań (niektóre kodowania znane są pod różnymi nazwami).

# encoding: utf-8

p Encoding.list
p Encoding.name_list
p Encoding.aliases
Wyjście:
[#<Encoding:ASCII-8BIT>, #<Encoding:UTF-8>, #<Encoding:US-ASCII>, #<Encoding:Big5>, #<Encoding:CP949>, #<Encoding:Emacs-Mule>, #<Encoding:EUC-JP>, #<Encoding:EUC-KR>, #<Encoding:EUC-TW>, #<Encoding:GB18030>, #<Encoding:GBK>, #<Encoding:ISO-8859-1>, #<Encoding:ISO-8859-2>, #<Encoding:ISO-8859-3>, #<Encoding:ISO-8859-4>, #<Encoding:ISO-8859-5>, #<Encoding:ISO-8859-6>, #<Encoding:ISO-8859-7>, #<Encoding:ISO-8859-8>, #<Encoding:ISO-8859-9>, #<Encoding:ISO-8859-10>, #<Encoding:ISO-8859-11>, #<Encoding:ISO-8859-13>, #<Encoding:ISO-8859-14>, #<Encoding:ISO-8859-15>, #<Encoding:ISO-8859-16>, #<Encoding:KOI8-R>, #<Encoding:KOI8-U>, #<Encoding:Shift_JIS>, #<Encoding:UTF-16BE>, #<Encoding:UTF-16LE>, #<Encoding:UTF-32BE>, #<Encoding:UTF-32LE>, #<Encoding:Windows-1251>, #<Encoding:IBM437>, #<Encoding:IBM737>, #<Encoding:IBM775>, #<Encoding:CP850>, #<Encoding:IBM852>, #<Encoding:CP852>, #<Encoding:IBM855>, #<Encoding:CP855>, #<Encoding:IBM857>, #<Encoding:IBM860>, #<Encoding:IBM861>, #<Encoding:IBM862>, #<Encoding:IBM863>, #<Encoding:IBM864>, #<Encoding:IBM865>, #<Encoding:IBM866>, #<Encoding:IBM869>, #<Encoding:Windows-1258>, #<Encoding:GB1988>, #<Encoding:macCentEuro>, #<Encoding:macCroatian>, #<Encoding:macCyrillic>, #<Encoding:macGreek>, #<Encoding:macIceland>, #<Encoding:macRoman>, #<Encoding:macRomania>, #<Encoding:macThai>, #<Encoding:macTurkish>, #<Encoding:macUkraine>, #<Encoding:stateless-ISO-2022-JP>, #<Encoding:eucJP-ms>, #<Encoding:CP51932>, #<Encoding:GB2312>, #<Encoding:GB12345>, #<Encoding:ISO-2022-JP (dummy)>, #<Encoding:ISO-2022-JP-2 (dummy)>, #<Encoding:Windows-1252>, #<Encoding:Windows-1250>, #<Encoding:Windows-1256>, #<Encoding:Windows-1253>, #<Encoding:Windows-1255>, #<Encoding:Windows-1254>, #<Encoding:TIS-620>, #<Encoding:Windows-874>, #<Encoding:Windows-1257>, #<Encoding:Windows-31J>, #<Encoding:MacJapanese>, #<Encoding:UTF-7 (dummy)>, #<Encoding:UTF8-MAC>]
["ASCII-8BIT", "UTF-8", "US-ASCII", "Big5", "CP949", "Emacs-Mule", "EUC-JP", "EUC-KR", "EUC-TW", "GB18030", "GBK", "ISO-8859-1", "ISO-8859-2", "ISO-8859-3", "ISO-8859-4", "ISO-8859-5", "ISO-8859-6", "ISO-8859-7", "ISO-8859-8", "ISO-8859-9", "ISO-8859-10", "ISO-8859-11", "ISO-8859-13", "ISO-8859-14", "ISO-8859-15", "ISO-8859-16", "KOI8-R", "KOI8-U", "Shift_JIS", "UTF-16BE", "UTF-16LE", "UTF-32BE", "UTF-32LE", "Windows-1251", "BINARY", "IBM437", "CP437", "IBM737", "CP737", "IBM775", "CP775", "CP850", "IBM850", "IBM852", "CP852", "IBM855", "CP855", "IBM857", "CP857", "IBM860", "CP860", "IBM861", "CP861", "IBM862", "CP862", "IBM863", "CP863", "IBM864", "CP864", "IBM865", "CP865", "IBM866", "CP866", "IBM869", "CP869", "Windows-1258", "CP1258", "GB1988", "macCentEuro", "macCroatian", "macCyrillic", "macGreek", "macIceland", "macRoman", "macRomania", "macThai", "macTurkish", "macUkraine", "CP950", "stateless-ISO-2022-JP", "eucJP", "eucJP-ms", "euc-jp-ms", "CP51932", "eucKR", "eucTW", "GB2312", "EUC-CN", "eucCN", "GB12345", "CP936", "ISO-2022-JP", "ISO2022-JP", "ISO-2022-JP-2", "ISO2022-JP2", "ISO8859-1", "Windows-1252", "CP1252", "ISO8859-2", "Windows-1250", "CP1250", "ISO8859-3", "ISO8859-4", "ISO8859-5", "ISO8859-6", "Windows-1256", "CP1256", "ISO8859-7", "Windows-1253", "CP1253", "ISO8859-8", "Windows-1255", "CP1255", "ISO8859-9", "Windows-1254", "CP1254", "ISO8859-10", "ISO8859-11", "TIS-620", "Windows-874", "CP874", "ISO8859-13", "Windows-1257", "CP1257", "ISO8859-14", "ISO8859-15", "ISO8859-16", "CP878", "SJIS", "Windows-31J", "CP932", "csWindows31J", "MacJapanese", "MacJapan", "ASCII", "ANSI_X3.4-1968", "646", "UTF-7", "CP65000", "CP65001", "UTF8-MAC", "UTF-8-MAC", "UCS-2BE", "UCS-4BE", "UCS-4LE", "CP1251", "locale", "external", "internal"]
{"BINARY"=>"ASCII-8BIT", "CP437"=>"IBM437", "CP737"=>"IBM737", "CP775"=>"IBM775", "IBM850"=>"CP850", "CP857"=>"IBM857", "CP860"=>"IBM860", "CP861"=>"IBM861", "CP862"=>"IBM862", "CP863"=>"IBM863", "CP864"=>"IBM864", "CP865"=>"IBM865", "CP866"=>"IBM866", "CP869"=>"IBM869", "CP1258"=>"Windows-1258", "CP950"=>"Big5", "eucJP"=>"EUC-JP", "euc-jp-ms"=>"eucJP-ms", "eucKR"=>"EUC-KR", "eucTW"=>"EUC-TW", "EUC-CN"=>"GB2312", "eucCN"=>"GB2312", "CP936"=>"GBK", "ISO2022-JP"=>"ISO-2022-JP", "ISO2022-JP2"=>"ISO-2022-JP-2", "ISO8859-1"=>"ISO-8859-1", "CP1252"=>"Windows-1252", "ISO8859-2"=>"ISO-8859-2", "CP1250"=>"Windows-1250", "ISO8859-3"=>"ISO-8859-3", "ISO8859-4"=>"ISO-8859-4", "ISO8859-5"=>"ISO-8859-5", "ISO8859-6"=>"ISO-8859-6", "CP1256"=>"Windows-1256", "ISO8859-7"=>"ISO-8859-7", "CP1253"=>"Windows-1253", "ISO8859-8"=>"ISO-8859-8", "CP1255"=>"Windows-1255", "ISO8859-9"=>"ISO-8859-9", "CP1254"=>"Windows-1254", "ISO8859-10"=>"ISO-8859-10", "ISO8859-11"=>"ISO-8859-11", "CP874"=>"Windows-874", "ISO8859-13"=>"ISO-8859-13", "CP1257"=>"Windows-1257", "ISO8859-14"=>"ISO-8859-14", "ISO8859-15"=>"ISO-8859-15", "ISO8859-16"=>"ISO-8859-16", "CP878"=>"KOI8-R", "SJIS"=>"Shift_JIS", "CP932"=>"Windows-31J", "csWindows31J"=>"Windows-31J", "MacJapan"=>"MacJapanese", "ASCII"=>"US-ASCII", "ANSI_X3.4-1968"=>"US-ASCII", "646"=>"US-ASCII", "CP65000"=>"UTF-7", "CP65001"=>"UTF-8", "UTF-8-MAC"=>"UTF8-MAC", "UCS-2BE"=>"UTF-16BE", "UCS-4BE"=>"UTF-32BE", "UCS-4LE"=>"UTF-32LE", "CP1251"=>"Windows-1251", "locale"=>"UTF-8", "external"=>"UTF-8"}

Do każdego kodowania możemy dostać się także poprzez odpowiednią stałą Encoding::<NAZWA_KODOWANIA>, np: Encoding::UTF_8, Encoding. Jeśli chcemy pobrać obiekt kodowania na podstawie łańcucha znaków (bo np. pobraliśmy go z pliku) to możemy użyć metody Encoding.find.

# encoding: ascii

s = "hello"
puts s.encoding == Encoding::ASCII
puts s.encoding == Encoding::US_ASCII

%w(utf-8 ascii foo).each do |encoding_name|
  begin
    encoding = Encoding.find(encoding_name)
    puts encoding.name
  rescue ArgumentError => e
    puts e.message
  end
end

Wyjście:
true
true
UTF-8
US-ASCII
unknown encoding name - foo

Tak jak wspomniałem, każde kodowanie może mieć jakieś aliasy. Prócz wspomnianej metody Encoding.aliases zwracającej hash, można je pobrać także metodą Encoding#names dla konkretnego obiektu kodowania.

# encoding: utf-8

puts Encoding::UTF_8.names
Wyjście:
UTF-8
CP65001
locale
external

Nowa pseudo-stała __ENCODING__

Dodano nową pseudo-stałą o nazwie __ENCODING__, która zwraca obiekt kodowania jakie zostało użyte w pliku.

# encoding: utf-8

puts "Ten plik został zakodowany przy użyciu kodowania: #{__ENCODING__.name}"
Wyjście:
Ten plik został zakodowany przy użyciu kodowania: UTF-8

String raz jeszcze

Póki co pokazałem, że każdy łańcuch ma skojarzony z nim obiekt kodowania. Obiekt ten jest tylko etykietą łańcucha i nie wpływa bezpośrednio na jego wewnętrzną zawartość (czyli ciąg bajtów). Zwracam na to uwagę, ponieważ Ruby nie używa wewnętrznie żadnego konkretnego kodowania (dokładniej opiszę to kilka akapitów dalej). Ma to swoje wady i zalety. Wadą jest to, że trzeba mieć większą świadomość pisząc programy, które operują na stringach. Trzeba zdawać sobie sprawę, że w jednej chwili mogą istnieć stringi o różnych kodowaniach (różne środowiska, ustawienia lokalne, kodowania plików na dysku, kodowania plików źródłowych itp.). Z kolei zaletą jest ogromna elastyczność, która w efekcie może dać większe możliwości w tej kwestii niż zastosowane rozwiązania w Javie czy Pythonie.

String#force_encoding, String#bytes, String#valid_encoding?

Jak już wspomniałem, klasa String od wersji 1.9 reprezentuje ciąg znaków. Jednak wewnętrznie jest to wciąż ciąg bajtów, ustawione kodowanie odpowiada za sposób interpretacji tych bajtów. Ruby w żaden sposób nie modyfikuje (chyba że go o to poprosisz) wartości tych bajtów. Do wymuszenia zmiany tej interpretacji służy metoda String#force_encoding, która ustawia nowe kodowanie, ale mimo wszystko nie dokonuje żadnej konwersji bajtów (jeśli interesuje Cię zawartość na poziomie bajtów to użyj metody String#bytes, która zwraca odpowiedni iterator). Jest to przydatne na przykład w sytuacji gdy musimy wczytać jakieś dane, ale nie znamy kodowania, które zostało użyte. Jeśli po odczytaniu i sprawdzeniu kilku bajtów możemy określić kodowanie, to możemy o tym poinformować właśnie poprzez tą metodę. Praktycznym przykładem może być odczyt pliku html z dysku, w którym informacja o kodowaniu jest zawarta w nagłówku (<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />). Jeśli chcemy się upewnić, że wewnętrzna sekwencja bajtów jest poprawna z ustawionym kodowaniem, to możemy użyć do tego metody String#valid_encoding?. Pora na mały przykład z użyciem wymienionych metod.

# encoding: utf-8

# String#force_encoding
# String#valid_encoding?
str_utf   = "foo" # kodowanie odziedziczone po źródle
str_ascii = str_utf.dup.force_encoding("ascii")
puts "#{str_utf}.valid_encoding? == %s" % str_utf.valid_encoding?
puts "#{str_ascii}.valid_encoding? == %s" % str_ascii.valid_encoding? # true, występowały same znaki ascii

str_utf = "Föö"
str_ascii = str_utf.dup.force_encoding("ascii")
puts "#{str_ascii}.valid_encoding? == %s" % str_ascii.valid_encoding? # false, znaki spoza zakresu ascii

str_utf = "Föö"
str_ascii = str_utf.dup.force_encoding("binary") # to samo co ascii-8bit
puts "#{str_ascii}.valid_encoding? == %s" % str_ascii.valid_encoding? # true, dowolny ciąg bajtów jest dozwolony

# String#bytes
str = "Föö"
puts "str(%s): #{str}" % str.encoding
puts " bajty(%d): %p" % [str.bytesize, str.bytes.to_a]
puts " znaki(%d): %p" % [str.size, str.chars.to_a]

str_ascii = str.force_encoding("ascii")
puts "str(%s): #{str}" % str.encoding
puts " bajty(%d): %p" % [str.bytesize, str.bytes.to_a]
puts " znaki(%d): %p" % [str.size, str.chars.to_a]
Wyjście:
foo.valid_encoding? == true
foo.valid_encoding? == true
Föö.valid_encoding? == false
Föö.valid_encoding? == true
str(UTF-8): Föö
 bajty(5): [70, 195, 182, 195, 182]
 znaki(3): ["F", "ö", "ö"]
str(US-ASCII): Föö
 bajty(5): [70, 195, 182, 195, 182]
 znaki(5): ["F", "\xC3", "\xB6", "\xC3", "\xB6"]

String#encode

Inną bardzo przydatną metodą jest String#encode, która służy do przekonwertowania stringa w jednym kodowaniu do stringa w drugim. Jest to jedno z tych (raczej oczywistych) miejsc, w którym konwersja następuje na poziomie bajtów (inaczej ciężko mówić o konwersji). Oto przykład, w którym wykorzystamy tą metodę do konwersji znaków z utf-8 do iso-8859-2 i cp1250.

# encoding: utf-8

s1 = "ąęć"
s2 = s1.encode("iso-8859-2")
s3 = s1.encode("cp1250")

puts "#{s1}"
puts " (%s)bajty: %p" % [s1.encoding, s1.bytes.to_a]
puts " (%s)bajty: %p" % [s2.encoding, s2.bytes.to_a]
puts " (%s)bajty: %p" % [s3.encoding, s3.bytes.to_a]
Wyjście:
ąęć
 (UTF-8)bajty: [196, 133, 196, 153, 196, 135]
 (ISO-8859-2)bajty: [177, 234, 230]
 (Windows-1250)bajty: [185, 234, 230]

Celowo wypisałem tylko bajty ponieważ co najwyżej jeden z wypisywanych łańcuchów wyświetliłby się poprawnie.

Warto zwrócić na uwagę, że w przypadku gdy odpowiednia konwersja nie może zostać wykonana, jest rzucany wyjątek Encoding::UndefinedConversionError. Przykładowo

# encoding: utf-8

nuclear = "☢"
nuclear.encode("ascii") # powoduje Encoding::UndefinedConversionError
                        # ponieważ znak nie może zostać skonwertowany
                        # (brak odpowiednika)
Wyjście:
code/encode02.rb:4:in `encode': "\xE2\x98\xA2" from UTF-8 to US-ASCII (Encoding::UndefinedConversionError)
	from code/encode02.rb:4:in `<main>'

String#ascii_only?

Metoda, jak łatwo się domyślić, sprawdza czy dany łańcuch zawiera tylko znaki ASCII (zakres bajtów \x00-\x79, czyli 0-127).

Literały

Jak już widziałeś wcześniej, literały stringów dziedziczą kodowanie znaków po kodowaniu ustawionym w nagłówku pliku, w którym się znajdują. Jest to połowiczna prawda w przypadku literałów wyrażeń regularnych (Regexp) oraz symboli (Symbol), w wypadku których zostanie użyte kodowanie ASCII jeśli wszystkie znaki są właśnie z tego zakresu. Głównie chodzi o ułatwienie pracy z tymi typami (w przypadku symboli mogłoby dojść do sytuacji, w której dany symbol miałbym kodowanie zależne od tego jakie kodowanie miałby plik, w którym wystąpił wystąpił po raz pierwszy).

# encoding: utf-8

def show_encoding(str)
  puts "'#{str}' is #{str.encoding.name}"
end

show_encoding "cat"
show_encoding "∂og"

show_encoding :cat
show_encoding :∂og

show_encoding /cat/
show_encoding /∂og/
Wyjście:
'cat' is UTF-8
'∂og' is UTF-8
'cat' is US-ASCII
'∂og' is UTF-8
'(?-mix:cat)' is US-ASCII
'(?-mix:∂og)' is UTF-8

Z kolei jeśli plik zawiera kodowanie nie-unicodowe, ale dany łańcuch zawiera sekwencję "\uDDDD" to jego kodowanie ustawiane jest na UTF-8.

# encoding: ascii

def show_str(s)
  puts "#{s}, encoding: #{s.encoding}"
end

s1 = "foo"
s2 = "\u2622"

show_str(s1)
show_str(s2)
Wyjście:
foo, encoding: US-ASCII
☢, encoding: UTF-8

Wydajność

Kolejnym aspektem, na który trzeba zwrócić uwagę, jest wydajność. Nie jestem w stanie przetestować wszystkich operacji związanych z łańcuchami znaków, a na których wydajność ma wpływ kodowanie znaków. Mogę natomiast podpowiedzieć, że należy zwracać uwagę na operacje, które wymagają dostępu do znaków o konkretnym indeksie. Są to m.in. takie metody jak String#[], String#index, String#insert, String#slice. Dla kodowań o stałej szerokości znaków (np. UTF-32) oraz łańcuchów znaków, które zawierają tylko 7 bitowe znaki ASCII (czyli takie, dla których String#ascii_only? zwraca true) złożoność dostępu wynosi O(1), dla pozostałych wynosi O(n). Jeśli nie jest się pewnym czy dostęp jest O(1) czy O(n) wystarczy sprawdzić to prostym benchmarkiem.

# encoding: utf-8
require "benchmark"

SIZE = 100_000
INDEX = SIZE / 2
N = 1000

s1 = "foo" * SIZE
s2 = "fóó" * SIZE

Benchmark.bm(10) do |make|
  make.report(s1.encoding.to_s) do
    N.times do
      char = s1[INDEX]
    end
  end

  make.report(s2.encoding.to_s) do
    N.times do
      char = s2[INDEX]
    end
  end
end
Wyjście:
                user     system      total        real
UTF-8       0.000000   0.000000   0.000000 (  0.000939)
UTF-8       0.340000   0.000000   0.340000 (  0.342168)

Dla łańcucha "foo" zawierającego tylko znaki ASCII, pomimo iż był zakodowany przy użyciu kodowania UTF-8 (a które ma zmienną długość znaków), dostęp po indeksie okazał się bardzo szybki. Z kolei dla łańcucha "fóó" dostęp nie mógł być zoptymalizowany i trwał o wiele dłużej, ponieważ łańcuch zawierał znaki spoza zbioru ASCII.

Moje słowa o optymalizacji dostępu dla kodowań o stałej szerokości znaków może potwierdzić następujący program:

# encoding: utf-8

require "benchmark"

SIZE = 100_000
INDEX = SIZE / 2
N = 1000

s1 = "foo".encode("utf-32be") * SIZE
s2 = "fóó".encode("utf-32be") * SIZE

Benchmark.bm(10) do |make|
  make.report(s1.encoding.to_s) do
    N.times do
      char = s1[INDEX]
    end
  end

  make.report(s2.encoding.to_s) do
    N.times do
      char = s2[INDEX]
    end
  end
end
Wyjście:
                user     system      total        real
UTF-32BE    0.000000   0.000000   0.000000 (  0.000301)
UTF-32BE    0.000000   0.000000   0.000000 (  0.000301)

Warto zwrócić także uwagę, że jeśli iterujemy po wszystkich znakach w łańcuchu to lepiej jest skorzystać z iteratora String#each_char, zamiast samemu zwiększać licznik i odwoływać się przez String#[]:

# encoding: utf-8

require "benchmark"

SIZE = 5_000
INDEX = SIZE / 2
N = 1000

s = "fóó" * SIZE

Benchmark.bm(20) do |make|
  make.report("String#each_char") do
    s.each_char do |c|
      a = c
    end
  end

  make.report("String#[i]") do
    s.size.times do |i|
      a = s[i]
    end
  end
end
Wyjście:
                          user     system      total        real
String#each_char      0.000000   0.010000   0.010000 (  0.004285)
String#[i]            0.340000   0.000000   0.340000 (  0.336463)

Operacje, które nie wymagają dostępu do znaków o konkretnych indeksach (np. konkatenacja łańcuchów) powinny być tak samo wydajne jak do tej pory. Chociaż, tak jak już wspomniałem, dobrze jest upewnić się robiąc odpowiednie testy wydajnościowe.

Encoding.default_external i Encoding.default_internal

Wspomniałem na początku, że Ruby w przeciwieństwie do Pythona, Javy czy innych języków nie stosuje żadnej reprezentacji wewnętrznej łańcuchów. Wynika to z faktu nieistnienia jednego, uniwersalnego kodowania znaków, które pozwalałoby na reprezentację wszystkich znaków na świecie. Nie jest nim nawet Unicode, chociaż takie są jego założenia. Z jednej strony daje to większą elastyczność, z drugiej wymaga więcej pracy od programisty. Jeśli pracujemy tylko z jednym kodowaniem to problemu nie ma. Wczytując np. dane z pliku określamy, że jego kodowanie to utf-8 i takie właśnie kodowanie otrzymują wczytywane z niego dane (chociaż już tu możemy zapytać: dlaczego muszę za każdym razem określać to kodowanie?).

Gorzej jednak, jeśli zmuszeni jesteśmy pracować na danych o różnych kodowaniach. Wyobraź sobie taką sytuację. Piszesz program, który wczytuje dane z plików o kodowaniach iso-8859-2, cp1250 i utf-8. Dane te następnie zapisywane są w bazie w kodowaniu utf-8. Sprawa wydaje się w miarę prosta. Wczytując dane z plików o 2 pierwszych kodowaniach konwertujesz dane do utf-8 i takie zapisujesz do bazy. Jeśli w przyszłości będziesz chciał zmienić kodowanie bazy danych na inne to będziesz musiał także zmienić kodowanie do którego konwertujesz dane z wczytywanych plików. Mógłbyś to oczywiście załatwić jakąś stałą, ale mimo wszystko może to wprowadzać niepotrzebne zamieszanie.

By ułatwić nam pracę, zostały wprowadzone dwa pojęcia, tj. kodowanie wewnętrzne (internal encoding) i kodowanie zewnętrzne (external encoding). To pierwsze określa domyślne kodowanie do którego są konwertowane wszystkie wczytywane z zewnątrz dane w procesie programu. To drugie określa domyślne kodowanie używane podczas wczytywanie zewnętrznych danych. Dla przykładu jeśli ustawimy kodowanie zewnętrzne na iso-8859-2, zaś kodowanie wewnętrzne na utf-8 to przy operacji File.open(filename).read Ruby domniema, że kodowaniem tego pliku jest utf-8, ale wczytane dane przekonwertuje do iso-8859-2.

Do ustawiania i pobierania tych kodowań służą metody Encoding.default_external, Encoding.default_external=, Encoding.default_internal, Encoding.default_internal=. Domyślnie kodowanie wewnętrzne nie jest ustawione, zatem dane nie są konwertowane w żaden sposób. Z kolei domyślne kodowanie zewnętrzne jest ustawiane na takie jakie mamy ustawione w ustawieniach lokalnych naszego systemu. Pora na mały przykład.

# encoding: utf-8

def show(encoding)
  puts "Zmieniam Encoding.default_internal na #{encoding}"
  Encoding.default_internal = encoding

  data = File.read("/etc/passwd")
  puts "data.encoding: #{data.encoding}"
end

puts "Domyślnym kodowaniem zewnętrznym jest: #{Encoding.default_external}"
puts "Domyślnym kodowaniem wewnętrznym jest: #{Encoding.default_internal}"

show(nil)
show("utf-8")
show("iso-8859-2")
Wyjście:
Domyślnym kodowaniem zewnętrznym jest: UTF-8
Domyślnym kodowaniem wewnętrznym jest: 
Zmieniam Encoding.default_internal na 
data.encoding: UTF-8
Zmieniam Encoding.default_internal na utf-8
data.encoding: UTF-8
Zmieniam Encoding.default_internal na iso-8859-2
data.encoding: ISO-8859-2

Jeśli chcemy dla konkretnego pliku określić inne niż domyślne kodowanie wewnętrzne i zewnętrzne to powinniśmy podać odpowiednie parametry do metody File.open. Nie powinniśmy natomiast zmieniać raz ustawionych (np. przy starcie programu) wartości Encoding.default_internal i Encoding.default_external.

# encoding: utf-8

Encoding.default_internal = "utf-8"
Encoding.default_external = "utf-8"
file = File.open("/etc/passwd")
puts "file.external_encoding: #{file.external_encoding}"
puts "file.internal_encoding: #{file.internal_encoding}"
puts "file.read.encoding: #{file.read.encoding}"

file = File.open("/etc/passwd", external_encoding: "ascii", internal_encoding: "iso-8859-2")
puts "file.external_encoding: #{file.external_encoding}"
puts "file.internal_encoding: #{file.internal_encoding}"
puts "file.read.encoding: #{file.read.encoding}"
Wyjście:
file.external_encoding: UTF-8
file.internal_encoding: 
file.read.encoding: UTF-8
file.external_encoding: US-ASCII
file.internal_encoding: ISO-8859-2
file.read.encoding: ISO-8859-2

Uff, to z pewnością jeden z najdłuższych wpisów jakie kiedykolwiek umiesciłem na tym blogu. Z pewnością nie opisałem każdego z możliwych aspektów dotyczących kodowań. Istnieje wiele różnych pułapek, na które trzeba uważać. Liczę na to, że po powyższej lekturze, na spokojnie usiądziecie sam na sam z interpreterem Rubiego w wersji 1.9.1 i spróbujecie pobawić się tym co Wam pokazałem. Gdybyście mięli jakieś inne wątpliwości, ciekawe pytania to piszcie w komentarzach.

Linki

Jeśli spodobał Ci się wpis to może umieścisz ten blog w swoim czytniku RSS?

Komentarze

1. avatar icon Piotr Sarnacki napisał(a) 12 Lut 2009 o godz. 09:06:

Świetny post, ująłeś chyba wszystko co może się przydać przy kodzeniu, jeżeli o kodowania chodzi.

Jeżeli chodzi o wydajność, to pamiętam, że ktoś na ruby-forum narzekał na wydajność pracowania na pojedynczych znakach (jako, że teraz nie są to już bajty). Zastanawiam się czy od tamtego czasu ta część była w jakiś sposób optymalizowana.

2. avatar icon Psi napisał(a) 12 Lut 2009 o godz. 10:50:

Kawał dobrej roboty :) Dodaję do delicious, na pewno się kiedyś przyda.

3. avatar icon dr_bonzo napisał(a) 12 Lut 2009 o godz. 19:04:

Niezle wyjasnione (oceniam jako poznajacy 1.9), prosto i zrozumiale.

4. avatar icon Dodek napisał(a) 12 Lut 2009 o godz. 20:16:

Dobrze, że się wzorowali na Pythonie – tam jest to bardzo elegancko zrobione.

5. avatar icon Radarek napisał(a) 12 Lut 2009 o godz. 22:22:

Jeżeli chodzi o wydajność, to pamiętam, że ktoś na ruby-forum narzekał na wydajność pracowania na pojedynczych znakach (jako, że teraz nie są to już bajty). Zastanawiam się czy od tamtego czasu ta część była w jakiś sposób optymalizowana.

@Piotr, myślę, że sprawa jest dokładnie taka sama jak była. Tj. dla kodowań o zmiennej długości znaków dostęp tzw. random access (string[i]) jest i będzie O(N). Jeśli faktycznie potrzeba sporo takich wywołań (i nie chodzi o iterację po kolejnych znakach) to można przekonwertować do jakiegoś kodowania o stałej szerokości znaków

@Dodek, nie widzę osobiście, aby obsługa kodowań była podoba do tych z Pythona. Jedyne co zostało zapożyczone (wspomniałem o tym w tekście) to deklaracja kodowania dla plików źródłowych. I w sumie dobrze, bo jest to znane i dobre rozwiązanie. Cała reszta jest zupełnie inna (m.in. dlatego, że python robi automatyczny transcoding do unicode).

6. avatar icon Void napisał(a) 22 Wrz 2009 o godz. 11:07:

Bardzo dobry post. Moje 1. boje z Rubym stały się bardziej zrozumiałe :-)

7. avatar icon morgoth napisał(a) 11 Paź 2009 o godz. 13:58:

Twój post znacznie uprościł mi pisanie części pracy magisterskiej o różnicach pomiędzy wersjami Rubiego i kodowaniu.
Dzięki!
BTW, dodałem Cię do bibliografii :-).
Pozdrawiam.

8. avatar icon Betclic napisał(a) 04 Gru 2009 o godz. 12:40:

Taki standard to nic innego jak zasady, których należy się trzymać by kod był czytelny i zrozumiały dla wszystkich.

Dodaj coś od siebie

Możesz korzystać ze składni Textile.

Pola oznaczone * są wymagane.

Proszę o dodawanie komentarzy związanych z tematem postu, sprawy osobiste proszę załatwiać przez maila bądź gg.

Zastrzegam sobie prawo do moderacji komentarzy (edycja, usuwanie).