🍄️

データ指向プログラミング (DOP) の実戦と雑感

2024/11/21に公開

まず、なんでもハッシュはよくないとされている

データ転送オブジェクト (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 で 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プログラマーである主人公が、想定外の要望を小出しにしてくる顧客に振り回される様子が他人事とは思えず、読んでいてなかなか楽しかった。

参照

https://www.amazon.co.jp/dp/4798179795

脚注
  1. Rubyアンチパターン: なんでもHash ↩︎

  2. 最近の Ruby では似たようなことをするのに User = Data.define(:first_name, :last_name) と書けたりする ↩︎

  3. 正確には user をイミュータブルにしておく ↩︎

  4. さすがにもうちょっと ActiveRecord の機能を活用していいと思うが、書籍『データ指向プログラミング』では INSERT 文を自力で書いているのでそれに合わせた ↩︎

  5. デザインパターンでいうところの First Class Collection ↩︎

  6. 外から操作できないのが利点であることの方が多いので本当は困らないのだが話の流れ的に困ることにする ↩︎

Discussion