CodeGym /コース /C++ SELF /関数からの参照の返却

関数からの参照の返却

C++ SELF
レベル 40, レッスン 1
使用可能

1. はじめに

初めて「関数が参照を返す」というフレーズを聞いた学生は、しばしばそれが「モニタ越しに変数をテレポートするような魔法」だと本気で思います。実際はもっと地味です:参照を返すというのは、新しいオブジェクトを返すのではなく、既に存在するオブジェクトへのアクセスを返すということです。つまり「もう一つの名前を返す」だけです。

値を返す(T)というのは、誰かに書類のコピーを渡すようなものです(あるいはムーブする場合もありますが、ここでは「別個の実体」というモデルが重要です)。参照を返す(T& または const T&)というのは、元の書類が保管されているアーカイブへの通行証を渡すようなものです。通行証は便利ですが、アーカイブが燃えてしまえば(オブジェクトが破棄されれば)、通行証だけが残り、意味へのアクセスは失われます。

参照を返すもっとも一般的な理由は:

  1. 大きなオブジェクト(例えば std::string や std::vector)をコピーしたくない、
  2. 呼び出し元に「どこか外にある特定のオブジェクト」を変更する能力を与えたい、

ここで危険領域が始まります:参照はオブジェクトを所有しません。ただ指し示すだけです:「ここにあるよ」と。もし「ここにある」が消えると、参照はぶら下がったもの(viсhing reference)になります。そうなるとプログラムは宝くじになります。

2. 戻り型としての T& と const T& の意味

戻り型が参照であることは非常に具体的な意味を持ちます。それは構文の話ではなく、関数の作者と利用者の間の約束ごとに関する話です。

もしあなたが T& と書くなら、実質的にこう宣言していることになります:「関数は、関数から抜けた後でも確実に存在するオブジェクトへの参照を返し、呼び出し元はその参照を通じてオブジェクトを変更できる」。

もしあなたが const T& と書くなら、こう宣言していることになります:「関数は、関数から抜けた後でも確実に存在するオブジェクトへの参照を返すが、その参照を通じて変更することはできず、読むことだけが許される」。

どちらの場合も鍵となるフレーズは「オブジェクトは関数終了後も確実に存在する」です。コンパイラはあなたが嘘をついていないかを証明してくれるわけではありません(警告を出すことはありますが、保証はありません)。したがって参照の返却は、プログラマがライフタイムのモデルを常に意識しているべき箇所です。

C++ 標準化でも「参照に束縛された一時オブジェクトの寿命」や「full-expression and temporaries bound to references」に関する議論や修正が別途あります — 話題は非常に繊細です。これはあなたが「馬鹿だから」ではなく、言語が歴史的に複雑だからです。

ライフタイムに関する実践ルール

関数が参照を返す場合、その参照が指すオブジェクトはその関数の内部で生成されたものであってはいけません。あるいは非常に長い寿命(例えば static)を持つか、呼び出し元のコードに属していて参照やポインタで渡されている必要があります。

そうでなければほぼ確実に「ぶら下がった参照」(dangling reference) を作ります:参照は残るがオブジェクトは存在しません。

抽象的な話を続けるより実例を見た方がいいので、学習用の簡単なコンソールプロジェクトを続けましょう。簡単なタスクトラッカー(タスクの一覧)を想定し、タスクは std::vector に保存されているとします。

3. ミニデータベース TaskTracker:Task モデルとタスクコンテナ

例が空中の「球面上的な参照」に見えないように、最小限のデータモデルを用意します。プロジェクトのどこかに次のような構造があると想像してください:

#include <string>

struct Task {
    int id = 0;
    std::string title;
    bool done = false;
};

そして main() ではタスクを次のように保持します:

#include <vector>

int main() {
    std::vector<Task> tasks;
    // ...
}

今日の講義の焦点は:タスクを見つけて参照を返す関数を書き、安全に行う方法を示すことです。

4. 参照を安全に返す方法

パラメータの参照を返す

これは参照を返す最も「クリーンな」シナリオです。関数はオブジェクトを生成せず、呼び出し元から受け取ったオブジェクトを参照として返します。場合によってはチェックや選択を行った後に返します。

例:「二つの数のうち大きい方を参照で返す」関数。奇妙に聞こえますが古典的なデモです。

#include <iostream>

int& max_ref(int& a, int& b) {
    return (a > b) ? a : b;
}

int main() {
    int x = 10;
    int y = 20;

    max_ref(x, y) = 999;
    std::cout << "x=" << x << " y=" << y << "\n"; // x=10 y=999
}

なぜ安全なのか?それは x と y が main() で作られており、少なくとも main() の終了までは生存するからです。関数 max_ref は新しいオブジェクトを作らず、既存のどちらかを選んで返すだけです。

重要な注意点:この関数は一時値を引数に取れません。なぜなら int& は一時オブジェクトに結びつかないからです。これは良いことで、コンパイラが参照を返してはいけない「一瞬しか存在しないもの」を防いでくれます。

外部から渡されたコンテナの要素への参照を返す

ここでタスクトラッカーに近づきます。id でタスクを見つけて、そのタスクへの参照を返し、呼び出し元で done や title を変更できるようにしたい、というケースです。

タスクが確実に存在すると仮定して Task& を返す関数を作るとします。これは重要な契約です:もし「確実に存在する」なら問題ありませんが、存在しない可能性があるなら別の設計が必要です。

以下は「教育的に危険な」スケッチです(こうするべきではありませんが、問題を見せるために有用です):

#include <vector>

Task& find_task_by_id(std::vector<Task>& tasks, int id) {
    for (Task& t : tasks) {
        if (t.id == id) return t;
    }
    return tasks.front(); // 教育用の応急処置:実際にはやるべきでない
}

なぜ参照の返却は概念的に安全になり得るのか?それは t が tasks の内部要素への参照であり、tasks が呼び出し元で生きているからです。関数は外部オブジェクトの一部への参照を返しています。

しかしこのコードが悪い理由は何か?要素が見つからない場合に「何か別のもの」を返してしまう点であり、これは契約違反の論理的エラーになります:間違ったタスクを書き換えてしまう可能性があります。これは参照の問題というより契約の問題です。

タスクが存在しない可能性があるなら、正直な選択肢はポインタ(Task*)を返し、nullable の契約を nullptr で示すことです:

#include <vector>

Task* find_task_ptr(std::vector<Task>& tasks, int id) {
    for (Task& t : tasks) {
        if (t.id == id) return &t;
    }
    return nullptr;
}

使用例:

#include <iostream>
#include <vector>

int main() {
    std::vector<Task> tasks{{1, "Read C++", false}, {2, "Drink tea", false}};

    if (Task* t = find_task_ptr(tasks, 2)) {
        t->done = true;
    }

    std::cout << tasks[1].done << "\n"; // 1
}

ここで重要なのは:我々は「参照の返却」について話しているのにポインタに話題が移ったことです。これは問題ありません:要点は単純で、「T& を返すべきなのは、関数がオブジェクトの存在を保証できるときだけ」です。

もし本当に Task& を返したいなら、契約を厳しくして「見つからなければプログラムを終了する」とするのも一つの手です。これは荒っぽいですが明確です(学習コードでは許容されます):

#include <iostream>
#include <vector>

Task& find_task_or_die(std::vector<Task>& tasks, int id) {
    for (Task& t : tasks) {
        if (t.id == id) return t;
    }
    std::cout << "Task not found, id=" << id << "\n";
    std::exit(1);
}

使用例:

#include <iostream>
#include <vector>

int main() {
    std::vector<Task> tasks{{1, "Read C++", false}, {2, "Drink tea", false}};

    Task& t = find_task_or_die(tasks, 2);
    t.done = true;

    std::cout << tasks[1].done << "\n"; // 1
}

このアプローチは主目的を果たします:曖昧で危険な状態を「厳しく、しかし明確な契約」に変えることです。

注意:std::vector の要素への参照は「腐る」可能性がある

std::vector の要素への参照を返すときは注意してください:ベクタが「構造的に」変更されなければ参照は有効ですが、要素の追加や削除、erase、メモリ再割当を伴う push_back 等を行うと参照は無効化されることがあります。

ここで無効化の細かい表を説明するつもりはありません(それは別トピックで大きな話題です)が、教訓は簡単です:コンテナの要素への参照は「永久的な乗車券」ではなく、「管理局が移転するまで有効な通行証」に過ぎない、ということです。

したがってコンテナの要素への参照を返すのは、安全なのは呼び出し元がそれを永遠に保存せず、その後でコンテナを変更しないことが保証されている場合だけです。

static オブジェクトへの参照を返す

プログラムの実行期間中ずっと存在する共有オブジェクトを返したいことがあります。関数内で static 変数を作れば、それは一度初期化されプログラム終了まで生存します。

例:アプリケーション名(定数文字列)。

#include <string>

const std::string& app_name() {
    static const std::string name = "TaskTracker";
    return name;
}

使用例:

#include <iostream>
#include <string>

int main() {
    std::cout << app_name() << "\n"; // TaskTracker
}

なぜ安全なのか?それは name が関数のスタック上ではなく静的記憶域に存在するため、app_name() から抜けても消えないからです。

ただし static を多用するのは避けるべきです。static はグローバル状態なので便利ですが乱用するとプログラム全体が密に結びつき、デバッグが難しくなります。しかし技術的な手段として「長く生きるオブジェクトへの参照を返す」には正当な選択肢です。

5. アンチパターン:どうやってぶら下がった参照を作るか

ローカル変数への参照を返す

これが古典です。もしこの講義で一つだけ覚えるなら、これにしてください。

関数のローカル変数はスタック上に存在します。関数が終了するとそのスタックフレームは破棄されます。したがってローカルオブジェクトへの参照を返してはいけません。

int& bad() {
    int x = 123;
    return x; // 禁断の魔術: x は bad() の終了後に消える
}

たとえコンパイラが警告を出してくれても(多くの場合は警告します)、警告を無視したり抑制したりすると「ときどき動くがときどき 0 を出す、あるいはクラッシュする」といった不可解な振る舞いに遭遇します。

これがぶら下がった参照の典型例です:参照は残っているがオブジェクトは存在しません。挙動は決して予測可能ではありません。

return で作られた一時オブジェクトへの const T&

ここはよくある落とし穴です:確かに const 参照は一時オブジェクトに結びつけることができます。しかし重要な点は「一時オブジェクトの寿命延長」はその一時がローカルな参照変数に直接束縛される範囲内でのみ機能するということです。return 文で参照を外へ返すと、そのローカル参照がもはや存在しないため延長は起きません。

つまり以下のコードは悪い例です:

#include <string>

const std::string& bad_title() {
    return std::string("temporary"); // 一時文字列は return 式の終了で消える
}

この場合の正しい選択肢は通常、値(std::string)を返すことです(それは許容される設計です)、あるいは長く生きるオブジェクト(例えば static な定数)への const& を返すことです。

ローカルなコンテナの要素への参照を返す

これは「もっと賢く見える」が原因は同じです:コンテナがローカルであれば関数終了とともに死に、要素も消えます。

#include <vector>

int& bad_elem() {
    std::vector<int> v{10, 20, 30};
    return v[1]; // v は終了時に死ぬ、要素も死ぬ、参照だけが残る
}

このようなコードを書きたくなったら、ほとんどの場合の正解は:値を返す、もしくはコンテナを呼び出し元から渡す、またはコンテナの寿命を延ばす(例えばグローバル/静的に保持する。ただしそれ自体が別の設計上の責任を伴う)ことです。

6. 関数から何を返すべきか:チートシート

選択肢を決めるための小さなチートシートを用意しましょう。脳に引っかかる指標になります。

型で伝えたいこと 返すもの 契約上の意味
「新しい結果を渡す、元のオブジェクトとは独立」
T
コピー/値。自立して存在する。
「結果は既存のオブジェクトで、必須かつこれからも生存する」
T&
外部オブジェクトへのエイリアス。変更可能。
「結果は既存のオブジェクトで、必須かつこれからも生存するが変更不可」
const T&
読み取り専用のエイリアス。
「結果が存在しない可能性がある」
T*
Nullable 契約:nullptr をチェックすること。

重要:学生がしばしば誤解するのは「値で返すのは常に遅い」という点です。現代の C++ では std::string や場合によっては std::vector を値で返すことは非常に普通で読みやすい設計です。参照を返すのは「最適化」ではなく意味の変更です:あなたは外部オブジェクトへのアクセスを返しています。

7. TaskTracker の実践:タスクを見つけて完了にする

簡単なシナリオを作りましょう:ユーザが id を入力し、そのタスクを完了済みにマークします。

バリアント A:「タスクは必ず存在する」— Task& を返す。

#include <iostream>
#include <vector>

Task& find_task_or_die(std::vector<Task>& tasks, int id) {
    for (Task& t : tasks) if (t.id == id) return t;
    std::cout << "No task with id=" << id << "\n";
    std::exit(1);
}

int main() {
    std::vector<Task> tasks{{1, "Read C++", false}, {2, "Drink tea", false}};
    find_task_or_die(tasks, 2).done = true;
    std::cout << tasks[1].done << "\n"; // 1
}

この場合の利点は連鎖的な操作です:見つけてすぐフィールドを変更でき、可読性が高いことです。

バリアント B:「タスクが存在しないことがある」— Task* を返す。

#include <iostream>
#include <vector>

Task* find_task_ptr(std::vector<Task>& tasks, int id) {
    for (Task& t : tasks) if (t.id == id) return &t;
    return nullptr;
}

int main() {
    std::vector<Task> tasks{{1, "Read C++", false}, {2, "Drink tea", false}};

    if (Task* t = find_task_ptr(tasks, 42)) {
        t->done = true;
    } else {
        std::cout << "Nothing to mark\n"; // Nothing to mark
    }
}

これも正直な設計です:型 Task* が存在しない可能性を思い出させ、チェックを促します。

8. ライフタイムのミニ図解

直感だけに頼らないように、スタックの簡単な図を頭に描くのは有用です。

想像してください:

const std::string& f() {
    std::string s = "hello";
    return s; // 悪い
}

ライフタイムのモデル(非常に大雑把):

main():
  f() を呼ぶ
    f():
      [stack frame f]
      s = "hello"   <-- ここで生きている
      return s      <-- "アドレス" を外に持ち出す
    f() 終了:
      stack frame f が破棄される
      s が破棄される  <-- オブジェクトが存在しない
  返された参照は「過去のどこか」を指す

これがぶら下がった参照が厄介な理由です:すぐには壊れないかもしれません。ある環境では動き、別の環境ではクラッシュします。Release ビルドだけで壊れることもあります。あるいは「上司が近づく金曜日にだけ壊れる」という最も神秘的なバグにも遭遇します。

9. 参照を返すときの典型的なミス

ミスその1:ローカル変数への T& を返すこと。
これが最も頻繁で最も危険なミスです:ローカル変数は関数終了時に消えるので、参照だけが残ります。たとえ一時的に動いても、それは単なる幸運な一致に過ぎません。

ミスその2:一時オブジェクトに対する const T& を return すること。
「const 参照は一時に結びつけられる」という知識が落とし穴になります:返却時には寿命延長が適用されず、一時オブジェクトは速やかに消え、参照はぶら下がります。

ミスその3:ローカルコンテナの要素への参照を返すこと。
コンテナ(例えば std::vector)自体がオブジェクトであり、ローカルであれば終了時に消えます。したがって v[i] への参照を返すのは同じアンチパターンです。

ミスその4:存在しないかもしれないオブジェクトへの T& を返すこと。
例えば「id でタスクを探す」場合です。タスクが存在しない可能性があるなら、T& は契約を偽るか、適当なダミータスクを返すか、プログラムを強制終了させるしかなくなります。多くの場合は T* と nullptr の組み合わせで存在しない場合を明確に伝える方が適切です。

ミスその5:返された std::vector の要素参照を保存し、その後でベクタを変更すること。
返却時点では参照が有効でも、その後の要素追加や削除で内部メモリが移動すると参照は無効になります。簡単なコードでは短命の参照パターンを使うこと:受け取ってすぐ操作し、長期間保存しないのが安全です。

1
タスク
C++ SELF, レベル 40, レッスン 1
ロック未解除
最大レバー
最大レバー
1
タスク
C++ SELF, レベル 40, レッスン 1
ロック未解除
チャンネル切替
チャンネル切替
1
タスク
C++ SELF, レベル 40, レッスン 1
ロック未解除
エラーなしのアクセス
エラーなしのアクセス
1
タスク
C++ SELF, レベル 40, レッスン 1
ロック未解除
生存する参照
生存する参照
コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION