プログラミング漫遊記

思ったことや、勉強したことをつらつらと。

改訂版・チェリー本発売記念 点字メーカープログラムに挑戦してみた!(Advent Calendar 2021/Qiita主催)

フィヨルドブートキャンプで学習中のはるぐちです。

伊藤淳一(@jnchito)さん著『プロを目指す人のためのRuby超入門/技術評論社』(通称チェリー本)の改訂2版が発売されたということでRubyプログラミング問題にチャレンジ! -改訂版・チェリー本発売記念-で出題されている「点字メーカープログラム」に挑戦してみました。アドベントカレンダー12月6日担当です。 qiita.com

こういう企画に参加するのは初めてだったのですが、

  • チェリー本に育てられたといっても過言ではないので発売記念イベントを是非盛り上げたい
  • 自分の書いたコードを他人に見てもらうのは良い学習になる
  • 他の人の書いたコードを読むのは良い学習になる

という思いから参加してみました。 よろしくお願いします!!!

プログラム作成前に考えたこと

仕様の確認

今回の点字メーカープログラムでは入力された文字列に対して点字のフォーマット(3×2)に合わせた文字列で出力するというものです。 入力値は訓令式のローマ字で構成される文字列で、1音ずつ半角スペースで区切られています。また、日本語音節でいう清音(あ行、か行、…、ら行、わ行)から「ゐ」、「ゑ」、「を」を除いた45音で考えるということになっています。つまり、濁音や促音、長音といった清音以外は考慮不要になっています。

例) A HI RU SI MA U MA KI RI Nなど

また、文字の間にスペースが空いてないなどの異常系の入力は考慮不要になっています。(よかった。)

ということでこれらの入力を想定した最低限動くプログラムを作成することにしました。というか、長音とか促音とか入ってくると自分の力では解ききれない可能性が😅

方針を立てる

プログラムの流れを整理してざっくりと方針を立てます。

  1. 'A HI RU'などを['A' , 'HI', 'RU']と半角スペースで区切って配列に分解する。
  2. 配列をループさせながら何らかの変換ロジックに従って変換する
    • 1音はNを除き「母音」+「0以上の子音」の組み合わせになっているのでそれぞれを分解する。
    • や行を除いて母音と子音で変換が干渉することはないのでそれぞれの変換を重ね掛けする。(最大2回変換する)
  3. 点字のフォーマット(3×2)に合わせて出力する

実際の作成物もおおよそこのような形で作成することができました。

実際に作ったものはこちら

GitHub PR https://github.com/JunichiIto/tenji-maker-challenge/pull/15

ロジックの解説

メインメソッド

メインメソッドから見ていきましょう。メインメソッドはto_tenjiというメソッドになっています。

def to_tenji(text)
  moras = text.split(' ') #方針立て1
  squares = dot(moras) #方針立て2
  format_tenji(squares) #方針立て3
end

メインメソッドの中身は作成前に立てた方針と同じような作りになっています。

まず、入力された文字列をString#splitを使って半角スペースで区切って1音ずつ配列に格納しています。変数名は音節を表す「モーラ(mora)」の複数形です。

次に、TenjiMaker#dotという点を打つ変換メソッド(後述)を作って変換しています。 戻り値は二重配列になっていて例えばA HI RUであれば次のような戻り値になっています。

# dot(['A' , 'HI', 'RU'])の戻り値
[
  ["o", "-", "-", "-", "-", "-"], # A
  ["o", "o", "o", "-", "-", "o"], # HI
  ["o", "-", "-", "o", "o", "-"] #RU
]

最後にそれらをフォーマットするメソッドを用いて出力しています。(後述)

ということで、点を打つ変換メソッドの中身を見ていきましょう。

変換ロジック(dotメソッド)

点を打つ場所についての考え方

点を打つ場所を考えるために下のサイトを元に(3×2)の場所について考えました。

全視情協:点字とは – 点字のしくみによると

点字は、縦3点、横2点の6点の組み合わせで作られています。そして、この単位をマスと言います。下の図をみてください。この図の①②④の点を組み合わせて母音を表し③⑤⑥の点を組み合わせて子音を表します。

① ④

② ⑤

③ ⑥

とあります。

つまり、点字のマスは縦に1、2、3…と番号が付けられていることがわかります。 正直、配列の操作のしやすさだけでいうと横に1、2…といった方が操作しやすいとような気がしてここはどうするか迷いました。(下参照)

# 配列で操作するならこっちの方がいいけど、、、
① ②

③ ④

⑤ ⑥

これはデータ構造をプログラムの都合に合わせるか、現実の点字のしくみに合わせるかというトレードオフになっていると思うのですが、仕事としてプログラムを書いたことのない自分にはどっちに寄せるべきか判断できなかったので以下のように基準を作りました。

  • できるだけ、現実の仕組みに合わせる
  • できるだけシンプルに表現する

できるだけ。というところで妥協し前者の方法(縦に番号をつけていく)で番号づけを行いながら配列操作のため番号を⓪番から始めるということを落とし所にしました。 つまり下の図のようになります。

# 現実の点字の仕組みに即しつつ、配列操作のため0番からはじめる
⓪ ③

① ④

② ⑤

そして、全視情協:点字とは – 点字のしくみを参考に各音と点を打つ場所(インデックス)を対応づけるテーブルを作りました。

FIRST_CONVERSION_TABLE = {
  'A' => [0],
  'I' => [0, 1],
  'U' => [0, 3],
  'E' => [0, 1, 3],
  'O' => [1, 3],
  'N' => [2, 4, 5],
  'Y' => [3]
}.freeze

SECOND_CONVERSION_TABLE = {
  'K' => [5],
  'S' => [4, 5],
  'T' => [2, 4],
  'N' => [2],
  'H' => [2, 5],
  'M' => [2, 4, 5],
  'R' => [4],
  'A' => [2],
  'U' => [2, 5],
  'O' => [2, 4]
}.freeze

このテーブルをよく見るとA,U,O,Nがどちらのテーブルにも重複して存在していますが、これは「ん」と「な行」でNの変換位置が異なることや「や行」が母音の変換位置が変わることを考慮し、役割に応じてそれぞれのテーブルに分けています。こうすることによってif分での複雑な条件分岐を避けることができました。

1音ずつループを回して打点していく

点を打つロジックはこのようになっています。

def dot(moras)
  moras.map do |mora|
    square = Array.new(6) { '-' }
    first_char, second_char = mora.start_with?(/[WY]/) ? mora.chars : mora.chars.reverse

    FIRST_CONVERSION_TABLE[first_char]&.each { |i| square[i] = 'o' }
    SECOND_CONVERSION_TABLE[second_char]&.each { |i| square[i] = 'o' }
    square
  end
end

このコード部分はmapメソッドで1音ずつ取り出し、点を打って配列にに格納するという作業を行なっています。

Array.new(6) { '-' }で基本となる1マス分の配列を作成しています。

[ '-', '-', '-', '-', '-', '-']`

ブロックを渡さずにArray.new(6, '-')とすると'-'という要素が同一オブジェクトになり、1つ変更すると全ての'-'が変更になってしまうので注意が必要です。

ポイントはfirst_char, second_char = mora.start_with?(/[WY]/) ? mora.chars : mora.chars.reverseの部分でString#charsで1音を1文字に分解するのですが、

  • 「わ行」や「や行」が入っている場合はA U Oといった母音はSECOND_CONVERSION_TABLEで変換してほしい
  • それ以外の音はA I U E Oといった母音はFIRST_COVERSION_TABEで変換してほしい

という比較的簡単な条件分岐になっています。

どのテーブルで変換するかを仕分けした後は、それぞれEnumarable#eachをつかって該当のインデックスに点(ここでは文字列oですが)を打っていきます。最後にループの内部で打点したsquaresという配列を返しています。

フォーマット部分

フォーマット部分は以下のようになっています。

def format_tenji(squares)
  transposed_squares = squares.map { |square| square.each_slice(3).to_a.transpose.map(&:join) }
  transposed_squares.transpose.map { |rows| rows.join(' ') }.join("\n")
end

もしかしたらかなり読みにくいかもしれません。😅

メソッド1行目に関して squares.map { |square| square.each_slice(3).to_a.transpose.map(&:join) }は縦に番号をつけた関係で、まず配列を2分割して行と列を入れ替える作業を行なっています。その後、内部の配列を結合して行ごとの配列にしていいます。

1音に対するフォーマットのイメージ図は以下の通りです。

# each_slice(3)3点ずつ2つに分ける
[[ 0, 1, 2] [3, 4, 5]]

# transposeで行列の置換

[
  [0, 3],
  [1, 4], 
  [2, 5]
],

# map(&:join)で内側の要素を結合する
[
  [03], # 1行目
  [14], # 2行目
  [25] # 3行目
]

メソッドの2行目 transposed_squares.transpose.map { |rows| rows.join(' ') }.join("\n")に関しては各音の1行目、2行目、3行目を揃えるためにtransposeして内部の要素を半角スペースで結合しています。

イメージ

# transposeで行列の置換
[ 
  [[03], [03], [03]...], # 1行目どうし
  [[14], [14], [14]...],  # 2行目どうし
  [[25], [25], [25]...] # 3行目どうし
]

# mapで行同士を結合
[
  [03 03 03...],
  [14 14 14...],
  [25 25 25...]
]

# join("\n") で改行しながら結合
[ 
  03 03 03 ... \n
  14 14 14 ...\n
  25 25 25 ...\n
]

最後にこれをputsして完成です。

作ってみての感想

アピールポイント

変換テーブルを2つ用意して、条件分岐の複雑さを回避したことでしょうか。 ただ、実際は促音や濁音などの清音以外の音が入ってくるのでこれは今回の仕様のみ使える方法だと割り切って考える必要がありそうですね。

フォーマット部分でややこしいことはしていますが、難しいことはしていないと思うので、ロジックの単純さがアピールポイントになると思います!!

苦労したところ

点字1マスの点の番号づけに苦労しました。

横に番号をつけていくと配列操作が楽になりフォーマットのロジックが簡単になりますが、変換テーブルを見たときにインデックスの意味がわからなくなるし、縦に番号をつけていくと変換テーブルの意味は分かりやすくなりますが、フォーマットするロジックは複雑になります。

点字の点の打ち方と比べてフォーマットは仕様によってコロコロ変わる恐れがあると思い、今回に関しては変わりにくい点字のインデックスの方をメンテナンスしやすいように、現実に即した番号づけをしました。

最後に

『プロを目指す人のためのRuby超入門』の改訂2版の発売おめでとうございます。現在フィヨルドブートキャンプでは初版の方で平日の夕方輪読会をしていますが、そこで一緒になって「あーでもない、こーでもない」と知識を探求する仲間に出会えました。この2版を使ってしっかり学んでいきたいと思います。

最後まで読んでくださってありがとうございました。まだまだ、アドベントカレンダーは続きますのでお楽しみください!!