delhi09の勉強日記

技術トピック専用のブログです。自分用のメモ書きの投稿が多いです。あくまで「勉強日記」なので記事の内容は鵜呑みにしないでください。

Rubyの「=で終わるメソッド」が代入風に書けるのは言語仕様なのか?

課題

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 # 私の名前は田中です

このような書き方が可能なのは、次のどちらの理由によるものなのか気になったので調べてみました。

  1. Rubyではメソッド名に=が使えるため、これは単なるRuby文化的な慣習にすぎない
  2. Rubyの言語仕様としてメソッドの末尾が=の場合が特別にサポートされている

結論

答えは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 # 私の名前は田中です

つまり、Rubyp.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パーサーのソースコードを読んだほうがいいそうです。

github.com

参考記事

gihyo.jp gihyo.jp

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_writePM_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=というメソッド呼び出しとして扱う」という専用処理を持っていることが確認できました。