やわらかテック

興味のあること。業務を通して得られた発見。個人的に試してみたことをアウトプットしています🍵

Rubyで演算子の順序を入れ替えるとエラーになるのはなぜか

先日、Rubyで文字列を指定数分だけ繰り返す処理を書いていた所、面白い現象に遭遇しました。
文字列を繰り返すために*を使ったのですが「文字列 * 数値」の場合はエラーが発生せずに望み通りの値が返るものの、順序を逆にして「数値 * 文字列」とするとエラーが発生します。

puts "s" * 5 # sssss

puts 5 * "s"
# Main.rb:3:in `*': String can't be coerced into Integer (TypeError)
#  from Main.rb:3:in `<main>'

どちらの呼び出し方でもエラーが発生せずに使えると思っていたのですが、想定違いでした。
自分の認識がおかしいのかな...と思いPython3で試してみたところ、どちらの呼び出し方でもエラーになりませんでした。

print("s" * 5) # sssss
print(5 * "s") # sssss

うーん、これはRuby固有の現象なのでしょうか。気になったので調べてみました。
※はてブの都合上、*があると斜体として認識されてしまうため以降、*をmultipleと表記します。

Stringのmultiple

self * times -> String
文字列の内容を times 回だけ繰り返した新しい文字列を作成して返します。

[PARAM] times:
整数

String#* (Ruby 3.2 リファレンスマニュアル)

Stringクラスが提供しているmultipleは引数に整数を指定しています。
先ほど「文字列 * 数値」の順序で実行した時にエラーが発生しなかったのは指定されている引数の型を満たしていたからなんですね。逆を言えば、Integerクラスが提供しているmultipleは文字列型を期待していないためにエラーが発生したんだろうと推測できます。

ruby/string.cのコードを見てみると、RubyのVALUE型の値が引数に期待されていることが分かります。
ただし、NUM2LONGはRubyのVALUE型オブジェクトをlong型に変換するためのものだそうで、数値に変換できない場合にエラーが発生します。

VALUE
rb_str_times(VALUE str, VALUE times)
{
    VALUE str2;
    long n, len;
    :
    /* ここでlong型に変換している */ 
    len = NUM2LONG(times);
    if (len < 0) {
        rb_raise(rb_eArgError, "negative argument");
    }
    :
}

ruby/string.c:2212

Integerのmultiple

self * other -> Numeric
算術演算子。積を計算します。

[PARAM] other:
二項演算の右側の引数(対象)

Integer#* (Ruby 3.2 リファレンスマニュアル)

こちらには期待している値の情報が明記されていませんでした。
二項演算の右側の引数とありますが何のことでしょうか。コードを見るのが一番早いのでruby/numeric.cを見てみると、こちらも同様にRubyのVALUE型を引数に期待しています。FixnumBignumについて今回は詳細には触れませんが、古いRubyのversionではIntegerはFixnumBignumの二つに分かれていたそうですが、現在ではIntegerに統合されています。

VALUE
rb_int_mul(VALUE x, VALUE y)
{
    if (FIXNUM_P(x)) {
        return fix_mul(x, y);
    }
    else if (RB_BIGNUM_TYPE_P(x)) {
        return rb_big_mul(x, y);
    }
    return rb_num_coerce_bin(x, y, '*');
}

動作確認はしていませんが、Integerの場合にはFIXNUM_P(x)が真となりfix_mulが呼び出されます。
つまり、Integerのmultipleも数値に変換できないRubyのVALUE型の値を受け渡すことによって、エラーが発生していたということです。

Pythonでエラーにならないのはなぜ

おそらくmultipleの実装に違いがあるからというのが答えでしょう。
RubyではStringとIntegerのmultipleがそれぞれ定義されており、どちらも引数に数値へ変換可能なRubyのVALUE型を期待していました。Pythonでも同じような実装がされているものの、引数を受け取った後の処理に違いがあり「文字列 * 数値」と「数値 * 文字列」のどちらであっても、同じ結果が返るようになっているのでしょう。

...と結論を出してしまうのはもったいないので調べてみました。
Pythonではmultipleはマジックメソッド__mul__を使用して実行されます。また__mul__での実行に失敗した場合にはスワップに対応したマジックメソッドの__rmul__が実行されるそうです。Pythonで演算子の順序を入れ替えてもエラーにならないのは、スワップして再度、トライする機構が備わっているからなんですね。

mul_ = (4).__mul__(5)
rmul_ = (4).__rmul__(5)

print(mul_) # 20
print(rmul_) # 20

3. Data model — Python 3.11.4 documentation

参考文献