Uploaded avatar of PercyGrunwald

تطبیق یونیکد در Elixir

@PercyGrunwald
بیش از 7 سال پیش

تمرین‌های Exercism کوچک، ساختگی و اغلب به‌ظاهر پیش‌پاافتاده‌اند. به‌راحتی می‌توان تصور کرد که برنامه‌نویسان باتجربه چیزی برای یاد گرفتن از آن‌ها نداشته باشند. اما حل این مسائل مصنوعی می‌تواند شما را وادار کند بخش‌هایی از زبان برنامه‌نویسی‌تان را یاد بگیرید و به کار ببرید که شاید تاکنون کاوش نکرده‌اید. این یادگیری تازه می‌تواند شما را به حل کارآمدتر یا گویاتر مسائل دنیای واقعی برساند.

فراوانی حروف موازی تمرینی با دشواری متوسط در ترک Elixir در Exercism است که شمار شگفت‌آوری از درس‌های جالب را پیش روی شما می‌گذارد. چالش اصلی در حل این تمرین، پرداختن به حروف چند زبان مختلف است، چون یکی از موارد آزمون به زبان آلمانی است و نویسه‌هایی دارد که بیرون از الفبای انگلیسی‌اند. اگر بیشتر وقتتان را صرف توسعه‌ی برنامه‌هایی برای انگلیسی‌زبانان می‌کنید، این ممکن است نخستین باری باشد که با چنین نیازمندی‌ای روبه‌رو می‌شوید. آنچه از این تمرین یاد می‌گیرید برای هر کسی که برنامه‌ای چندزبانه یا غیرانگلیسی می‌نویسد فایده‌ی روشنی دارد، اما می‌تواند در بسیاری از زمینه‌های دیگر هم کمک کند، مثلاً اعتبارسنجی‌های مطمئن‌تر برای نام کاربری و گذرواژه.

برای حل موفق این تمرین، باید تابعی به اسم Frequency.frequency/2 پیاده‌سازی کنید که فراوانی حروف را در فهرستی از رشته‌ها تعیین می‌کند که می‌توانند به هر زبانی باشند:

iex> Frequency.frequency(["Freude", "schöner", "Götterfunken"], workers)
%{
  "c" => 1, 
  "d" => 1, 
  "e" => 5, 
  ...
  "ö" => 2
}

بیایید با مسئله‌ی بنیادی که این تابع باید حل کند شروع کنیم و قدم‌به‌قدم به یک پیاده‌سازی کامل برسیم.

تشخیص اینکه یک نویسه در Elixir حرف است یا نه

چطور با Elixir تشخیص می‌دهید که "a" یک حرف است یا نه؟

فکر می‌کنم بیشتر افراد از یک «عبارت باقاعده» مانند /[a-z]/ استفاده می‌کنند:

iex> String.match?("a", ~r/^[a-z]$/)
true

اما "A" چطور؟

افزودن اصلاح‌کننده‌ی i (بدون حساسیت به بزرگی و کوچکی حروف) احتمالاً ساده‌ترین راه است:

iex> String.match?("A", ~r/^[a-z]$/i)
true

خب، حالا "ö" چطور؟

وقتی نخستین بار به این مسئله پرداختم، مطمئن نبودم بهترین راه چیست؛ /[a-z]/i قطعاً جواب نمی‌دهد:

iex> String.match?("ö", ~r/^[a-z]$/i)
false

تشخیص اینکه "ö" یک حرف است یا نه، بخش اصلی حل این مسئله‌ی Exercism است، چون یکی از متن‌های آزمون به زبان آلمانی است:

# parallel_letter_frequency_test.exs
...
# Poem by Friedrich Schiller. The corresponding music is the European Anthem.
@ode_an_die_freude """
Freude schöner Götterfunken
...
"""

شاید بتوانید از یک عبارت باقاعده استفاده کنید تا بررسی کنید یک نویسه نویسه‌ی ویژه نیست، اما احتمالاً طولانی، نازیبا و شکننده از آب درمی‌آید. چقدر می‌توانید مطمئن باشید که همه‌ی نویسه‌های ویژه‌ی ممکن را که ممکن است به عنوان ورودی به تابع شما داده شوند پوشش داده‌اید؟ به نظرم راه بهتری وجود دارد.

عبارات باقاعده‌ی یونیکد در Elixir

راه بهتر برای این مسئله استفاده از اصلاح‌کننده‌ی u در ماژول Regex در Elixir است:

unicode (u): الگوهای ویژه‌ی یونیکد مانند \p را فعال می‌کند و اصلاح‌کننده‌هایی مثل \w، \W، \s و هم‌خانواده‌هایشان را طوری تغییر می‌دهد که با یونیکد هم مطابقت داشته باشند.

مشخص می‌شود که اصلاح‌کننده‌ی u، و به‌طور خاص الگوی \p، راه‌حلی واقعاً ظریف است. الگوی \p به شما امکان می‌دهد یک «گرافیم» (نام دیگری برای یک نویسه‌ی یونیکد) را در هر یک از دسته‌های نویسه‌ی یونیکد مطابقت دهید. این نه‌تنها دسته‌های مشخصی مانند Ll (حرف، کوچک) و Sc (نماد، ارز) را در بر می‌گیرد، بلکه دسته‌های مادر مانند L (حرف) و S (نماد) را هم شامل می‌شود.

می‌توانید با الگوی \p{L} با هر حرفی در هر حالتی در هر زبان انسانی که یونیکد پوشش می‌دهد مطابقت دهید. این امکان مطابقت‌های نسبتاً قدرتمندی را فراهم می‌کند.

نویسه‌های پایه‌ی لاتین در انگلیسی مثل همیشه کار می‌کنند:

iex> String.match?("a", ~r/^\p{L}$/u)
true
iex> String.match?("A", ~r/^\p{L}$/u)
true

گونه‌های لاتین با اوملاوت و اکسان اگیو هم مشکلی ندارند:

iex> String.match?("ö", ~r/^\p{L}$/u)
true
iex> String.match?("Á", ~r/^\p{L}$/u)
true

بیایید مطمئن شویم که این الگو با هر نویسه‌ای مطابقت نمی‌کند. چند نویسه که شبیه حرف‌اند اما حرف نیستند چطور:

iex> String.match?("$", ~r/^\p{L}$/u)
false
iex> String.match?("@", ~r/^\p{L}$/u)
false

خیلی خوب، اما یادتان هست گفتم هر زبانی؟ هیچ مشکلی نیست:

# Chinese character for "you"
iex> String.match?("你", ~r/^\p{L}$/u)
true

# Cyrillic capital letter "zhe"
iex> String.match?("Ж", ~r/^\p{L}$/u)
true

به‌کارگیری تطبیق یونیکد برای مسئله‌ی پیش رو

حالا که ابزاری داریم که به ما کمک می‌کند تشخیص دهیم یک گرافیم حرف است یا نه، می‌توانیم از آن برای حل مسئله استفاده کنیم. یک پیاده‌سازی اولیه از تابع Frequency.frequency/2 می‌تواند چنین باشد:

def frequency(texts, _workers) do
  texts
  |> get_all_graphemes()
  |> count_letters()
end

defp get_all_graphemes(texts) do
  texts
  |> Enum.join()
  |> String.graphemes()
end

تنها کاری که count_letters/1 باید انجام دهد این است که الگوی String.match?(grapheme, ~r/^\p{L}$/u) را که در بالا شناسایی کردیم به کار ببرد تا شمارش هر حرف را در فهرست graphemes یک واحد افزایش دهد. این هم نمونه‌ای از پیاده‌سازی، برگرفته از راه‌حل من برای این مسئله‌ی Exercism:

defp count_letters(graphemes) do
  Enum.reduce(graphemes, %{}, fn grapheme, acc ->
    if String.match?(grapheme, ~r/^\p{L}$/u) do
      downcased_letter = String.downcase(grapheme)
      Map.update(acc, downcased_letter, 1, fn count -> count + 1 end)
    else
      acc
    end
  end)
end

این تابع فهرستی از گرافیم‌ها را می‌پذیرد، مثلاً ["a", "A", "ö", "$"]، و نقشه‌ای برمی‌گرداند که فقط حروف را می‌شمارد و بزرگی و کوچکی را نادیده می‌گیرد، یعنی %{"a" => 2, "ö" => 1}. با توجه به اینکه این تابع می‌تواند ورودی هر زبانی را پردازش کند، به نظر من ۹ خط کد نسبتاً قدرتمندی است.

نتیجه‌گیری

مشخص می‌شود وقتی با تطبیق یونیکد آشنا باشید، مطابقت دادن حروف غیرانگلیسی بسیار ساده می‌شود، و خوشبختانه این یک ویژگی اصلی در ماژول Regex در Elixir است. پیش از حل این مسئله‌ی Exercism تقریباً هیچ چیزی درباره‌ی این ویژگی نمی‌دانستم، اما حالا آن را بخشی جدانشدنی از جعبه‌ابزار Elixir خودم می‌دانم.

می‌توانید از این ابزار تازه به روش‌های زیادی استفاده کنید؛ چند موردی که به ذهنم می‌رسد اعتبارسنجی مطمئن‌تر گذرواژه‌ها و نام‌های کاربری است، یا حتی تعیین اینکه یک رشته‌ی ورودی یک رشته‌ی ارزی معتبر است، بدون نیاز به فهرست کردن دستی همه‌ی نمادهای ارزی ممکن:

iex> currency_string_regex = ~r/\p{Sc}\d+\.\d{2}/u
~r/\p{Sc}\d+\.\d{2}/u

iex> ["$1.00", "£1.00", "¥1.00", "€1.00", "&1.00"] \
...> |> Enum.filter(&String.match?(&1, currency_string_regex))
["$1.00", "£1.00", "¥1.00", "€1.00"]
3 مارس 2019 · برایتان مفید بود؟