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
を開発するうえで、以下のようないくつかの制約が考慮されています。
- 位置引数でもキーワード引数でも初期化方法を統一すること
- すべての引数をデフォルトで省略不可とすること
#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, 2
をx: 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
の場合はnew
とinitialize
の責任分担が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 evolutionのRuby 3.2向け更新は完了しています。 ↩
-
原注: この
initialize
メソッドはC言語で定義されており、Data
のすべての子孫クラスで汎用的に使える形になっているので、シグネチャはこの通りではありませんが、振る舞いとしてはこのようになります。 ↩
概要
原著者の許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。
参考: class
Data
(Ruby 3.2 リファレンスマニュアル)参考: class
Struct
(Ruby 3.2 リファレンスマニュアル)なお、
Data
もStruct
も、new
で位置引数とキーワード引数を"混ぜて"同時に渡すことは想定されていません(本記事にもそうした用例はありません)。位置引数かキーワード引数のどちらか1種類に統一して渡す必要があります。new(x:1, 2)
は、Data
とStruct
どちらの場合もSyntaxErrorになります。new(1, y:2)
は、Data
ではArgumentError、Struct
では#<struct MutPoint x=1, y={:y=>2}>
のように期待と異なる結果になります。Struct.new
でkeyword_init: true
オプションを指定するとArgumentErrorになります。