Οι ασκήσεις στο 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"]