課題
Rubyでは、フィールド名+=というメソッド名を定義することで、インスタンス変数への代入のような表現ができます。
以下のサンプルコードでいうと、def name=(name)がそれにあたります。
class Person def initialize(name) @name = name end def name=(name) @name = name end def introduce puts "私の名前は#{@name}です" end end p = Person.new("山田") p.introduce # 私の名前は山田です p.name = "田中" p.introduce # 私の名前は田中です
このような書き方が可能なのは、次のどちらの理由によるものなのか気になったので調べてみました。
結論
答えは2.でした。Rubyのパーサーには、末尾が=で終わるメソッド専用の処理ロジックが組み込まれていました。
推論
最終的にはRubyのパーサーのコードを確認して裏付けを取りましたが、挙動の観察だけでも2.の可能性が高いと推測できます。
p.name=("田中")の場合
メソッド名がname=なので、当然ながら次のようにも書けます。
# 省略 p.name=("田中") p.introduce # 私の名前は田中です
p.name= "田中"の場合
Rubyではメソッド呼び出しのかっこを省略できるため、次のように書いても動作します。
# 省略 p.name= "田中" p.introduce # 私の名前は田中です
ここまでは、1.と2.のどちらの仮説でも説明がつきます。
p.name = "田中"の場合
しかし、p.nameと=の間にスペースが入るこの書き方も動作します。
# 省略 p.name = "田中" p.introduce # 私の名前は田中です
つまり、Rubyはp.name =をp.name=として解釈していることになります。
Rubyには「メソッド名の途中にスペースが入っても自動で補完する」といった仕様は存在しないため、この挙動は通常のメソッド呼び出しルールでは説明できません。
たとえば、name=メソッドの末尾を?に書き換えて同様のことを試してみると、syntax error, unexpected tIDENTIFIER, expecting ':'というエラーになります。
class Person # 省略 def name?(name) @name = name end # 省略 end # 省略 p.name ? "田中" p.introduce # 私の名前は田中です
このことから、Rubyには末尾が=で終わるメソッドには特別な構文ルールが存在すると推測できます。
裏付け
裏を取るためにChatGPTにナビゲーションしてもらいながら、Rubyのパーサーのソースコードを読んでみました。
ChatGPTによると、Ruby本体のリポジトリに同封されているパーサーよりもRuby3.4からデフォルトになったPrismパーサーのソースコードを読んだほうがいいそうです。
参考記事
src/prism.c内のparse_write関数を見ると、以下のような実装が確認できます。
parse_write(pm_parser_t *parser, pm_node_t *target, pm_token_t *operator, pm_node_t *value) { // 省略 switch (PM_NODE_TYPE(target)) { // 省略 case PM_CALL_NODE: { // 省略 if (char_is_identifier_start(parser, call->message_loc.start, parser->end - call->message_loc.start)) { // When we get here, we have a method call, because it was // previously marked as a method call but now we have an =. This // looks like: // // foo.bar = 1 // // When it was parsed in the prefix position, foo.bar was seen as a // method call with no arguments. Now we have an =, so we know it's // a method call with an argument. In this case we will create the // arguments node, parse the argument, and add it to the list. pm_arguments_node_t *arguments = pm_arguments_node_create(parser); call->arguments = arguments; pm_arguments_node_arguments_append(arguments, value); call->base.location.end = arguments->base.location.end; parse_write_name(parser, &call->name); pm_node_flag_set((pm_node_t *) call, PM_CALL_NODE_FLAGS_ATTRIBUTE_WRITE | pm_implicit_array_write_flags(value, PM_CALL_NODE_FLAGS_IMPLICIT_ARRAY)); return (pm_node_t *) call; } } // 省略 } // 省略 } }
私はパーサーのコードを読み慣れてない & C言語に疎いのでロジックの詳細は分かりませんが、コードコメントからこのブロックが末尾が=で終わるメソッド専用のロジックであることが伺えます。
さらに、parse_writeがPM_CALL_NODEのブロックで呼び出しているparse_write_nameを読むと、メソッド名の末尾に=を追加する処理をやっていそうです。
parse_write_name(pm_parser_t *parser, pm_constant_id_t *name_field) { // The method name needs to change. If we previously had // foo, we now need foo=. In this case we'll allocate a new // owned string, copy the previous method name in, and // append an =. pm_constant_t *constant = pm_constant_pool_id_to_constant(&parser->constant_pool, *name_field); size_t length = constant->length; uint8_t *name = xcalloc(length + 1, sizeof(uint8_t)); if (name == NULL) return; memcpy(name, constant->start, length); name[length] = '='; // Now switch the name to the new string. // This silences clang analyzer warning about leak of memory pointed by `name`. // NOLINTNEXTLINE(clang-analyzer-*) *name_field = pm_constant_pool_insert_owned(&parser->constant_pool, name, length + 1); }
以上より、Rubyのパーサーは「foo.bar = 1のような式を見つけたら、bar=というメソッド呼び出しとして扱う」という専用処理を持っていることが確認できました。