#はじめに
これは、2021年の Ruby アドベントカレンダー2の3日目の記事です。
先日「挑戦者求む!Rubyで点字メーカープログラムを作ってみよう 〜Qiita Advent Calendar 2021〜」という記事を見つけました。
これは面白そうな題材です。10人いれば100通りの実装が出てくるのが ruby の醍醐味。是非とも、いろいろな方が書いたコードを見比べてみたい、自分の作ったコードとも比較してみたい。
なので、さっそく参加しようと思ったものの、その時点で既にすべての枠が埋まっていたのでした。仕方がないので、こうして勝手にRuby アドベントカレンダーに投稿しています。
(Ruby アドベントカレンダー1の2日目にも、同じような方がいる模様です)
なお、出来上がったコードとテストの実行結果は、この記事の末尾に掲載しています。
##2021/12/25 追記
2021年の Ruby アドベントカレンダー2の21日目の記事で、フルセットの点字仕様に対応した「辛口バージョン」を作成してみました。
#前提条件
レギュレーションは基本的に、元記事の指定内容に沿ったものにしています。
元記事には評価のポイントについても記載されていますが、あまり気にせず、自分が普段 ruby で書いているやり方で実装してみました。
#ロジックの説明
点字の仕様を読んで、点字が6個のドットのパターンで構成されていること、母音と子音の組み合わせで合成できそうな規則性があること、といった辺りを大まかに把握してから、作り始めました。
クラスは、点字文字列を扱うTenjiString
と、点字のドットパターンを組み立てるファクトリTenjiString::DotPattern
の2つです。
最近の傾向に沿って、点字1文字を扱うクラスは用意していません。
##ドットパターンの組み立て
クラスTenjiString::DotPattern
の責務は、点字のドットパターンの組み立てで、ローマ字の1語を与えると、対応する点字のドットパターンが得られます。
ドットパターンは、内部では6ビットの2進数として扱っています。
2進数とドットパターンの配置との対応関係は、次の通りとしました。
MSB | LSB | ||||
---|---|---|---|---|---|
b5 | b4 | b3 | b2 | b1 | b0 |
b5 | b4 | |
b3 | b2 | |
b1 | b0 | |
この対応関係は、表示の際の利便性により、点字の仕様を記載している「全視情協:点字とは - 点字のしくみ」でのドット番号とは異なっています。 |
ローマ字の1語は、まず文字数が1個か2個か、で場合分けをします。
1個の場合、母音または撥音("ん")ですので、対応するドットパターンを返します。
2個の場合、「子音+母音」の組合せなので、1文字目を子音、2文字目を母音として、それぞれ対応するドットパターンを合成しています。
や行とわ行の場合、可能な母音の種類とドットパターンが通常とは異なるので、これも場合分けしています。
##点字文字列の表示
クラスTenjiString
は、文字列クラスとして点字を扱うものですが、現在は表示用にドットパターンを出力することが、主な責務です。
入力としてローマ字の文字列を与えると、空白で分割して、ローマ字1語単位で配列に変換し、TenjiString::DotPattern
を使ってドットパターンを取得したのち、画面出力用に変換します。
点字は、表示が3行に渡るので、各行の出力内容を作成してから、結合しています。
各行の出力内容は、ドットパターンを6桁2進数の文字列へ変換して、2文字づつ3個に分割したものを、それぞれ使っています。
前述の、ドットパターンと2進数との配置の対応関係は、ここの処理が書きやすいように決めました。
#実装の解説
##完全コンストラクタと不変オブジェクト
インスタンス変数は、#initialize
だけで設定し、attr_reader
による参照のみ、行っています。
これにより、#initialize
以外の個所でインスタンス変数が出てこない(つまりアットマークが出てこない)表面的なチェックだけで、インスタンスオブジェクトの不変性が担保できます。
なお、不変なオブジェクトを構築しやすくするため、インスタンス作成にはクラスメソッド#create
を使用しています。
##関数型プログラミング
厳密な意味での関数型プログラミングではなく、ある程度、寄せている感じです。
まず、繰返しや条件分岐のような、いわゆる制御構造が一切でてきません。今回は加えて三項演算子すら使っていませんが、別に縛りをかけている訳ではなく、普段は三項演算子も後置if式も使っています。
ローカル変数もほとんど出てきません。唯一の使用箇所はTenjiString#row_dots
で、ラムダの説明変数としてformatter
があるだけです。
これも縛りをかけているというよりも、結果的にローカル変数を使う必要が無くなった為です。その代わり、短いメソッドが多数できています。
また、外部との界面になっているメソッドを除くと、メソッドに引数が付いている箇所もほとんどありません。今回は、ブロック内から呼ばれているTenjiString#row_dots
のみです。
##わかりにくい箇所
以下は、case に相当する処理を、ハッシュとメソッドの動的呼び出しの組合せで実現しています。やりすぎだと思う方も多いでしょう。自分でも、そう思います。普段は三項演算子で事足りるケースが殆どなので、多用はしていません。
def two_letter_word
method(two_letter_word_table).call
end
def two_letter_word_table
{
y: :composition_with_shift,
w: :composition_wa,
}.fetch(first_char, :standard_composition)
end
以下は、文字数(1または2)と配列の添え字(0から始まる)を揃えるため、配列の先頭に1個、要素を挿入しています。
文字数から 1 を引いて配列の添え字にする方法では、文字数が0の場合に添え字が -1 となって期待通りに動きません。
def construct_pattern
method(construct_method).call
end
def construct_method
construct_method_table.prepend(nil).fetch(word.length)
end
def construct_method_table
[
:one_letter_word,
:two_letter_word,
]
end
##異常系の対応
異常な入力の場合は何らかの例外が上るように実装しています。
レギュレーションでは「入力の異常系については考慮不要」となっていますので、どのエラーで何の例外が上るか、上がった例外をどう処理するか、には対応していません。
エラーチェック用のコードは特に追加していませんが、例えば、異常な文字が入った場合はHash#fetch
で例外が上り、1語の文字数がおかしい場合はmethod(nil).call
で例外が上る、といった様に、既存のメソッドが持つ例外対応を利用しています。
##拡張性
レギュレーションに沿って、要求されている範囲は実装していますが、同時に、要求されていない範囲は実装していません。
今後、例えば対応するローマ字の種類を増やす場合、「を」や促音、長音については、TenjiString::DotPattern
にて文字数1個への追加で容易に対応可能です。
濁音や拗音への追加対応の場合、点字が2マス使用するので、まずTenjiString
側でローマ字での文字列変換処理を新規追加した後、ドットパターンを追加したTenjiString::DotPattern
へ渡す流れを考えています(詳細は検証していません)
どちらのケースでも、既存のコードの変更箇所は最小限で済むはずです。
#心残りなところ
##わ行の対応
点字には、かなりの規則性があるのですが、それでも扱いを分ける必要が出てきます。
わ行の扱いを「独立した特別扱い」にするか「や行と同じ扱い」にするか、2通りのアプローチが考えられますが、今回は後者で実装しています。
ただ、実際に書いてみて、や行と同じ扱いにするメリットが少なかったので、どちらで作っても大差なかったと感じています。
##ドットパターンの表示
ドットパターンの表示処理を、どこのクラスに持たせるか、で悩みました。
同じドットパターンに関連する処理ではあるものの、クラスTenjiString::DotPattern
に入れるのは、違うと思います。
点字の表示や出力に責務を負う、新たなクラスを作って、そこで行うのが適切だった気がしています。
今後、クラスTenjiString
がクラスString
並みに機能を充実していくことがあるなら、その際に見直しを行うことになるでしょう。
なお、TenjiString#print_in_dots
というメソッド名は、もう少しいい感じの名前にしたかったです。
#結果
##出来上がったコード
class TenjiMaker
def to_tenji(text)
TenjiString.create(text).print_in_dots
end
end
class TenjiString
def self.create(words)
new(words).create
end
def initialize(words, tenji_string = nil)
@words = words
@tenji_string = tenji_string
end
def create
self.class.new(words, build)
end
def print_in_dots
(0..2).map {|line| row_dots(line) }
.join("\n").tr('10', 'o-')
end
private
attr_reader :words, :tenji_string
def parse
words.downcase.split(' ')
end
def build
parse.map {|word| DotPattern.create(word) }
end
def row_dots(line)
formatter = ->(dots) { ('%06b' % dots).scan(/../)[line] }
dot_pattern.map(&formatter).join(' ')
end
def dot_pattern
tenji_string.map(&:dot_pattern)
end
end
class TenjiString::DotPattern
def self.create(word)
new(word).create
end
def initialize(word, dot_pattern = nil)
@word = word
@dot_pattern = dot_pattern
end
def create
self.class.new(word, construct_pattern)
end
attr_reader :dot_pattern
private
attr_reader :word
def construct_pattern
method(construct_method).call
end
def construct_method
construct_method_table.prepend(nil).fetch(word.length)
end
def construct_method_table
[
:one_letter_word,
:two_letter_word,
]
end
def one_letter_word
vowel.merge(repellency).fetch(first_char)
end
def two_letter_word
method(two_letter_word_table).call
end
def two_letter_word_table
{
y: :composition_with_shift,
w: :composition_wa,
}.fetch(first_char, :standard_composition)
end
def standard_composition
consonant.fetch(first_char) | vowel.fetch(second_char)
end
def composition_with_shift
consonant_with_shift.fetch(first_char) | shifted_vowel.fetch(second_char)
end
def composition_wa
consonant_with_shift.fetch(first_char) | shifted_vowel_wa.fetch(second_char)
end
def first_char
word[0].to_sym
end
def second_char
word[1].to_sym
end
# 母音
def vowel
{
a: 0b0_10_00_00,
i: 0b0_10_10_00,
u: 0b0_11_00_00,
e: 0b0_11_10_00,
o: 0b0_01_10_00,
}
end
# 子音
def consonant
{
k: 0b0_00_00_01,
s: 0b0_00_01_01,
t: 0b0_00_01_10,
n: 0b0_00_00_10,
h: 0b0_00_00_11,
m: 0b0_00_01_11,
r: 0b0_00_01_00,
}
end
# 子音(母音が一番下へ移動する)
def consonant_with_shift
{
y: 0b0_01_00_00,
w: 0b0_00_00_00,
}
end
# 母音(一番下へ移動した)
def shifted_vowel
{
a: 0b0_00_00_10,
u: 0b0_00_00_11,
o: 0b0_00_01_10,
}
end
# 母音(わ行専用)
def shifted_vowel_wa
{
a: 0b0_00_00_10,
o: 0b0_00_01_10,
}
end
# 撥音("ん")
def repellency
{
n: 0b0_00_01_11,
}
end
end