勝手に「点字メーカープログラム」を作ってみる

#はじめに
これは、[2021年の Ruby アドベントカレンダー２](https://qiita.com/advent-calendar/2021/ruby)の３日目の記事です。

先日「[挑戦者求む！Rubyで点字メーカープログラムを作ってみよう 〜Qiita Advent Calendar 2021〜](https://blog.jnito.com/entry/2021/11/04/092218)」という記事を見つけました。
これは面白そうな題材です。１０人いれば１００通りの実装が出てくるのが ruby の醍醐味。是非とも、いろいろな方が書いたコードを見比べてみたい、自分の作ったコードとも比較してみたい。

なので、さっそく[参加しよう](https://qiita.com/advent-calendar/2021/ruby-challenge)と思ったものの、その時点で既にすべての枠が埋まっていたのでした。仕方がないので、こうして勝手にRuby アドベントカレンダーに投稿しています。
（Ruby アドベントカレンダー１の２日目にも、同じような方がいる模様です）

なお、出来上がったコードとテストの実行結果は、[この記事の末尾](#結果)に掲載しています。

##2021/12/25 追記
2021年の Ruby アドベントカレンダー２の[２１日目の記事](https://qiita.com/Reichardt/items/7d51eb02a4968a92e1c8)で、フルセットの点字仕様に対応した「辛口バージョン」を作成してみました。

https://qiita.com/Reichardt/items/7d51eb02a4968a92e1c8

#前提条件
レギュレーションは基本的に、元記事の指定内容に沿ったものにしています。
元記事には評価のポイントについても記載されていますが、あまり気にせず、自分が普段 ruby で書いているやり方で実装してみました。
#ロジックの説明
点字の仕様を読んで、点字が６個のドットのパターンで構成されていること、母音と子音の組み合わせで合成できそうな規則性があること、といった辺りを大まかに把握してから、作り始めました。

クラスは、点字文字列を扱う`TenjiString`と、点字のドットパターンを組み立てるファクトリ`TenjiString::DotPattern`の２つです。
[最近の傾向](https://jp.quora.com/C-%E7%AD%89%E3%81%AE%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0%E8%A8%80%E8%AA%9E%E3%81%A7%E3%81%AF-%E6%96%87%E5%AD%97%E5%88%97%E5%9E%8B-string-%E3%81%A8%E6%96%87%E5%AD%97%E5%9E%8B-char-%E3%81%8C/answers/253187887)に沿って、点字１文字を扱うクラスは用意していません。
##ドットパターンの組み立て
クラス`TenjiString::DotPattern`の責務は、点字のドットパターンの組み立てで、ローマ字の１語を与えると、対応する点字のドットパターンが得られます。

ドットパターンは、内部では６ビットの２進数として扱っています。
２進数とドットパターンの配置との対応関係は、次の通りとしました。

|MSB|||||LSB|
|:--:|:--:|:--:|:--:|:--:|:--:|
|b5|b4|b3|b2|b1|b0|

||||
|:--:|:--:|:--:|
||b5|b4|
||b3|b2|
||b1|b0|
この対応関係は、表示の際の利便性により、点字の仕様を記載している「[全視情協:点字とは - 点字のしくみ](http://www.naiiv.net/braille/?tenji-sikumi)」でのドット番号とは異なっています。

ローマ字の１語は、まず文字数が１個か２個か、で場合分けをします。
１個の場合、母音または撥音（"ん"）ですので、対応するドットパターンを返します。
２個の場合、「子音＋母音」の組合せなので、１文字目を子音、２文字目を母音として、それぞれ対応するドットパターンを合成しています。
や行とわ行の場合、可能な母音の種類とドットパターンが通常とは異なるので、これも場合分けしています。
##点字文字列の表示
クラス`TenjiString`は、文字列クラスとして点字を扱うものですが、現在は表示用にドットパターンを出力することが、主な責務です。

入力としてローマ字の文字列を与えると、空白で分割して、ローマ字１語単位で配列に変換し、`TenjiString::DotPattern`を使ってドットパターンを取得したのち、画面出力用に変換します。

点字は、表示が３行に渡るので、各行の出力内容を作成してから、結合しています。

各行の出力内容は、ドットパターンを６桁２進数の文字列へ変換して、２文字づつ３個に分割したものを、それぞれ使っています。
前述の、ドットパターンと２進数との配置の対応関係は、ここの処理が書きやすいように決めました。
#実装の解説
##完全コンストラクタと不変オブジェクト
インスタンス変数は、`#initialize`だけで設定し、`attr_reader`による参照のみ、行っています。
これにより、`#initialize`以外の個所でインスタンス変数が出てこない（つまりアットマークが出てこない）表面的なチェックだけで、インスタンスオブジェクトの不変性が担保できます。

なお、不変なオブジェクトを構築しやすくするため、インスタンス作成にはクラスメソッド`#create`を使用しています。
##関数型プログラミング
厳密な意味での関数型プログラミングではなく、ある程度、寄せている感じです。

まず、繰返しや条件分岐のような、いわゆる制御構造が一切でてきません。今回は加えて三項演算子すら使っていませんが、別に縛りをかけている訳ではなく、普段は三項演算子も後置if式も使っています。

ローカル変数もほとんど出てきません。唯一の使用箇所は`TenjiString#row_dots`で、ラムダの説明変数として`formatter`があるだけです。
これも縛りをかけているというよりも、結果的にローカル変数を使う必要が無くなった為です。その代わり、短いメソッドが多数できています。

また、外部との界面になっているメソッドを除くと、メソッドに引数が付いている箇所もほとんどありません。今回は、ブロック内から呼ばれている`TenjiString#row_dots`のみです。
##わかりにくい箇所
以下は、case に相当する処理を、ハッシュとメソッドの動的呼び出しの組合せで実現しています。やりすぎだと思う方も多いでしょう。自分でも、そう思います。普段は三項演算子で事足りるケースが殆どなので、多用はしていません。

```ruby
  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 を引いて配列の添え字にする方法では、文字数が０の場合に添え字が -1 となって期待通りに動きません。

```ruby
  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`で例外が上り、１語の文字数がおかしい場合は`method(nil).call`で例外が上る、といった様に、既存のメソッドが持つ例外対応を利用しています。
##拡張性
レギュレーションに沿って、要求されている範囲は実装していますが、同時に、要求されていない範囲は実装していません。

今後、例えば対応するローマ字の種類を増やす場合、「を」や促音、長音については、`TenjiString::DotPattern`にて文字数１個への追加で容易に対応可能です。
濁音や拗音への追加対応の場合、点字が２マス使用するので、まず`TenjiString`側でローマ字での文字列変換処理を新規追加した後、ドットパターンを追加した`TenjiString::DotPattern`へ渡す流れを考えています（詳細は検証していません）
どちらのケースでも、既存のコードの変更箇所は最小限で済むはずです。
#心残りなところ
##わ行の対応
点字には、かなりの規則性があるのですが、それでも扱いを分ける必要が出てきます。

わ行の扱いを「独立した特別扱い」にするか「や行と同じ扱い」にするか、２通りのアプローチが考えられますが、今回は後者で実装しています。
ただ、実際に書いてみて、や行と同じ扱いにするメリットが少なかったので、どちらで作っても大差なかったと感じています。
##ドットパターンの表示
ドットパターンの表示処理を、どこのクラスに持たせるか、で悩みました。

同じドットパターンに関連する処理ではあるものの、クラス`TenjiString::DotPattern`に入れるのは、違うと思います。
点字の表示や出力に責務を負う、新たなクラスを作って、そこで行うのが適切だった気がしています。
今後、クラス`TenjiString`がクラス`String`並みに機能を充実していくことがあるなら、その際に見直しを行うことになるでしょう。

なお、`TenjiString#print_in_dots`というメソッド名は、もう少しいい感じの名前にしたかったです。

#結果
##出来上がったコード
```ruby:lib/tenji_maker.rb
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
```
##テストの実行結果
![rake.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2312031/9b65a8e9-d45b-32e6-d25b-bb985b9282ba.png)