LoginSignup
4
0

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-12-02

はじめに

これは、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というメソッド名は、もう少しいい感じの名前にしたかったです。

結果

出来上がったコード

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

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0