Uploaded avatar of PercyGrunwald

Unicode-Abgleich in Elixir

@PercyGrunwald
vor Mehr als 7 jahre

Übungen auf Exercism sind klein, künstlich und wirken oft trivial. Man könnte leicht annehmen, dass erfahrene Entwickler daraus nichts lernen können. Doch wenn du solche künstlichen Probleme löst, kann dich das dazu bringen, Teile deiner Sprache zu lernen und anzuwenden, die du vielleicht noch nie erkundet hast. Dieses neue Wissen kann dir helfen, Probleme aus der Praxis effizienter oder ausdrucksstärker zu lösen.

Parallel Letter Frequency ist eine Übung mittlerer Schwierigkeit im Elixir-Track von Exercism, die eine überraschend große Zahl interessanter Lektionen bereithält. Eine zentrale Herausforderung beim Lösen der Übung ist der Umgang mit Buchstaben aus verschiedenen Sprachen, denn einer der Testfälle ist auf Deutsch und enthält Zeichen, die nicht zum englischen Alphabet gehören. Wenn du die meiste Zeit Anwendungen für englischsprachige Nutzer entwickelst, ist das vielleicht das erste Mal, dass du dich mit einer solchen Anforderung auseinandersetzen musst. Was du aus dieser Übung lernst, nützt allen, die mehrsprachige oder nicht englischsprachige Anwendungen schreiben, kann aber auch in vielen anderen Bereichen helfen, etwa bei robusteren Validierungen von Benutzernamen und Passwörtern.

Um die Übung erfolgreich zu lösen, musst du eine Funktion Frequency.frequency/2 implementieren, die die Buchstabenhäufigkeit in einer Liste von Strings bestimmt, die in jeder beliebigen Sprache sein können:

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

Fangen wir mit dem grundlegenden Problem an, das diese Funktion lösen muss, und arbeiten wir uns bis zu einer vollständigen Implementierung vor.

Feststellen, ob ein Zeichen in Elixir ein Buchstabe ist

Wie würdest du mit Elixir feststellen, ob "a" ein Buchstabe ist?

Ich denke, die meisten würden einen regulären Ausdruck wie /[a-z]/ verwenden:

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

Und was ist mit "A"?

Am einfachsten ist es wahrscheinlich, den i-Modifikator (ohne Beachtung der Groß-/Kleinschreibung) hinzuzufügen:

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

Okay, und was ist mit "ö"?

Als ich mich zum ersten Mal mit diesem Problem beschäftigte, war ich mir nicht sicher, was der beste Weg ist. /[a-z]/i wird definitiv nicht funktionieren:

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

Festzustellen, ob "ö" ein Buchstabe ist, gehört zum Kern der Lösung dieses Exercism-Problems, denn einer der Texte in den Tests ist auf Deutsch:

# 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
...
"""

Vielleicht könntest du einen regulären Ausdruck verwenden, um zu prüfen, ob ein Zeichen kein Sonderzeichen ist, aber wahrscheinlich wird er lang, unelegant und anfällig. Wie sicher kannst du sein, dass du jedes mögliche Sonderzeichen abgedeckt hast, das deiner Funktion als Eingabe übergeben werden könnte? Ich bin überzeugt, dass es einen besseren Ansatz gibt.

Reguläre Ausdrücke mit Unicode in Elixir

Ein besserer Ansatz für dieses Problem ist der u-Modifikator im Regex-Modul von Elixir:

unicode (u) - aktiviert Unicode-spezifische Muster wie \p und bewirkt, dass Modifikatoren wie \w, \W, \s und ähnliche ebenfalls auf Unicode passen.

Es zeigt sich, dass der u-Modifikator und speziell das \p-Muster eine wirklich elegante Lösung sind. Mit dem \p-Muster kannst du ein Graphem (ein anderer Name für ein einzelnes Unicode-Zeichen) in jeder der Unicode-Zeichenkategorien finden. Dazu gehören nicht nur konkrete Kategorien wie Ll (Buchstabe, kleingeschrieben) und Sc (Symbol, Währung), sondern auch die übergeordneten Kategorien wie L (Buchstabe) und S (Symbol).

Mit dem Muster \p{L} kannst du jeden Buchstaben in jeder Groß- oder Kleinschreibung in jeder von Unicode abgedeckten menschlichen Sprache finden. Damit ist ein ziemlich mächtiger Musterabgleich möglich.

Grundlegende lateinische Zeichen aus dem Englischen funktionieren wie gewohnt:

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

Auch lateinische Zeichenvarianten mit Umlauten und Akutakzenten sind kein Problem:

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

Stellen wir sicher, dass es nicht einfach bei jedem Zeichen einen Treffer liefert. Wie sieht es mit Zeichen aus, die wie Buchstaben aussehen, aber keine sind:

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

Sehr schön, aber erinnerst du dich, dass ich jede Sprache gesagt habe? Kein Problem:

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

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

Den Unicode-Abgleich auf das vorliegende Problem anwenden

Jetzt, wo wir ein Werkzeug haben, mit dem wir feststellen können, ob ein Graphem ein Buchstabe ist, können wir es zur Lösung des Problems einsetzen. Eine erste Implementierung der Funktion Frequency.frequency/2 könnte so aussehen:

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

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

Alles, was count_letters/1 tun muss, ist, das oben gefundene Muster String.match?(grapheme, ~r/^\p{L}$/u) anzuwenden, um den Zähler für jeden Buchstaben in der Liste der graphemes zu erhöhen. Hier ist eine Beispielimplementierung aus meiner Lösung für dieses Exercism-Problem:

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

Diese Funktion nimmt eine Liste von Graphemen entgegen, z. B. ["a", "A", "ö", "$"], und gibt eine Map zurück, die nur die Buchstaben zählt und die Groß-/Kleinschreibung ignoriert, nämlich %{"a" => 2, "ö" => 1}. Wenn man bedenkt, dass diese Funktion Eingaben aus jeder Sprache verarbeiten kann, würde ich sagen, dass neun Zeilen Code ziemlich mächtig sind.

Fazit

Es stellt sich heraus, dass der Abgleich nicht-englischer Buchstaben ziemlich einfach wird, wenn man den Unicode-Abgleich kennt, und glücklicherweise ist das ein Kernmerkmal des Regex-Moduls von Elixir. Bevor ich dieses Exercism-Problem gelöst habe, kannte ich dieses Feature kaum, aber heute würde ich es als unverzichtbaren Teil meiner Elixir-Toolbox betrachten.

Du kannst dieses neue Werkzeug auf viele Arten einsetzen, und ein paar, die mir einfallen, sind eine robustere Validierung von Passwörtern und Benutzernamen oder sogar die Feststellung, ob ein eingegebener String eine gültige Währungszeichenkette ist, ohne alle möglichen Währungssymbole manuell auflisten zu müssen:

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. März 2019 · Fandest du ihn hilfreich?