データ指向プログラミング (DOP) の実戦と雑感
まず、なんでもハッシュはよくないとされている
データ転送オブジェクト (DTO) としては便利だが、なんでもハッシュ[1]にしておくのは、機能を追加しずらかったりするのでよくないとされている。
したがって、こんなのは、
user = { first_name: "Taro", last_name: "Yamada" }
このように
class User
def initialize(first_name, last_name)
@first_name = first_name
@last_name = last_name
end
end
user = User.new("Taro", "Yamada")
なってくれてると安心する[2]。こうしておけばフルネームがほしいときも、そこに
class User
def full_name
"#{@first_name} #{@last_name}"
end
end
user.full_name # => "Taro Yamada"
メソッドを足せばよくなる。
ところが DOP では、そんなとこに full_name を生やしたら他のところから利用できないじゃないか、というのである。
コードをデータから切り離せ
DOP では、
user = { first_name: "Taro", last_name: "Yamada" }
def full_name(data)
"#{data[:first_name]} #{data[:last_name]}"
end
full_name(user) # => "Taro Yamada"
とするのが正解だそう[3]。
つまり、まずなんでもハッシュはよくないという考えからして逆だったという。
モジュールにしたり継承したりすればよくない?
他のところから full_name を使いたいなら共通メソッドをモジュールにして User に include するとか、User に親クラスを用意して full_name を定義するとか、単に User を継承するのでよくない? というのが OOP の言い分になると思うのだが、これに対して DOP の観点では、一括りに複雑なのはダメということだった。
たしかに複雑さは避けるべきだが、複雑と捉えるものの方向性が DOP と OOP では大きく異なっているようで、これではまともに対話が成り立たないようが気がする。
また DOP は継承を目の敵にしている感があり、OOP の主張はすべて話にならないのである。
DOP はテストが楽だという主張
OOP で full_name をテストするには、
user = User.new("foo", "bar")
user.full_name == "foo bar" # => true
のように書くことになる。
ところが DOP ではデータはハッシュなのでこのようにすぐ書ける。
user = { first_name: "foo", last_name: "bar" }
full_name(user) == "foo bar" # => true
full_name をテストするのに User クラスを持ち出すのは大袈裟で、そんなことしてるからテストのハードルが上がるんだ、とのこと。
full_name 関数に適合してないデータが渡る懸念
いやいやいや、そんなどこからでも full_name が呼べたら逆に危ないでしょ、という OOP 派のつっこみには、
require "json_schemer"
def full_name(user)
unless JSONSchemer.schema({
"type" => "object",
"properties" => {
"first_name" => {"type" => "string"},
"last_name" => {"type" => "string"},
},
"required" => ["first_name", "last_name"],
}).valid?(user)
raise ArgumentError, user.inspect
end
"#{user[:first_name]} #{user[:last_name]}"
end
full_name({}) rescue $! # => #<ArgumentError: {}>
としてデータが正しいが検証すればいいじゃん、とのこと。
データを汎用的なデータ構造で表せ
Rails で、
ActiveRecord::Schema.define do
create_table :posts do |t|
t.text :content
end
create_table :comments do |t|
t.belongs_to :post
t.text :content
end
end
のスキーマがあれば、
post = Post.create!(content: "本文")
post.comments.create!(content: "コメント")
が、頭に浮かぶ。
しかし DOP では次のように書く[4]。
post = { "content" => "本文" }.freeze
connection = ActiveRecord::Base.connection
connection.execute("INSERT INTO posts (content) VALUES ('#{post['content']}')")
post_id = connection.select_value("SELECT last_insert_rowid()") # => 1
post = post.merge("id" => post_id).freeze # => {"content"=>"本文", "id"=>1}
comment = {"content" => "コメント"}.freeze
post = post.merge(comments: [comment]).freeze
connection.execute("INSERT INTO comments (post_id, content) VALUES ('#{post['id']}', '#{comment['content']}')")
post_id = connection.select_value("SELECT last_insert_rowid()") # => 1
post = post.merge("id" => post_id).freeze # => {"content"=>"本文", "id"=>1, :comments=>[{"content"=>"コメント"}]}
汎用的なデータ構造で表すことでジェネリック関数が使えるのが利点だと言われている。
汎用的なデータ構造は便利なのか?
たしかに、たとえば単なる配列を操作するのは簡単だが、それをクラスでラップ[5]してしまうと、外から操作するのが難しくなって困る場合がある[6]。ただし、Ruby であれば Array クラスを継承するとか、each メソッドを定義することで、外に対して配列としてのインターフェイスを残すことができるので別に困らない。
困るのは JavaScript の場合で、今とりあえず動くものを作りたい(そしてあとのことはわりとどうでもいい)場合は、クラスでラップするよりも配列のままの方が都合がよいことが多い。実際に自分の(リファクタリングする気も起きなくなった) JavaScript のコードを見てみると第一引数に汎用的なデータを受け取って何かする関数だらけになっていた。したがって、汎用的なデータ構造が便利かどうかは言語に依存していて、特に JavaScript の場合はクラスでラップするメリットが少ないのと、lodash の恩恵を受けやすいからだと思われる。
書籍『データ指向プログラミング』でも「ただの配列にしておいたことでこんなに lodash が活用できるぞ!」という箇所が多く出てくる。
DOP はパフォーマンスが良いという主張
書籍『データ指向プログラミング』ではとくに触れられていないが、ネットを見ると「DOP ではデータが並んでいるので、ハードウェアのキャッシュに乗りやすく効率的である」という主張が見られる。これは個人的に……「ハードウェアに寄せた最適化」と「コードをデータから切り離す」という観点を分けてほしい。
データをブラックボックス化する点を悪と見なされてしまう OOP であっても、最適化によってパフォーマンスが大幅に向上する場合は、部分的にハードウェアに寄せた最適化をすることもあるでしょう。したがって、この点はたしかに DOP の利点かもしれないけど、それを含めて一緒に主張されると話がややこしくなるので分けて考えるべきだと思う。
書籍『データ指向プログラミング』のお話がなかなかよかった
この書籍は単なる DOP の解説ではなく全体を通して小説のような形式で書かれている。普通のWEBプログラマーである主人公が、想定外の要望を小出しにしてくる顧客に振り回される様子が他人事とは思えず、読んでいてなかなか楽しかった。
参照
Discussion