Scheme
FunctionalProgramming
関数型プログラミング

手続きの呼び出しを戻り値化してテストする

はじめに

先日紹介したmatch-letの発見により俺の中ではscheme熱が盛り上がっているのだが、そんな中で面白いことを思いついた。手続きの呼び出しをどうテストするのかということについてのアイデアだ。

関数型プログラミングのいいところはいくつもあるが、テストを自動化できるということは最も大きな利点の一つだろう。副作用がなく引数に対して常に同じ値が返されるので、戻り値を検証するだけでテストを完了できる。

schemeをリスト処理のみに用いる場合、プログラムが行うことはreadで入力したリストを関数で処理し、その戻り値をwriteで出力するだけだ。そのためテストは上述のように関数の戻り値を調べるだけでいい。

しかし俺はこれからschemeを汎用的に使おうとしているのであって、その場合は手続きをより頻繁に呼び出さねばならない。ここで言う手続きとは、ファイル/ネットワーク/データベース/GUIとかそんなのの処理のことだ。

※コメント欄にて詳しい方からご指摘をいただいております通り、schemeでは副作用の有る無しで関数(function)/手続き(procedure)という語義の区別をしていない。し、知ってたんだからね!一応ね!

言うまでもなくこういった処理は副作用として実行されるので、戻り値だけではテストできない。コードが意図通りに動作するかを検証するには、処理結果を調べなければならない。そういった検証をテストコードにより自動化できるケースもあるが、多くの場合は『目視にて確認』しなければならない。ああめんどい。

手続きの呼び出しを戻り値化してみる

ここで題材として無意味な手続きを示す。整数のリストを受け取り、3で割り切れるときだけbuhiと出力するプログラムだ。

(define (buhi-proc l)
  (for-each (lambda (n)
              (begin
                (display n)
                (if (= (modulo n 3) 0)
                  (display " buhi")
                  (void))
                (newline)))
    l))

beginやvoidの呼び出しは冗長だが、これから述べる手品の種なので看過していただきたい。voidは見ての通り何もしない手続きだが、どうもscheme標準ではなくchicken scheme固有であるようだ。

さてこのプログラムをテストするには、実際に実行結果を調べなければならない。それには『目視にて確認』しても良いし、期待される結果をテキストに書き出し実際の出力と比較しても良いが、それをやりたくないというのが本稿の趣旨である。

そのため次のように無理矢理に関数化してみる。

(define (buhi-func l)
  (map (lambda (n)
         (list
           (list display n)
           (if (= (modulo n 3) 0)
             (list display " buhi")
             (list void))
           (list newline)))
    l))

buhi-procの呼び出しをリスト化しただけだ。この戻り値は以下のようなコードで実行しbuhi-procと同じ出力結果を得ることができる。

(define (process-calls calls)
  (for-each (lambda (call)
              (if (procedure? (car call))
                (apply (car call) (cdr call))
                (let [[r (process-calls call)]]
                  (if (pair? r)
                    (process-calls r)))))
    calls))

この方法なら戻り値を調べるだけでテストを完了できそうだが、リスト化しいちいちapplyするので余計な実行時間がかかる。誰しも遅いプログラムは書きたくないのが人情というものだ。

手続きの呼び出しをマクロ化してみる

ここまで来たら筋書きが見えてしまったかもしれないが続けよう。上述のbuhi-procとbuhi-funcはプログラムの構造が全く同じであることから、次のようなマクロによりソースコードを共通化できるのである。

; お手続き
(begin
  (define-syntax order
    (syntax-rules ()
      ((_ c ...)
       (begin c ...))))
  (define-syntax work
    (syntax-rules ()
      ((_ f ...)
       (f ...))))
  (define-syntax pass
    (syntax-rules ()
      ((_)
       (void))))
  (define-syntax repeat
    (syntax-rules ()
      ((_ f a ...)
       (for-each f a ...))))
  )

; お関数
(begin
  (define-syntax order
    (syntax-rules ()
      ((_ c ...)
       (list c ...))))
  (define-syntax work
    (syntax-rules ()
      ((_ f ...)
       (list f ...))))
  (define-syntax pass
    (syntax-rules ()
      ((_)
       '())))
  (define-syntax repeat
    (syntax-rules ()
      ((_ f a ...)
       (map f a ...))))
  )

; buhi-2way
(define (buhi l)
  (repeat (lambda (n)
            (order
              (work display n)
              (if (= (modulo n 3) 0)
                (work display " buhi")
                (pass))
              (work newline)))
    l))

これによりbuhiはテストでは関数となり、アプリケーションでは手続きとなる。テストとアプリケーションでファイル分けをしなければならないのが面倒だが。

テストの実際

こうして関数化された手続きが返すリストの構造は実装のロジックに依存している。テストの実際において同値性を検証するのに実装の内部を考慮しなければならないのはいかにも面倒である。

そのため以下のような関数で戻り値のリストを平滑化する。

(define (simplify-order order)
  (reverse
    (let loop [[l order]
               [r '()]]
      (if (null? l) r
        (let [[e (car l)]]
          (loop (cdr l)
            (cond
              ((null? e) r)
              ((and (pair? e)
                 (procedure? (car e)))
               (cons e r))
              (else
                (append (loop e '()) r)))))))))

かくてテストは以下のようになる。

(equal? (simplify-order (buhi '(3 4)))
  (order
    (work display 3)
    (work display " buhi")
    (work newline)
    (work display 4)
    (work newline)))

これは控えめに言ってもスマートであるし美しい。

おわりに

まあとは言え、この方法では戻り値のある手続きには対応できない。インプット・データ処理・アウトプットをきれいに分離し、異常系は例外で処理する必要があるだろう。

あービール飲みたい!

いじょう