やっつめ

Clojureで「プロトコル」というものを使う

『プログラミング Clojure 第2版』を読んでるんだけど、なかなか難しい。

今回は、p.145『6.3 プロトコル』のところを読んでるんだけど、本に記述ミスが
あったこともあり、読むのにすごく時間がかかった。
(サンプルファイルも同様にミスがあった)

とりあえず、名前空間を以下のように設定する。
また、必要なパッケージを読み込む

(ns examples.io
  (:import (java.io FileInputStream InputStreamReader BufferedReader)))

例として以下のようなコマンドを考えてみる。
ファイルの内容を1文字ずつ読み込むというコマンドである。
gulp というのは、「飲み込む」という意味。
(同書 p.142)

(defn gulp [src]
  (let [sb (StringBuilder.)] 
    (with-open [reader (-> src
                           FileInputStream.
                           InputStreamReader.
                           BufferedReader.)] 
      (loop [c (.read reader)]
        (if (neg? c)
          (str sb)
          (do
            (.append sb (char c))
            (recur (.read reader))))))))

(let [sb (StringBuilder.)])
String sb = new StringBuilder() というのをこのように書ける。

with-open — オープンのあとリソースを開放してくれる(closeしてくれる)

(loop [c (.read reader)]
1文字ずつ読み込む
.read は BufferedReader. のメソッド

(if (neg? c)
neg? — ゼロより小さい場合は true
この場合は、入力が最後に達したということ

(str sb) — StringBuilderクラスにおける toStringメソッド

(.append sb (char c))
.append — javaのStringBuilderのメソッド
(char c) — 整数値c のキャラクター文字を返す
たとえば (char 65) は \A である。

この gulp のコードを java で書いてみた。

Gulp.java
import java.io.FileInputStream;
import java.io.InputStreamReader;
import java.io.BufferedReader;

import java.io.IOException;

public class Gulp {
    public Gulp () {}

    public String read (String filename) {
        StringBuilder sb = new StringBuilder();
        try {
            FileInputStream fis = new FileInputStream( filename );
            InputStreamReader isr = new InputStreamReader( fis );
            BufferedReader reader = new BufferedReader( isr );

            int c = reader.read();
            while (c != -1) {
                sb.append( (char)c );
                c = reader.read();
            }
            reader.close();
        } catch (IOException e) {
            e.printStackTrace();
            System.out.println("ファイルを開けませんでした。");
        }
        return sb.toString();
    }
}

こんな感じになるかと思う。

上の gulp はファイルを入出力の対象としているが、これを様々な インプットデバイスに対応するように書き換えてみる。

まず、手始めに、様々なソースを読み込む meke-reader を定義する。

入力元として ファイル(java.io.File もしくは文字列によるファイル名)、socket、URL、あるいは既に InputStream である場合をサポートする。
(同書 p.144)

(defn make-reader [src]
  (-> (condp = (type src)
        java.io.InputStream src
        java.lang.String (FileInputStream. src)
        java.io.File (FileInputStream. src)
        java.net.Socket (.getInputStream src)
        java.net.URL (if (= "file" (.getProtocol src))
                       (-> src .getPath FileInputStream.)
                       (.openStream src)))
      InputStreamReader.
      BufferedReader.))

この make-reader を使って gulp を書き換える。
(同書 p.143)

(defn gulp [src]
  (let [sb (StringBuilder.)] 
    (with-open [reader (make-reader src)]
      (loop [c (.read reader)]
        (if (neg? c)
          (str sb) 
          (do
            (.append sb (char c))
            (recur (.read reader))))))))

make-reader は、新しい入力を追加するとなると、書き直す必要がある。

インターフェースはそれを実装する場合、クラスを書き換える必要がある。

プロトコルは、既存のデータ型に新たなインターフェースへの実装を追加できる。

IOFactory というプロトロルを定義する。
(同書 p.146

(defprotocol IOFactory
  "A protocol for things that can be read from and written to."
  (make-reader [this] "Creates a BufferedReader."))
(extend-protocol IOFactory
  InputStream
  (make-reader [src]
    (-> src InputStreamReader. BufferedReader.))

  File
  (make-reader [src]
    (make-reader (FileInputStream. src)))

  Socket
  (make-reader [src]
    (make-reader (.getInputStream src)))

  URL
  (make-reader [src]
    (make-reader
     (if (= "file" (.getProtocol src))
       (-> src .getPath FileInputStream.)
       (.openStream src))))

このコードでは、上記 gulp を実行するとエラーが出る。

(gulp "abc.txt")
Execution error (IllegalArgumentException) at examples.io/eval5933$fn$G (my-protocol.clj:37).
No implementation of method: :make-reader of protocol: #'examples.io/IOFactory found for class: java.lang.String

このエラーの意味がわからなかった。

要するに、java.lang.String のクラスにあるIOFactoryのメソッドの実装がない
というような意味かな。

Clojureのエラーメッセージは、ちょっとわかりにくいなあ。

抜けている部分を追加したコードは、これ。

(extend-protocol IOFactory
  InputStream
  (make-reader [src]
    (-> src InputStreamReader. BufferedReader.))

  String                                    ;; <=== <1>
  (make-reader [src]                        ;; この3行が
    (make-reader (FileInputStream. src)))   ;; 抜けていた

  File
  (make-reader [src]
    (make-reader (FileInputStream. src)))

  Socket
  (make-reader [src]
    (make-reader (.getInputStream src)))

  URL
  (make-reader [src]
    (make-reader
     (if (= "file" (.getProtocol src))
       (-> src .getPath FileInputStream.)
       (.openStream src))))

最初の gulp のコードでは、
(defn make-reader [src] … ) のところで、

java.lang.String (FileInputStream. src)

とやってる。それが抜けてたのか。

まとめ

javaのインターフェースは、ケースに応じて処理を変えたいときなどに使えるけど、
インターフェースを実装するためには、実装するクラスの内容を書き換えなければ
ならない。

Clojureのプロトコルは、既存のコードを変更せずに、必要な処理を既存の関数に
つけたすことができる。
いかにも 関数型言語らしいアプローチだと思った。