見出し画像

Retro-gaming and so on

4択クイズ

教えて!gooに以下のような問題が上がってた。


さぁ、どうだろうか。
一つの視点としては、「どのようなコーディングをするべきか」と問われても、あまりにも範囲が広く曖昧だ、と言うような考え方もあるだろう。4択クイズがどーの、ってぇのは関係ねぇんじゃねぇか、と。
そして、Lispを知ってる層だと明らかにある点が気になる筈だ。

「あれ、この人、REPL知らないんじゃない?」

と。
そう、この質問者は「ソフトウェアの作り方を知らない」。だからかなり広い範囲の意味での「曖昧な質問」になってるんだ。Java ServletとかJSPとかは枝葉の事柄であって、それらは本質的な問題じゃないんだ。単にソフトウェアの作り方を知らない、それだけなんだよな。
しかし、この質問者を責める事は出来ないだろう。かなりの確率で、どんな初学者向けプログラミング入門書だろうと、K&Rの焼き直しだ、って話は何度か言っている。このテの入門書だとプログラミング言語の「機能」及び「その使い方」は説明するが、「ソフトウェアの作り方」には全く触れないんだ。良くってスクリプト程度を書くだけで終わってしまう。
言っちゃえば、絵画教室に通っても、筆の種類、筆の使い方、絵の具の混ぜ方は教えるけど絵の描き方は教えない、と言うような状態だ。「絵の描き方を学びたい」と思ってるのに道具の使い方の解説に終始すれば受講者はアタマに北半球だろう。
ところが、それが「プログラミング入門」では行われてるわけだ。事実上詐欺っつっても良い。
従って、この質問者が全く指針やら方針が見えない状態になってる、ってのは割に当たり前なんだよな。大体そのテのプログラミング入門だと「オブジェクト指向だと、コードが整理して書けます」とか大嘘ばっか書いてるが、そもそもコンセプトとして「どうプログラムを書くか」解説が無い状態で、随分と酷い画餅じゃないか。
いや、最近マジで思ってきた。やっぱ入門用言語としてはLispが一番イイんじゃないか、と。いや、「Lispの機能が✕✕だから」ってぇんじゃない。そうじゃなくって、Lisp関連で発刊されてる書籍ではREPLと言うコンセプトをかなり初期に扱ってるブツが多いから、だ。そしてストレートにそこにツッコんで行きやすい。あっという間に「ソフトウェアを書くと言う」概念をつかめるようになるだろう。いや、「全てのソフトウェアは基本インタプリタだよ」って言う助言は必要にはなるだろうけどね。
もちろん、Pythonなんかでもそういう教授は出来ると思うが、単に本の質が良くねぇんだ。ヘタクソなC言語臭ぇコードを書いてはK&Rの焼き直しをやって、スクリプト程度を書いててもしゃーないやろ、って本ばっかだからだ。Lispに比べると圧倒的に本の質が悪い。単にそれだけ、だ。
ここんとこ、Webアプリに始まって今や機械学習とか、言っちゃえば「ライブラリの使い方」で終始してるような「出版ブーム」っつーか「教育ブーム」みたいなんで、まっとうに「ソフトウェアの書き方」を指南してる書籍ってLisp関連の本しかない。だからホント、まずはLispを学ぶべきだ、って最近はますますそう思ってきたんだって。言語はへんてこりんだけど本がいい。それがLispを薦める最大の理由だ。
こういう質問者の質問見てると、なんつーの、ある意味、ロクなプログラミング指南書がねぇ、って事に関しての被害者だよな、とか思うんだわ。いや、マジで。気の毒でしょーがない。
ここでは、この4択クイズのソフトウェアを実際にREPLと言うアイディアと共にRacketで実装してみよう。まぁ、単純に僕がJava ServletとかJSPを知らんだけ、なんだが、「エンジン設計」に関してのアイディアは共通してるモノだ。ユニバーサルにREPLと言うテクニックは使えるモノだ、と言うパースペクティヴを持っている。
では始めよう。

実はこの問題、特有の難点があるこたぁ事実なんだけど、それは後回しにしよう。まずは扱うデータ設計だ。データ設計さえしっかりしとけば、REPLでプロセスするのも俄然ラクになる。
とは言っても比較的簡単だ。例えば次のような4択クイズがあったとする。


適当な4択クイズを掲載してるサイトから引っ張ってきたが、取り敢えず必要なのは

  • 問題文
  • 選択肢とそれにまつわる真偽
の2つだ。これをデータとして設計する。Lispなんでやっぱりリストがいいだろう。
一つの問題を次のようなデータ構造にする。

(アブラカタブラの意外な意味とは?
 (いただきます #f)
 (ごちそうさまでした #f)
 (悪霊退散 #f)
 (花粉症退治 #t))

問題文を先頭として、選択肢とその真偽を一つのリストにした4要素を持つリストになる。なお、敢えて選択肢番号は外してある。
ちなみに、問題も選択肢も、通常だったら文字列にするケースが多いだろうが、メンドくせぇんで(笑)、全部シンボルとした。古来からのLispっぽいプログラムになっている。
こういうデータ形式としておいて、大域変数としてデータベース変数、*db*を用意する。こんなカンジになる。

(define *db*
 '((お酢に卵を殻ごといれると卵はどうなるでしょう?
 (透明な卵になる #t)
 (鏡のようになんでもうつる卵になる #f)
 (卵が溶けてなくなる #f)
 (卵が石のように堅くなる #f))
 (しゃっくりはある調味料をなめると止まります。ある調味料とはなんでしょう?
 (お酢 #f)
 (砂糖 #t)
 (醤油 #f)
 (塩 #f))
 ( ...

これはまぁいいだろう。
で、だ。
毎回同じ問題が表示されるのもアレだし、選択肢の順番にも変化を持たせたい。
そこで、Racket組み込みのshuffleと言う関数を使って*db*に格納されてる問題の順序とそれら選択肢の順序を入れ替える。そしてその先頭から10個問題を引っ張ってくる設計にする。
変数*db*の中身をシャッフルする関数は次のように書く。

(define (shuffle7 lst)
 (foldl (lambda (proc x)
    (proc (map (match-lambda ((cons first rest)
                 (cons first (proc rest))))
         x))) lst (
make-list 7 shuffle)))

ポイントとしては、shuffleと言う関数を7つの要素としたリストを作り、それを初期値lstに対してfoldlで連続適用する、と言う大枠だ。
foldlが取るリストは必ずしもデータのリストではなく、関数のリストでも構わない、って事に注目しよう。これは一般に、reduceを使う際でのテクニックでもある。
また、それと同時に、lstの各要素をmatch-lambdaで先頭とそれ以外に分け、「それ以外」、つまり選択肢のリストもmapしながらシャッフルしている。
なお、通算7回シャッフルしてるのは、ババ抜きの項でも書いたが、数学的には7回もシャッフルすれば、「充分に混ざる」と言われてるから、だ。

次にユーティリティを作る。
まず、「選択肢番号を付加」する為に、Pythonでお馴染みのenumerateを導入する。これは以前作ったんで、そのまま流用する。

(define enumerate
 (case-lambda
  ((iterable start)
   (map cons (range start (+ start (length iterable))) iterable))
  ((iterable) (enumerate iterable 0))))

もう一つ、入力は数値1文字、だと想定されるんで、キーボードからの読み込みはread-charをベースにした文字になるが、それを数値に変換する必要があるんで、digit-valueと言う関数を作る。

(define (digit-value char)
   (string->number (list->string `(,char))))

これで材料は揃った。さて、REPLを組んでいこう・・・とは言っても、実はこの問題、一つだけ厄介なトコがある。
ルーピングを考える際、次のような動作になるんだ。

問題文選択 => 問題文表示 => 入力 => 正解判定 => 表示

まるで次のように見える。

eval1 => print1 => read => eval2 => print2 

つまり、2つREPLが必要なように見える。
いや、実際はそうしても構わないだろう。2つのREPLを作って互いに順番に現れるようにしても構わない。
要するに、

(print2 (eval2 (read (print1 (eval1)))))

となるように書いても構わないんだが、ここではそれは止めて、phase(フェーズ≒段階)によってreadevalprintの動作が「切り替わる」ようにして設計していこう(※1)。

まずは、eval部が必要とする、環境変数、worldを構造体でデザインする。

(struct world (phase quiz alist table acc) #:transparent)

構造体worldは5つのスロットを持つ。

  1. phase: readevalprintの動作を切り替える真偽値。
  2. quiz: tableから引っ張ってきた問題の先頭(質問文)を格納するスロット。
  3. alist: tableから引っ張ってきた問題の選択肢+解答部分。自然と連想リストになっている。
  4. table: 問題文のデータリスト。デフォルトでは10問格納される。
  5. acc: アキュムレータ。問題に正解/不正解した、と言う真偽値をconsで記録していく。
取り敢えずこういう構造にしてみる。
まずはRead部を作る。
Read部は先に書いた通り、read-charを利用して作るが、read-charには特有の問題があるので、それを解決するように作る。

(define (input env)
 (match-let (((world p q a t acc) env))
  (
unless p
   (
begin0
    (digit-value (read-char))
    (read-char)))))

関数inputも環境を取る。環境はworld構造体なんで、まずはmatch-letで構造体をスロット毎へとバラバラに分解する。ここで必要なのはphaseスロットの情報だ。
phase#tの時は全く読み込みを行わない。#fの時だけ読み込みを行うようにする。
そして単純にはread-charで読み込んだ数字文字を数値に変換して返す、だけなんだが、ここでread-charにはちと困った現象があるんだ。
read-charは一文字読み込んでその文字を返す関数なんだけど、リターンキーを叩かないと動作が終了しない。しかし、read-charが使ってるバッファに、リターンキー情報が残っちまうんだ。


実際インタプリタで試してみて欲しい。二回目の(read-char)呼び出しでは、何も入力しなくても「改行情報」が吐き出される。これが入力バッファに改行情報が残っていた証拠で、まるで便秘女の糞詰り状態になってた、って事だ。
以前、星田さんが出力バッファに対して困っていたが、入力バッファにも似たような性質があって、入力バッファも掃除せなアカン、って事だ(これをflushと呼び、ぶっちゃけ、水洗便所の「水を流す」のと同じ単語だ)。
上の写真の例を見てみれば分かるだろうけど、単純に入力バッファをflushするには、read-charを二回叩けばいいわけだ。しかし、必要な情報は1回目の返り値であって2回目で返ってくる情報は邪魔なんだよな。
こういう時に使うのがRacketではbegin0だ(※2)。begin0はSchemeではあまり使わない逐次処理の為のマクロなんだけど、返り値は1番目のモノを使う、と言う性質があって(破壊的変更が無ければ)2番目以降の動作をハッキリ言えば無視する。beginと逆の事をやるわけだ。
これで入力情報だけ返してflushの動作は実行されても全く返り値として使われない。そしてread-charのおかしな動作を防ぐ事が出来るわけだ。
なお、関数inputは1〜4のキーしか入力を想定していない。それ以外のキーが叩かれた場合どーすんの?って思うかもしれないが、心配には及ばない。エラーになってもそれを捕まえればいいだけ、だからだ。よってここでは余計な事は考えない。

次にevalへ行く。
evalも動作が2つあって、phaseが真の時は問題文と選択肢+解答をtableから引っ張ってきてそれぞれ該当するスロットへ格納、そうじゃなければ入力情報に従って、正解・不正解のデータをアキュムレータへとconsする。また、一回evalが呼ばれる度にphaseの真偽を切り替える。

(define (interp x env)
 (match-let (((world p q alist table acc) env))
  (world (not p)
     (if p
      (caar table)
      q)
     (if p
      (enumerate (cdar table) 1)
      alist)
     ((if p
      cdr
      
identity) table)
     ((if p
      identity
      (lambda (v)
       (cons (third (assv x alist)) v))) acc)
     )))

例によって、最初に環境情報であるworld構造体をmatch-letでバラバラに分解する。そしてまたworld構造体を作って返す、ってのが大枠だ。
phaseスロットであるpinterp関数が呼ばれる度に#tだった時は#fに、#fだった時は#tへと切り替わる。それを行うのがnot関数だ。
quizスロットであるqpが真の時、tablecarcarが格納される。それが問題文だから、だ。pが偽の時は何もしないので、qのままで継続状態となる。
alistスロットはpが真の時tablecarcdrが格納され、自然と連想リストになっている。それは選択肢+答えのデータ構造だ。ついでにここでenumerate関数で選択肢番号が付加される。pが偽の時は何もしないので、alistのままで継続状態となる。
tableスロットにはデフォルトで問題が10問格納される。pが真の時carpqに手渡され、table(cdr table)へと更新される。pが偽の時はそのまま据え置きになる。
accは正解/不正解を詰め込むリストだ。pが真の時は何もしないが、pが偽の時、input関数から入力情報(数値)がやってきて、alistスロットからその数値をキーとするデータを返してもらい、それが正答か否か調べて、そのままその情報をconsする。つまり、常にaccの先頭が「今答えた回答」の真偽情報になる。
ちとこのinterp関数の見た目で「ウゲ」とか思うかもしんないけど、やってるこたぁ簡単で、今まで他の例で見たevalに比べても大して難しい事はやってない筈だ。

最後にprint関数を作る。ここに環境情報が来る時点でphaseevalが受け取ったモノと逆になってる事に気をつけよう。

(define (print env)
 (let ((table '((#t . #\○) (#f . #\✕))))
  (match-let (((world p
          q
          `((,n1 ,s1 ,f1) (,n2 ,s2 ,f2) (,n3 ,s3 ,f3) (,n4 ,s4 ,f4))
          t acc) env))
   (display (if p
        (cdr (assv (car acc) table))
        (format "~a~%~%~a. ~a~%~a. ~a~%~a. ~a~%~a. ~a~%" q n1 s1 n2 s2 n3 s3 n4 s4)))
   (newline)
   env)))

まず出力用テーブルを作ってるが、これは受け渡されたworld構造体のaccスロットの先頭の真偽値に対応して○か☓かを出力する為のものだ。
次に例によってmatch-letworld構造体を分解してるが、選択肢用のalistスロットを更に細かく分解してる。と言うのも出力の為だ。
nが選択肢番号、sが選択肢の内容、fが解答、って事だな。
phasepが真の時、○か☓かを表示して、そうじゃない時には問題文と選択肢を表示するだけの簡単なお仕事になっております。当然、正解か不正解は表示しない。
気をつけるのは、「出力は副作用」で、この関数の真の作用は環境情報であるworld構造体を「そのまま」返す事だ。つまり、実質的には「何もしない関数」となっていて、返されたworld構造体はそのまま、またinput関数へと流れていく。

さて、あとはREPLを組み上げればいい。
最初のヴァージョンは多分次のようになってるだろう。

(define repl
 (case-lambda
  (() (repl 10))
  ((n) (let loop ((w (world #t #f #f (take (shuffle7 *db*) n) '())))
    (match-let (((world p q a table acc) w))
     (if (and p (null? table))
      (display (format "~a問中~a問正解~%"
          n
          (length (
remove #f acc))))
      (loop (print (interp (input w) w)))))))))

デフォルトでは問題数は10としたが、別に問題数はそれに限らないようにしている。
初期条件として名前付きletに変数wを与え、world構造体を組み上げる。
即刻match-letでスロット毎に分解する。
ゲームの終了条件はphaseスロットが真、かつtableスロットが空になった時。この時点で、何問中何問正解したか表示する。正解数の計算方法は特に語る必要もないだろう。
あとは単にループしとけば良い。おしまい、だ。

ただし、入力で1〜4以外のキーが叩かれた時にはエラーが飛んでくる。


エラーはexn:fail:contractだと教えてくれている。
よって例外処理としてはこいつを捕まえればいい。
改めて、Racketの例外処理機構はwith-handlersで、こいつはSchemeのguardwith-exception-handlerの間の子みたいな書式だ。

(define repl
 (case-lambda
  (() (repl 10))
  ((n) (let loop ((w (world #t #f #f (take (shuffle7 *db*) n) '())))
    (match-let (((world p q a table acc) w))
     (if (and p (null? table))
      (display (format "~a問中~a問正解~%"
             n
             (length (remove #f acc))))
      (with-handlers ((exn:fail:contract?
              (lambda (e)
               (displayln "1〜4を入力してください")
               (loop w))))
       (loop (print (interp (input w) w))))))))))

これで終了、だ。
あとは(repl)と打てば遊ぶ事が出来る。


繰り返すが、REPLさえ覚えておけばソフトウェアを書くのはお茶の子サイサイだ。逆にREPLを知らない、と言う事は方針や指針の立ち様が無いので、オブジェクト指向だろうと関数型言語だろうと、混乱を来す事となる。
とにかくREPLだ。何が何でもREPLだ。まずはREPLの作り方を覚えよう。
REPLは何もインタプリタの為だけのモノじゃない。いや、逆だ。何度も言うけど全てのソフトウェアはインタプリタなんだ。
この原則をアタマに入れてデザインすれば、迷うことなく「ソフトウェアを作る」方針が決まるだろう。
そして全ての初学者用プログラミング入門書にはそう言うテクニックを記述すべきだと思うんだけど、残念ながら意外と、それに対して明るいプログラマが実は少なくて、それを書いてくれない、と言う事実があるだけ、なんだ。

なお、今回のソースコードはここにあげておく。

※1: 今回、Racketではこういう方針にしたが、例えばPythonみたいに、input関数にプロンプト表示機能がある場合、問題文+選択肢をプロンプトとして、もっとシンプルでベーシックなREPLが構成可能だろう。
Python版は例えばこんなカンジになる。

※2: ANSI Common Lispでのprog1にあたる。

 
  • Xでシェアする
  • Facebookでシェアする
  • はてなブックマークに追加する
  • LINEでシェアする

最近の「プログラミング」カテゴリーもっと見る

最近の記事
バックナンバー
人気記事