Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby 3.2のData#initializeがキーワード引数も位置引数も渡せる設計になった理由(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

参考: class Data (Ruby 3.2 リファレンスマニュアル)
参考: class Struct (Ruby 3.2 リファレンスマニュアル)


なお、DataStructも、newで位置引数とキーワード引数を"混ぜて"同時に渡すことは想定されていません(本記事にもそうした用例はありません)。位置引数かキーワード引数のどちらか1種類に統一して渡す必要があります。

  • new(x:1, 2)は、DataStructどちらの場合もSyntaxErrorになります。
  • new(1, y:2)は、DataではArgumentError、Structでは#<struct MutPoint x=1, y={:y=>2}>のように期待と異なる結果になります。
    • Struct.newkeyword_init: trueオプションを指定するとArgumentErrorになります。

Ruby 3.2のData#initializeがキーワード引数も位置引数も渡せる設計になった理由(翻訳)

私はハルキウ出身のウクライナ人Rubyistです。私の国ではまだ戦争が続いています。ロシアが2/24に全面侵攻を開始し、8年間におよぶハイブリッド戦争は継続しています。引き続き皆さんの支援を必要としています。どうかご寄付を、そして声を上げてください!

現在私は、Ruby evolutionサイトの内容を最新の3.2リリースを元にすべて更新する作業を進めています(今週か来週には終わりそうです1)。しかしその作業中に、新しいコアクラスでよく議論の的になるトピックがあり、説明用に小さな記事を書きたくなりました。

🔗 コーディングの幸せのためにコアクラスで決定された興味深い設計

Ruby 3.2では、シンプルでイミュータブルなValue Objectを使いやすいAPIで定義するDataクラスが新たに導入されました。

たとえば以下のように定義できます。

Point = Data.define(:x, :y)

p1 = Point.new(1, 2)       #=> #<data Point x=1, y=2>
# または
p2 = Point.new(x: 1, y: 2) #=> #<data Point x=1, y=2>

ここでクイズです。上のコード例でPoint#initializeのシグネチャはどんな形になると思いますか

「脊髄反射的な」回答は以下のような感じになるでしょう。

def initialize(*args, **kwargs)
  # ここで引数かキーワード引数かを決定して内部変数を設定する
end

残念、シグネチャは効果的に以下のような形になるので2、上のようにはなりません。

def initialize(x:, y:)
end

このことは、initializeを手動で再定義して、どんな引数が渡されるかをチェックすれば確かめられます。

Point = Data.define(:x, :y) do
  def initialize(...) # 任意の引数を受け取れるよう定義する
    p(...)            # 引数をそのまま全部出力する
    super
  end
end

p1 = Point.new(1, 2)        # prints {x: 1, y: 2}
p2 = Point.new(x: 1, y: 2)  # prints {x: 1, y: 2}

つまり、位置引数(positional arguments)がキーワード引数(keyword arguments)に変換されるのは#initializeに渡される"前"なのです!

🔗 どうしてそうなった?

この振る舞いはある意味予想外でしょう(既に公式バグトラッカーでもバグとして報告が何度も寄せられています)。しかしこの設計は、正当な理由に基づいているのです。

Dataを開発するうえで、以下のようないくつかの制約が考慮されています。

  1. 位置引数でもキーワード引数でも初期化方法を統一すること
  2. すべての引数をデフォルトで省略不可とすること
  3. #initializeを再定義して一部の引数にデフォルト値を提供したり、引数の保存前に前処理できれば便利なはずである

もっと具体的に説明するため、以下のようにDataから派生したPointで作業するところを想像してみましょう。

Point.new(1, 2)
#=> #<data Point x=1r, y=2r> (入力を有理数に変換する)
Point.new(1)
#=> #<data Point x=1r, y=0r> (`y`にデフォルト値を指定する)

"位置引数でもキーワード引数でも初期化を統一すること"を守ると、以下のようになります。

Point.new(x: 1, y: 2) #=> #<data Point x=1r, y=2r>
Point.new(x: 1)       #=> #<data Point x=1r, y=0r>

#initializeが位置引数もキーワード引数も受け取る必要がある場合、あなたならタスク1(デフォルト値付き)をどう実装しますか?

おそらく以下のような感じになるかもしれません。

def initialize(*args, **kwargs)
  # 1種類の引数だけが提供されるようにして再実装する
  raise ArgumentError unless args.empty? != kwargs.empty?
  if args.count == 1
    # 2番目の位置引数にデフォルト値を提供する
    args << 0
  else
    # さもなければ、キーワード引数にデフォルト値を提供する
    kwargs[:y] = 0 unless kwargs.key?(:y)
  end
  super(*args, **kwargs)
end

これではあまりに面倒で実装も素朴すぎるので、ほとんどの場合"投げやり気味に"1種類の引数についてだけ実装するのではないでしょうか。

Data#initializeの方法を使えば、再定義はこんなに簡単になります。

Point = Data.define(:x, :y) do
  # デフォルト値を提供し、しかもキーワード/位置どちらの場合でも受け取ってその先に渡す
  def initialize(x:, y: 0) = super(x:, y:)
end

# ちゃんと動いている
Point.new(1)           #=> #<data Point x=1, y=0>
Point.new(x: 1)        #=> #<data Point x=1, y=0>
Point.new(1, 2)        #=> #<data Point x=1, y=2>
Point.new(x: 1, y: 2)  #=> #<data Point x=1, y=2>

引数の変換についても同様です。

Point = Data.define(:x, :y) do
  def initialize(x:, y:) = super(x: x.to_r, y: y.to_r)
end

# 位置引数もキーワード引数も使える
Point.new(1, 2)
# => #<data Point x=(1/1), y=(2/1)>
Point.new(x: 1.5, y: 2.5)
# => #<data Point x=(3/2), y=(5/2)>

以上が理由のすべてです。

🔗 一体どうやって?

ここで戸惑う点の1つは「どうやったらそんなことができるの?」です。普通のRubyistなら「SomeClass.newの振る舞いはSomeClass#initializeメソッドで完全に定義済みである」、平たく言えば「.new#initializeを呼び出しているだけだ」と考えるでしょう(なお、名前の異なる.new#initializeという2つのメソッドがあるのは、Ruby内部のちょっとした"癖"に過ぎません)。

しかし私がRubyで愛している点は、中核となる少数の概念が一貫した形で相互作用している、つまり予測可能であることです。つまり、.newは単なるメソッドであり、デフォルトの実装は以下のように振る舞います。

class MyClass
  def self.new(...)
    obj = allocate      # 初期化されていないMyClassのインスタンスを作成
    obj.initialize(...) # initializeメソッドを呼び出し、.newが受け取ったすべての引数を渡す
    obj                 # アロケーション・初期化されたオブジェクトを返す
  end
end

しかし、.newはオブジェクトが初期化される前の時点で引数を前処理できない、などということはまったくありません。実は、引数の前処理は多くのコアクラスでも行われているのです。たとえばArray.newではこんなことができます。

Array.new(5) { _1**2 } #=> [0, 1, 4, 9, 16]

実装がどうであれ、Array.newは単にその引数を#initializeに渡しているだけの"ジェネレーター"メソッドに見えませんか?

つまり、仮にData.define(:x, :y)がシンプルなRubyクラスとして実装されているとしたら、以下のようなことができるでしょう。

class SimplePoint
  def self.new(*args, **kwargs)
    raise ArgumentError unless args.empty? != kwargs.empty?
    kwargs = {x: args[0], y: args[1]} if !args.empty?
    res = allocate
    res.send(:initialize, **kwargs)
    res
    # 最後の3行は"他のクラスと同様に以下だけでもいい
    #   super(**kwargs)
  end

  def initialize(x:, y:)
    @x = x
    @y = y
  end
end

SimplePoint.new(1, 2)       #=> #<SimplePoint @x=1, @y=2>
SimplePoint.new(x: 1, y: 2) #=> #<SimplePoint @x=1, @y=2>

ここで嬉しい点は、引数の統一に伴う複雑さのほとんどがData.newの内部で処理されることです。「#initialize は常にキーワード引数に統一された形で引数を受け取る」ということだけ覚えておけば済みます。

🔗 ただし少々"癖"があります

率直に申し上げると、この設計上の決定については少しばかり誇らしい気持ちがありますが、欠点がまったくないわけではありません。

🔗 1: #initializeの再定義は慎重に行う必要がある

以下は動きません。さらに悪いのは理解に苦しむ形で動かなくなってしまうことです。

Point = Data.define(:x, :y) do
  # 自分はどうせ位置引数しか使わないから
  # initializeの再定義はシンプルでも別にいいよね!
  def initialize(x, y)
    super(x.to_i, y.to_i)
  end
end

# 期待する結果:
Point.new(1, 2)       # => 動くはず、引数も変換されるはず
Point.new(x: 1, y: 2) # => たぶんArgumentErrorかな、使わないからいいけど!

# 現実の結果:
Point.new(1, 2)
# ArgumentError: wrong number of arguments (given 1, expected 2)

こうなった原因は、newが常に 1, 2x: 1, y: 2に変換し、"それを"initializeに渡そうとするからです。initializeはこれを受け入れる準備ができていません!

以下のような修正が考えられます。

# シグネチャのことはどうでもいい人向け:
# 上の例でこんなふうに引数を変換したいと思うだろう
Point = Data.define(:x, :y) do
  def initialize(x:, y:) = super(x: x.to_i, y: y.to_i)
end

Point.new(1, 2)       #=> #<data Point x=1, y=2>
Point.new(x: 1, y: 2) #=> #<data Point x=1, y=2>
# シグネチャをちゃんとしておきたい、かつ位置引数だけ受け取りたい人向け:
# ちょっと見苦しいけど動く
Point = Data.define(:x, :y) do
  def self.new(x, y) = allocate.tap { _1.send(:initialize, x:, y:) }
end

Point.new(1, 2)       #=> #<data Point x=1, y=2>
Point.new(x: 1, y: 2) #=> ArgumentError

後者の例を多用するのであれば、以下のように少し手を加えて見苦しさを軽減できます。

class Point < Data.define(:x, :y)
  # 実はデフォルトの実装を呼び出せば何とかなる!
  def self.new(x, y) = super
end

Point.new(1, 2)            #=> #<data Point x=1, y=2>
Point.new(x: 1, y: 2)
# in `new': wrong number of arguments (given 1, expected 2) (ArgumentError)

ここでData.defineは"無名の"データクラスを作成しており、その子孫クラスであるPointは親クラスの実装(Dataのデフォルト実装)を手軽に参照できます。

🔗 2: 定義されていないキーワード引数でも自由に渡せる

.newは、キーワード引数がいくつ渡されようとまったく気にかけません。.newは、あらゆるものをキーワード引数にひたすら変換しようとするだけで、残りはinitializeが引き受けます。

Point = Data.define(:x, :y)
Point.new(x: 1, y: 2, scale: 2)
# in `initialize': unknown keyword: :scale (ArgumentError)
# 例外が発生する場所は#initializeなので、
# その気になれば余分なキーワード引数もここで処理できる
Point = Data.define(:x, :y) do
  def initialize(x:, y:, scale: 1)
    super(x: x * scale, y: y * scale)
  end
end

Point.new(x: 1, y: 2, scale: 2) #=> #<data Point x=2, y=4>

🔗 3: ...しかし位置引数はそういうわけにいかない

Point = Data.define(:x, :y)

# これはinitializeの再定義だけでは対応できない
Point.new(1, 2, 3)
# in `new': wrong number of arguments (given 3, expected 0..2) (ArgumentError)

上のエラーメッセージにあるin 'new'にご注目ください。あらゆるものをキーワード引数に変換しようとする場所はこのnewなのです。余分な位置引数が渡されてしまうと、newのコードは位置引数がどのキーに所属するかを推測できません。

つまり、位置引数を余分に渡しても動くようにするには、上述の例と同様にnewを再定義するしかありません。

Point = Data.define(:x, :y) do
  def self.new(x, y, scale) = allocate.tap { _1.send(:initialize, x:, y:, scale:) }

  def initialize(x:, y:, scale: 1)
    super(x: x * scale, y: y * scale)
  end
end

Point.new(x, y, scale)Point.new(x: ..., y: ..., scale: ...)を両方とも再定義するのは相当面倒ではありますが。

🔗 最後に: Structの動作は異なる

1つ興味深い点を述べておきたいと思います。Ruby 3.2のStructについても、以下のようにキーワード引数と位置引数のどちらでも初期化が可能になりました(ちなみにkeyword_init:パラメータはキーワード引数と位置引数のどちらかだけを使うことを指定する用途のために残されています)。

MutPoint = Struct.new(:x, :y)
MutPoint.new(1, 2)       #=> #<struct MutPoint x=1, y=2>
MutPoint.new(x: 1, y: 2) #=> #<struct MutPoint x=1, y=2>

ただしStructの場合はnewinitializeの責任分担がDataの場合と異なります。これは、以下のように#initializeを再定義して引数をすべて出力してみるとわかります。

MutPoint = Struct.new(:x, :y) do
  def initialize(...) # 任意の個数の引数を受け取れるよう定義
    p(...)            # すべての引数をそのまま出力
    super
  end
end

p1 = MutPoint.new(1, 2)        # 出力: 1, 2
p2 = MutPoint.new(x: 1, y: 2)  # 出力: {x: 1, y: 2}

Structにおける設計上の決定は、Dataの長所と短所が入れ替わった形になっています。一見しただけではそれほど戸惑いませんが、上述の規約を維持したまま#initializeを再定義しようとすると面倒なことになります。これは「たまたま」そうなったのですが、それなりの理由があるのです。

🔗 1: Structでは代入の"後で"後処理が可能

Structのインスタンスはミュータブルであり、省略できない引数はありません。つまり、後でどうにでもできるのです!

MutPoint = Struct.new(:x, :y) do
  def initialize(...) # 任意の個数の引数を受け取れるよう定義
    super
    self.y ||= 0
    self.x = x.to_i
    self.y = y.to_i
  end
end

MutPoint.new('1')     #=> #<struct MutPoint x=1, y=0>

Dataは引数を省略できないので、この手が使えません。既に初期化されたオブジェクトに属性を設定する手段もありません。

🔗 2: Structの他のソリューションでは後方互換性が失われる可能性がある

キーワード引数の初期化をまったく考慮せず、古い振る舞いに依存した以下のようなコードが世の中には山ほどあります...

Word = Struct.new(:word, :sentence_id) do
  def initialize(word, sentence)
    super(word.strip, sentence.id)
  end
end

上のコードはRuby 3.2より前なら完全に動きますし、今後も動き続けるはずです(誰かがWord.newにキーワード引数を渡そうとするとおかしなことにはなりますが)。

しかしDataを使えば、Structよりもクリーンで定義も小さく、それでいて有用なAPIをゼロから導入できます。

だからそうしたのです。

関連記事

Ruby 3.2の新機能Data.defineについて(翻訳)

Ruby: メソッド引数のデフォルト値で遊んでみた(翻訳)


  1. 訳注: Ruby evolutionのRuby 3.2向け更新は完了しています。 
  2. 原注: このinitializeメソッドはC言語で定義されており、Dataのすべての子孫クラスで汎用的に使える形になっているので、シグネチャはこの通りではありませんが、振る舞いとしてはこのようになります。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。