التمارين على 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
...
"""
ربما يمكنك استخدام تعبير نمطي للتحقق مما إذا كان المحرف ليس محرفًا خاصًا، لكنه على الأرجح سيكون طويلًا وغير أنيق وهشًّا. ما مدى ثقتك بأنك غطّيت كل محرف خاص محتمل قد يُمرَّر كمدخل إلى دالتك؟ أعتقد أن هناك أسلوبًا أفضل.
تعبيرات Unicode النمطية في Elixir
أسلوب أفضل لحلّ هذه المشكلة هو استخدام مُعدِّل u في وحدة Regex في Elixir:
unicode (
u) - يفعّل أنماطًا خاصة بـ Unicode مثل\p، ويجعل معدِّلات مثل\wو\Wو\sوما شابهها تطابق Unicode أيضًا.
اتضح أن مُعدِّل u، وتحديدًا النمط \p، يمثّل حلًّا أنيقًا حقًّا. يتيح لك النمط \p مطابقة غرافيم (اسم آخر لمحرف Unicode واحد) ضمن أي من فئات محارف Unicode. وهذا لا يقتصر على فئات محددة مثل Ll (حرف، حالة صغيرة) وSc (رمز، عملة)، بل يشمل أيضًا الفئات الأم مثل L (حرف) وS (رمز).
يمكنك مطابقة أي حرف من أي حالة في أي لغة بشرية يغطيها Unicode باستخدام النمط \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
تطبيق مطابقة Unicode على المشكلة المطروحة
الآن بعد أن أصبح لدينا أداة تساعدنا في تحديد ما إذا كان الغرافيم حرفًا أم لا، يمكننا تطبيقها لحلّ المشكلة. قد يبدو التنفيذ الأولي لدالة 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}. وبالنظر إلى أن هذه الدالة قادرة على التعامل مع مدخلات من أي لغة، أستطيع القول إنها 9 أسطر من الكود قوية جدًّا.
الخاتمة
اتضح أن مطابقة الأحرف غير الإنجليزية تصبح بسيطة جدًّا عندما تعرف عن مطابقة Unicode، ولحسن حظنا أنها ميزة أساسية في وحدة 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"]