Hatena::ブログ(Diary)

keigoiの日記

2012-06-16

[] 実務で使うOCaml - 泥臭い仕事をサクっとこなす方法

プログラマが実務で出会うのは、問題が整理されたキレイな仕事ばかりじゃない。プロダクトに本質的じゃない部分でもプログラムを書く必要に迫られる。いわゆる開発方法論では抽象化されてしまう、今ここにいるソフトウェア開発者の悩みだ。

今日は、私が仕事で書いたOCamlのコードを晒して、如何にOCamlプログラマ仕事の道具として優れているかを主張したい。泥臭く、関数的でない、エレガントさのかけらもない、生活臭のあるコードだ。勤務先はOCamlをメイン言語として使っている。研究所とかではなく普通に受託開発を生業としている会社だ。OCamlは理論一辺倒で、マニア向けで、現実のソフトウェア開発には使えない、という誤解が万が一あるかもしれないが、全くそんなことはない。 (Haskellもそうだけど、それはまたの機会に)

いかにOCamlが優れているかについての概論めいた話は、OCamlを数十人体制で10年使っているJane Street Capitalの Yaron Minskyによる なぜ次に学ぶ言語は関数型であるべきか をナナメ読みすればいいと思う。特に「なぜOCamlなのか?」のあたり。これはACM Queueという立派な雑誌の記事だし、もちろん熟読するに越したことはないと思う。

追記:キャミバ様 (@camloeba)にコードを改善していただけました! ↓のコードはザックリすぎてイマイチでしたが、面白い批評付きで読み易くなっています。

状況: 金融業のメッセージ通信プロトコル on TCP/IP

今回のコードを書くに至った状況について書きます。

指定した銘柄の株価を時系列で取得するソフトウェアを書くお仕事だった(*ちょっとぼかしてある*)。ここで問題になったのは、通信処理の実装だ。

プロトコルは上流側により定義されたTCP/IP上に作られた独自のもの。繋ぎっぱなしのストリームに、メッセージと呼ばれる単位のかたまりが、いくつも連番が振られて流れてくるようになっている。この基本プロトコルの上で、株価の銘柄コードと、ある日付範囲を指定してリクエストすると、レスポンスとして株価の系列が複数のメッセージに載って返送されてくる。あまりインターネット時代を感じさせるプロトコルではないけれど、これがリアルな金融業界の通信プロトコルのひとつなのだ。

説明のため、A社の1日から4日までの株価を取得するリクエストを

要求: ("A",1,4)

と書き、レスポンス(メッセージの列)は

応答:[1100; 1098; 1081; 1120]

のように書くようにする。

泥臭い問題

調べていくうちに、プロトコルアプリ層で欲しい機能が欠けていることがわかった。 というのは、レスポンスとリクエストを対応づけるIDがなかったのだ。

これは大きな問題だった。受け取ったメッセージがどのリクエストに対応するレスポンスのものか、すぐにはわからないのだ。 複数のリクエストを同時に投げると、複数のレスポンスの系列がストリーム中でインターリーブ(混ざりながら)しながら返送されてしまう。

要求の列:[("A",1,4); ("B",2,3)]
応答の列:[1100; 1098; 910; 1081; 940; 1120]
(* 1100, 1098, 1081, 1120 は A社の株価, 910, 940 は B社の株価 *)

なんということだ…

幸い、レスポンスのヘッダにはリクエストされた銘柄コードと日付範囲が入っている。

具体的には、上の応答の列は、ヘッダを含めると、次のような感じになる。 (ここでa=("A",1,4), b=("B",2,3)と略記します)

応答の列:[(a,1100); (a,1098); (b,910); (a,1081); (b,940); (a,1120)]

しかし、依然として、同じリクエストを2回投げた場合にはキーがかぶってしまう。

こんなプログラムを書きました

次の OCaml関数 receive_quote は、金曜の昼間に一息で書いたコードだけど、それなりにまともに動いている。

ポイントは、

  • コードが比較的短い。
  • バッファがやや複雑な構造をしているにも関わらず、実行時エラーになるようなヘマをやらかしていない
  • 引数の型をほとんど書いていない (実際、無くてもよい)

ざっくり書いただけのコードだし、RubyPythonならこれよりちょっとだけ少ない分量で書けるかもしれないけど、実行時エラーに悩まされることになるかもしれない。私ならたぶんそうなる。しかしOCamlなら、コンパイル時にほとんどのエラーが検出されるし、実際、コンパイル成功後は意図通り動作しているので、週末は仕事を忘れて遊んで暮らせる。

コードに入る前にもっと詳細なことを書くと、各メッセージには「連番」と「リクエスト全体のメッセージ数」が振られている。

応答の列:[(a,1100,0,4); (a,1098,1,4); (b,910,0,2); (a,1081,2,4); (b,940,1,2); (a,1120,3,4)]

といった感じだ。

OCamlをよく知らない人のためにたくさんコメントを書いたけれど、普段はこんなに詳細に書いたりしない。コメントを外したバージョンは http://ideone.com/aWBKX にある。あれ? 結構くどく書いてあるな…

type request = string * string * string (* リクエスト. 銘柄id,期間from,期間toの三つ組 *)
;;
let code (c,_,_) = c

type quote = { price:int; } (* 銘柄の値段.実際には、他にも多くの情報が含まれている *)
;;

type response = {
    req: request; (* どのリクエストに対するレスポンスか *)
    body: quote; (* レスポンスの内容 *)
    seq:int; (* レスポンス中、何番目のメッセージか *)
    total:int; (* 応答メッセージの総数 *)
  }
;;

(* 受信途中のレスポンスのバッファを次の連想テーブル(Map)で構成する。
   OCamlのMapは、キーの型と比較関数をMap.Makeに渡せば得られる *)
module M = Map.Make (
    struct 
      type t = request (* リクエスト型をキーとして *)
      let compare = Pervasives.compare (* 何か適当に比較してもらう *)
    end)
;;

(* レスポンスを保持するバッファ。 一息で書いたのでやたら複雑な型だが、
   リクエストをキー(上記)として、受信途中のレスポンスをペア (int * quote option array) で保持する。
   このペアは (受信済みメッセージ数, 銘柄データの配列) という意味だ。
   - 未受信のメッセージをNoneで埋めておきたいがため option 型の配列になっている。
   - 同じキーをもつ複数のレスポンスを同時に扱う格納するため、リストにしてある。
   - グローバル変数なのは醜いけれど、クロージャに入れればすぐになんとかなる
*)
let buf : (int * quote option array) list M.t ref = ref M.empty
;;

(* 株価のリストをデータベースに保存する. ダミー *)
let save (code:string) (quotes:quote option list) : unit = ()
;;

(*
   メッセージrをバッファに格納する。 レスポンス全体が得られたら、saveで保存する。
*)
let receive_quote (r:response) : unit  =
  if r.total=1 then save (code r.req) [Some r.body] (* 総レコードが1件のみであればすぐに保存 *) 
  else begin
    let map = !buf in
    (* このリクエストに対するレスポンスのリスト(更新前) *)
    let all = try M.find r.req map with Not_found -> [] in

    (* バッファに新しいレスポンスを追加 *)
    let newentry () =
      let arr = Array.make r.total None in (* レスポンス全体をNoneで初期化 *)
      arr.(r.seq) <- Some r.body;
      buf := M.add r.req ((1, arr)::all) map (* 1つ受信しましたよとバッファを更新 *)
    in

    (* レスポンスのリストを走査し適切な位置に格納 / レスポンス全体が満たされたら保存 *)
    let rec addentry rest = function
      | [] -> newentry () (* 受信ずみレスポンスなし. 新しいレスポンスを追加する *)
      | (cnt, arr) as y::ys -> (* 受信ずみレスポンスあり *)
        match arr.(r.seq) with (* 当該の連番は受信済みか *)
        | None ->
            arr.(r.seq) <- Some r.body; (* レスポンスの該当する連番を満たす *)
            if cnt+1=r.total then begin
              (* レスポンス全体が満たされた. 保存後、この配列をバッファから除く *)
              save (code r.req) (Array.to_list arr) (* 保存 *);
              buf := M.add r.req (List.rev_append rest ys) map (* yの削除によりバッファを更新 *)
            end else
              (* また足りない.受信済み数をインクリメントしバッファを更新 *)
              buf := M.add r.req (List.rev_append rest ((cnt+1, arr)::ys)) map
        | Some q -> (* このレスポンスでは格納済み. 次のレスポンスを見る *) 
            addentry (y::rest) ys
    in 
    addentry [] all
  end
;;

簡単な動作確認

プログラム全体は http://ideone.com/6tUYa にある。

次のように入力を与えることができる。 Aのが2つ,Bのが1つ、レスポンスが来た状況だ。

let _ = 
  let a="A","1","4" and b = "B","1","2" in
  let resps = [
   {req=a; body={price=1100}; total=4; seq=0};
   {req=a; body={price=1098}; total=4; seq=1};
   {req=a; body={price=1100}; total=4; seq=0};
   {req=b; body={price=910}; total=2; seq=0};
   {req=a; body={price=1098}; total=4; seq=1};
   {req=a; body={price=1081}; total=4; seq=2};
   {req=a; body={price=1081}; total=4; seq=2};
   {req=a; body={price=1120}; total=4; seq=3};
   {req=b; body={price=940}; total=2; seq=1};
   {req=a; body={price=1120}; total=4; seq=3};
  ] in
  List.iter receive_quote resps

save関数を、画面にプリントするようにしてあるので、次のような出力が得られる:

A 1100;1098;1081;1120;
B 910;940;
A 1100;1098;1081;1120;

結論

同じコードをJavaで書こうと思っていたけれど、ちょっと大変そうでそんな気が起きないのでそれは誰か物好きに任せたい。

十中八九、OCamlのほうがJavaより保守性が高く見やすいコードになるはずで、それは上のコードをもっとエンタープライズ的に(log4j的なものににログを出すとか、アサーションを埋め込むとか、例外的状況に対処するコードを入れるとかで)書き加えても変わらないだろう。

ここまで書いておいて何だけれど、OCamlでうまくプログラムを書く方法とかはよくわからない。 ただ、上の場合は、

  • データ構造を先に決めた。レスポンスのキーで引くためにMapが必要だな、で、ランダムアクセスなので配列をここに、キーが被るのでリストにして…破壊的に更新されるバッファだからrefに、という風に。
  • そして関数本体を、上で決めたデータ構造をなめるように書いた。

という手順だったように思う。 いつでも使えるわけではないけれど、とりあえず型を決めて、あとはガリガリコードを書いて、型エラーをつぶしていけば、いつのまにか問題は解けている。

OCamlは、何も概念的に難しいわけではない。関数プログラミングで実行効率を高めるためのちょっとしたイディオムはあるかもしれない(上のコードでいうrestとList.rev_appendとか)けど、基本的にはスッキリとした簡潔な言語だし、なんといっても多相性と完全な型推論が嬉しい。

また上の副作用バリバリの例を見ればわかるが、実のところ、OCaml関数型プログラミングにこだわる必要はないのである。というか、caml.inria.fr/ocaml には関数型なんて言葉は一つもでてこない。

Haskellの純粋関数的なフレーバーとモナドの話は知的好奇心をくすぐるし、FRPのように根本的な解決策は夢を与えるし、見た目もエレガントだ。一方、OCamlはあくまでもC++,Java,RubyPythonのような既存のプログラミング言語の延長線上にあるものだ。手続き的・オブジェクト指向プログラムの書き方もできる。しかしそれに加えて、型によるバグの発見が非常にパワフルで、また関数的(immutableなデータ構造や高階関数を使った)プログラミングを基礎に設計されているため何かとスッキリしている言語なのだ。

バグ、余談

上の例には、Mapに空リストが残ったままになるというバグがある。ヘビーに使っているとメモリリークが顕在化するかも。

余談だけれど、実のところ ベースのプロトコルはもっと複雑で、大半はC++で書かれている。 我々のアプリOCamlで書かれており、上記のようなglue codeを書いたというわけ。

camlspottercamlspotter 2012/06/18 11:57 レビュー欲しそうだったので大人気ないかもしれないですが https://gist.github.com/2946567 に書いておきます。
もちろん、人様の書いたコードを元にしているので、ほんとにお前はやっつけ仕事でこう書くのかよ!という突っ込みはありますが、
私はやっつけでもお金扱ってると普段はこれくらいのレベルで書くかな。

keigoikeigoi 2012/06/18 14:02 確かにその通り! ありがとうございます。
また OCaml の紹介としてみてもザックリのコードではあまりよくないですし、妙に読み辛いコードばかり書いてると思われてもアレですね。(そもそも人様にレビューしてもらうならちゃんと書けという話も)

dratinidratini 2014/04/27 09:59 OCaml(かHaskell)の習得が今年の目標なので興味深い記事でした。
文中で言及のあったPython版のやっつけコードを

http://ideone.com/SFwqpn

に置いておきます。
OCamlが読めないので自信はないのですが、要件は満たせていると思います。

スパム対策のためのダミーです。もし見えても何も入力しないでください
ゲスト


画像認証

トラックバック - http://d.hatena.ne.jp/keigoi/20120616/1339783819
リンク元