2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【Ruby】String#[]= について「すごい」と思ったので、予想を混ぜつつ調べた過程をまとめた

Last updated at Posted at 2023-04-01

はじめに

この記事には筆者の予想が多く含まれます。何か確実な根拠をもとに結論が出せておらず、最後が予想になっているのでご了承の上ご覧ください。

yukiと申します。Rubyを実務で使い始めてそろそろ半年になりそうです。これまではSalesforce(Apex)などを扱っていました。

突然ですが、Rubyはこんなことができてすごいです。

irb
irb(main):008:0> hoge = "index"
=> "index"
irb(main):009:0> hoge[1] = "N"
=> "N"

irb(main):010:0> hoge
=> "iNdex"

公式のリファレンスマニュアルによると...

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

self[nth] = val
nth 番目の文字を文字列 val で置き換えます。
[PARAM] nth:
置き換えたい文字の位置を指定します。
[PARAM] val:
置き換える文字列を指定します。
[RETURN]
val を返します。

文字列も配列のように添字を指定することで、該当する文字を得ることができるみたいですね。

配列に対して[1]で値が取れるのは個人的に納得できるのですが、なぜ文字列に対してこのようなことができるのでしょうか。
その謎を解明すべく我々はアマゾンの奥地へと向かった ———

大前提

"hello"[1]を実行した際にどのような処理が行われているのか調査してみます⛰

String#[]=を実行した時に呼び出されているもの

ここからは申し訳ないですが、推測も含みます。できる限り調べてみたのですが、間違い、勘違い、その他の言い間違いなど、気になる部分がありましたらご教示お願いいたします。

そもそも、def []なんて直接の定義はされていないだろうと思ったので、エイリアスを探してみました。

その結果、String.cファイルの下記の箇所で定義されていそうでした。

String.#L11984
    rb_define_method(rb_cString, "[]", rb_str_aref_m, -1);

cには疎いですがおそらく関数名を見るにrb_define_methodがcとrubyの関数の定義をまとめておくような関数でrb_str_aref_mが実体でしょう。

rb_str_aref_mを調べてみます

String.c
static VALUE
rb_str_aref_m(int argc, VALUE *argv, VALUE str)
{
    if (argc == 2) {
        if (RB_TYPE_P(argv[0], T_REGEXP)) {
            return rb_str_subpat(str, argv[0], argv[1]);
        }
        else {
            long beg = NUM2LONG(argv[0]);
            long len = NUM2LONG(argv[1]);
            return rb_str_substr(str, beg, len);
        }
    }
    rb_check_arity(argc, 1, 2);
    return rb_str_aref(str, argv[0]);
}

難しそうです。頑張って読み解いてみます。

argcというのは恐らくargument countですから、引数の数を表しています。

String#[]は公式ドキュメントを見てもわかるように、引数を2つまで取ることができます。

self[nth, len] -> String | nil[permalink][rdoc][edit]
slice(nth, len) -> String | nil
nth 文字目から長さ len 文字の部分文字列を新しく作って返します。 nth が負の場合は文字列の末尾から数えます。

if (argc == 2) {というのは、"hello"[1]と呼び出された場合と"hello"[1,2]のように引数2つで呼び出された場合とで場合分けしているのだと思いました。

深く実装を調べるべきですが、今回はhello[1]のように引数1つのパターンを追いたいので一旦横に置かせてもらい、最後にreturnされているrb_str_aref(str, argv[0]);について更に調べてみます。

rb_str_arefを調べてみます

コードで言うと以下の部分でした。

String.c
static VALUE
rb_str_aref(VALUE str, VALUE indx)
{
    long idx;

    if (FIXNUM_P(indx)) {
        idx = FIX2LONG(indx);
    }
    else if (RB_TYPE_P(indx, T_REGEXP)) {
        return rb_str_subpat(str, indx, INT2FIX(0));
    }
    else if (RB_TYPE_P(indx, T_STRING)) {
        if (rb_str_index(str, indx, 0) != -1)
            return str_duplicate(rb_cString, indx);
        return Qnil;
    }
    else {
        /* check if indx is Range */
        long beg, len = str_strlen(str, NULL);
        switch (rb_range_beg_len(indx, &beg, &len, len, 0)) {
          case Qfalse:
            break;
          case Qnil:
            return Qnil;
          default:
            return rb_str_substr(str, beg, len);
        }
        idx = NUM2LONG(indx);
    }

    return str_substr(str, idx, 1, FALSE);
}

またまた難しいですが、"hello"[1]の場合の正体はこれに違いないです。

さまざまな条件分岐をしていますが、indexについて何かしらの判定をしています。

    else if (RB_TYPE_P(indx, T_REGEXP)) {
        return rb_str_subpat(str, indx, INT2FIX(0));
    }
    else if (RB_TYPE_P(indx, T_STRING)) {
        if (rb_str_index(str, indx, 0) != -1)
            return str_duplicate(rb_cString, indx);
        return Qnil;
    }

この辺を見てみると、引数に正規表現が与えられているかとか、Stringが与えられているかとかでしょうか。

試しにやってみます。

irb(main):023:0> "hello"["1"]
=> nil

irb(main):027:0> "hello"[/a/]
=> nil

思った通り、例外は起こらずにnilが返ってきました。

    else if (RB_TYPE_P(indx, T_STRING)) {
        if (rb_str_index(str, indx, 0) != -1)
            return str_duplicate(rb_cString, indx);
        return Qnil; // ここ
    }

つまり、ここに入ったんじゃないかなぁと予想しつつ、一旦追うのをやめました。

問題なく数値が与えられた場合、return str_substr(str, idx, 1, FALSE);に辿り着きそうなので、更に読み進めていきます。

str_substrを調べてみます

String.c
static VALUE
str_substr(VALUE str, long beg, long len, int empty)
{
    char *p = rb_str_subpos(str, beg, &len);

    if (!p) return Qnil;
    if (!len && !empty) return Qnil;

    beg = p - RSTRING_PTR(str);

    VALUE str2 = str_subseq(str, beg, len);
    rb_enc_cr_str_copy_for_substr(str2, str);
    return str2;
}

一見頑張れば読めそうですが、また3つも関数を呼んでいますね。。

rb_str_subposを用いて*pを定義し、そこからRSTRING_PTR(str);を引いてbegを定義しているようです。

begを調査してみると、一般にbeginの略称らしいので、今回の場合「指定したindexが、文字全体のどこから始まるのか」を指しているのではないかと思いました。つまり、"hello"[1]の時の1が「文字全体で見た時に2文字目」と言う意味です。この予想を横に置きつつ、読み進めてみます。

    if (!p) return Qnil;
    if (!len && !empty) return Qnil;

ここに関しては、生成されたpや指定がなかった場合にnilを返しているのではないかなと考えました。
例えば、irbで呼び出す時も文字がないindexを指定するとnilが返ります。

irb(main):026:0> "hello"[5]
=> nil

では、最初に呼ばれている関数であるrb_str_subposを見てみましょう。

rb_str_subposを読んでみます

場所

長すぎたのでコードは省略します。今の自分にとっては難しいです。

正直、何やってるか全くわからなかったので、rb_str_subposを普通に調べてみることにしました。
が、思ったように説明してくれている記事も見つけられなかったので、AIに聞いてみることにします。

prompt
以下のコードはRubyに含まれるcのコードです。
何をしているか教えてください。
(コードペタリ)

実務で扱うコードは貼らないようにしましょう。

読みやすいように適宜改行を入れています。

GPT-3.5
rb_str_subposは、Rubyの文字列オブジェクトの中から指定されたサブ文字列が最初に現れる位置を見つける関数です。

この関数は、

Rubyの文字列オブジェクト str、
開始位置 beg、
および出力するサブ文字列の長さのポインタ lenp を受け取ります。

rb_str_subpos 関数の実装は、文字列のエンコーディングを処理するために多くの分岐が含まれています。

最初に、サブ文字列の長さが負である場合は、すぐに 0 を返します。

次に、文字列が単一バイトの場合は、指定された開始位置と長さからサブ文字列を単純に抽出できます。

文字列が複数のバイトで構成される場合は、指定された開始位置を処理するためにいくつかのエンコーディング特有の処理が行われます。

最後に、サブ文字列のポインタと長さが設定された後、ポインタが返されます。

この関数は、Rubyの文字列のサブ文字列を抽出するために内部的に使用されています。

Rubyの文字列オブジェクトの中から指定されたサブ文字列が最初に現れる位置ということなので、"hello"[1]のように指定した際のehelloのどこに現れているかのポインタを返しているのでしょうか。

ちなみに、サブ文字列と表現されていますが、調べてみると部分文字列ともいうらしいです。(そちらの方が一般的?)
Hello Worldの時のHelloのように文字列の一部の文字列を指すみたいですね。
helloeも部分文字列です。

今回調査しているString#[]が、まさにその部分文字列を生成してくれているので、およそあっていそうです。

今回のAIの回答をおよそあっていると仮定して調査を進めます。

beg = p - RSTRING_PTR(str);について考えてみます

*pには先ほどの部分文字列のポインタが入るようです。

beg = p - RSTRING_PTR(str);に目を向けてみると差分を計算しているので、おそらくRSTRING_PTR(str);の戻り値もポインタでしょう。

RSTRING_PTRを調べてみると、大変ありがたいことにリファレンスがありました。

引数 str の表す文字列のポインタの先頭を返します。

macro RSTRING_PTR (Ruby 3.2 リファレンスマニュアル)

シンプルですね。

つまり、beg = p - RSTRING_PTR(str);部分文字列のポインタから元の文字列のポインタの先頭の差分を求めていることになります。beginという意味からも推察できるように、サブ文字列が元の文字列の何文字目から始まるかを表しているのではないでしょうか。

改めてコードを見てみましょう。

String.c
static VALUE
str_substr(VALUE str, long beg, long len, int empty)
{
    char *p = rb_str_subpos(str, beg, &len);

    if (!p) return Qnil;
    if (!len && !empty) return Qnil;

    beg = p - RSTRING_PTR(str);

    VALUE str2 = str_subseq(str, beg, len);
    rb_enc_cr_str_copy_for_substr(str2, str);
    return str2;
}

もう少しです。次は2つ目のstr_subseq(str, beg, len)を見てみましょう。

String.c
static VALUE
str_subseq(VALUE str, long beg, long len)
{
    VALUE str2;

    const long rstring_embed_capa_max = ((sizeof(struct RString) - offsetof(struct RString, as.embed.ary)) / sizeof(char)) - 1;

    if (!SHARABLE_SUBSTRING_P(beg, len, RSTRING_LEN(str)) ||
            len <= rstring_embed_capa_max) {
        str2 = rb_str_new(RSTRING_PTR(str) + beg, len);
        RB_GC_GUARD(str);
    }
    else {
        str2 = str_new_shared(rb_cString, str);
        ENC_CODERANGE_CLEAR(str2);
        RSTRING(str2)->as.heap.ptr += beg;
        if (RSTRING(str2)->as.heap.len > len) {
            RSTRING(str2)->as.heap.len = len;
        }
    }

    return str2;
}

日が暮れてしまうので、こちらに関してもAIの力を借りましょう。

GPT-3.5
str_subseq は、Ruby言語の文字列オブジェクトからサブ文字列を取り出す関数です。

引数に取った文字列オブジェクトから、指定された位置と長さに対応する部分文字列を抽出して
新しい文字列オブジェクトとして返します。

つまり今回の場合は、helloから部分文字列の開始地点を定め、そこから指定の長さ文字を取り出すのでしょう。今回は1文字分しか指定していないので、eが返されて終了です。


ここから、この章のおまけです。

rb_str_arefの実装を読んだときに、return str_substr(str, idx, 1, FALSE);という感じでlenに1を指定していたので「1文字分」なのでしょう。
ということは、多分この関数はrb_str_aref_mでも呼ばれてるんじゃないかなぁ...。

途中まで調べた結果分かったのですが、この関数は約半年前の2022/9にてリファクタリングされた結果、定義されたようでした。
Refactor str_substr and str_subseq · ruby/ruby@aa2a428

ここまでおまけ


では、1つ前のstr_substrで最後に呼ばれていたrb_enc_cr_str_copy_for_substr(str2, str);を最後に見てみましょう。

rb_enc_cr_str_copy_for_substrを読んでみる

String.c
static void
rb_enc_cr_str_copy_for_substr(VALUE dest, VALUE src)
{
    /* this function is designed for copying encoding and coderange
     * from src to new string "dest" which is made from the part of src.
     */
    str_enc_copy(dest, src);
    if (RSTRING_LEN(dest) == 0) {
        if (!rb_enc_asciicompat(STR_ENC_GET(src)))
            ENC_CODERANGE_SET(dest, ENC_CODERANGE_VALID);
        else
            ENC_CODERANGE_SET(dest, ENC_CODERANGE_7BIT);
        return;
    }
    switch (ENC_CODERANGE(src)) {
      case ENC_CODERANGE_7BIT:
        ENC_CODERANGE_SET(dest, ENC_CODERANGE_7BIT);
        break;
      case ENC_CODERANGE_VALID:
        if (!rb_enc_asciicompat(STR_ENC_GET(src)) ||
            search_nonascii(RSTRING_PTR(dest), RSTRING_END(dest)))
            ENC_CODERANGE_SET(dest, ENC_CODERANGE_VALID);
        else
            ENC_CODERANGE_SET(dest, ENC_CODERANGE_7BIT);
        break;
      default:
        break;
    }
}

冒頭のdocを読んでみると

this function is designed for copying encoding and coderange from src to new string "dest" which is made from the part of src.

この関数は、srcからエンコーディングとコードレンジを、新しいdestというsrcの一部から作られた文字列にコピーするために作成されました。という感じでしょうか。

エンコーディングは知っていますが、コードレンジという言葉を知らなかったので調べてみました。

コード範囲。文字コードで、コードして割り当てられている範囲をいう。
例えば日本語で一般的な符号であるシフトJISの場合、
1バイト目: 8/1〜9/15、14/0〜15/12
2バイト目: 4/0〜7/14、8/0〜5/12
の範囲となり、これをコードレンジという。

コードレンジ ‐ 通信用語の基礎知識から引用させていただきました。

難しいですね。コードして、というのはエンコーディングをしてという意味でしょうか。

ChatGPTにも聞いてみました。

GPT-3
コードレンジとは、エンコーディングされた文字列の中で、どの範囲のバイト列が有効な文字列であるかを示す情報のことです。
具体的には、UTF-8エンコーディングの場合、1バイト文字、2バイト文字、3バイト文字、4バイト文字など、どのバイト列が正しい文字であるかを示します。

例えば、ASCIIコード範囲(0x00〜0x7F)のバイト列であれば、どのエンコーディングでも有効な文字列であるため、7bit範囲のコードレンジとされます。
一方、UTF-8で4バイト文字を表現するためのバイト列(0xF0 0x9F 0x8E 0xB0)は、UTF-8以外のエンコーディングでは無効なバイト列であるため、INVALID(無効)のコードレンジとされます。

このように、コードレンジはエンコーディングの正しさを判断する上で重要な情報となります。

なるほど、つまりこの関数では、UTF-8などのエンコードの情報と、コードレンジというどの範囲のバイト列が有効な文字列であるかを、新しく生み出された文字列(今回の場合はhelloの中から取り出したe)に設定しているのでしょう。

ChatGPTの説明が正しいか検証できる実力がなくて申し訳ない限りですが、一旦そういうことをしている関数として認識します。

結論(あくまで予想)

  1. 内部的にString.cの様々な関数を呼び出して処理をしている
  2. 引数の数に応じてrb_str_arefrb_str_aref_mで処理が分かれる
  3. rb_str_arefの場合、引数に与えられた数字を使い、元の文字列から部分文字列のポインタを決める(あくまでもこの時点での部分文字列はポインタを決めるもの)
  4. 元の文字列のポインタも決めて、3の部分文字列とのポインタを使って「最終的に出力する部分文字列」を作る
  5. エンコードとコードレンジを新しい文字列にセットする

一旦、このような結論(予想)とさせてください。

エンコーディングやコードレンジを扱っているところから、"hello"[1]ではなく"技術"[1]とか"あいうえお"[1]などを実行すると、内部的にまた何か処理が変わるんじゃないか?と思いました。

感想

Cで書かれているコードがあまり理解できなかったというか、そもそも読めないのも実力不足を感じました。ChatGPTからの回答も正しいものか検証できていないので良くないと思います。

一方で、やってみてポジティブなこともありました。

  • 仮説を立てて実装を調査できた
  • Rubyの実装を読んで理解を深めることができた
    • Rubyすごい。本当にすごい。リスペクト。開発している皆さんもリスペクト
  • ChatGPTを使って調査をある程度効率化できた

などなどです。最初から、String#[]の実装ってどうなってるの?と聞いてみれば教えてもらえたかも知れませんが、今回は調査のためやめておきました。また後で試してみようと思います。皆さんも是非試してみてください。

ここまで読んでいただきありがとうございました。
引き続き精進します。

2
1
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?