プログラミング PR

言語が変われば挙動も変わる!参照渡し・値渡しに潜む思わぬ落とし穴

記事内に商品プロモーションを含む場合があります

以前、以下の趣旨の記事を投稿したところ、何人かの方々から問い合わせを受けました。

pythonは値渡し
Pythonは「値渡し」です!「参照渡し」という誤解はなぜ生じるのか?Pythonを学び始めた時、ネットを調べて混乱したことがありました。 それは「Pythonは参照渡し」と書かれているサイトが多かっ...

その際に述べた主旨は、Pythonでは関数に渡されるのは「オブジェクトへの参照」であり、決して「参照渡し」ではない、というものでした。

問い合わせの内容は次のようなものでした。

  • 「”参照の値渡し”は結局、”参照渡し”では?」
  • 「関数内でオブジェクトが変われば、呼び出し元の値が変わるなら、それは参照渡しでは?」

これについて、私の理解では「関数内で「=」を使って変数に別のオブジェクトを代入し、呼び出し元の変数もその新しいオブジェクトで変更される場合」「参照渡し」と考えています。例えば、VB.NETのByRefやC#のrefキーワード付きの挙動がこれに該当します。

ただ、私が本当に伝えたかったのは、言葉の定義そのものではなく、複数のプログラミング言語を使う際に生じる“挙動の違い”です。

特定の言語のみを使っている場合には問題にならないかもしれませんが、他の言語でも同じようなコードを書いた際に異なる挙動が発生することで、予期せぬ不具合が発生することが多いためです。(実際に何度もそういうケースを見てきました。)

そこで本記事では、言語ごとの挙動の違いをわかりやすくするために、まず柔軟性の高いC++を使って様々なパターンを例示し、次にC#とPythonと比較することで、引数を渡した際の挙動とその影響の違いを段階的に解説していきたいと思います。

C++コードサンプル

前提

属性として名前(str)、年齢(int)、子供(Person)を持つ以下のようなPersonクラスを作成します。このPersonクラスのオブジェクトを関数に渡して内部で操作した時に呼び出し元にどのような影響が出るかを検証します。

#include <iostream>
#include <string>
#include <memory>
#include <iomanip>

class Person {
private:
    std::string name;
    int age;
    std::shared_ptr<Person> child;

public:
    Person(std::string n, int a) : name(n), age(a), child(nullptr) {}
    Person(std::string n, int a, std::shared_ptr<Person> c) : name(n), age(a), child(c) {}

    void setName(std::string n) { name = n; }
    void setAge(int a) { age = a; }
    void setChild(std::shared_ptr<Person> c) { child = c; }

    std::string getName() const { return name; }
    int getAge() const { return age; }
    std::shared_ptr<Person> getChild() const { return child; }

    void display() const {
        std::cout << "オブジェクトID: " << std::hex << std::setw(12) << std::setfill('0') << reinterpret_cast<uintptr_t>(this) << std::dec
                  << ", 名前: " << name << ", 年齢: " << age;
        if (child) {
            std::cout << ", 子供: " << child->getName() << " (" << child->getAge() << "歳)"
                      << ", 子供のオブジェクトID: " << std::hex << std::setw(12) << std::setfill('0') << reinterpret_cast<uintptr_t>(child.get()) << std::dec;
        }
        std::cout << std::endl;
    }
};

 

パターン① 値渡し+属性を変更

まずは、通常の値渡しを行い、関数内でオブジェクトの属性を変更するパターンについて説明します。
(宣言、クラスのコードは上記重複するので割愛します。)

void functest(Person p) {
    std::cout << "functest内:" << std::endl;
    p.setName("田中太郎");
    p.setAge(30);
    p.setChild(std::make_shared<Person>("田中花子", 5));
    
    p.display();      
}

int main() {

    auto child = std::make_shared<Person>("佐藤太郎", 3);
    Person person("佐藤一郎", 25, child);

    std::cout << "main内(functest呼び出し前):" << std::endl;
    person.display();

    // functestにオブジェクトを渡す
    functest(person);

    std::cout << "main内(functest呼び出し後):" << std::endl;
    person.display();

    return 0;
}

 

結果は以下の通りです。

main内(functest呼び出し前):
オブジェクトID: ffffee8dc290, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 000002e32ec0
functest内:
オブジェクトID: ffffee8dc238, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 000002e33f20
main内(functest呼び出し後):
オブジェクトID: ffffee8dc290, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 000002e32ec0

 

この場合、関数内での変更は呼び出し元には全く影響を与えません。

Java、C#、Pythonしか経験していない方の中には、「呼び出し元も変わるのでは?」と思った方もいるかもしれません。

C++では、値渡しを行うとオブジェクト自体がコピーされ、関数内に新しいオブジェクトが生成されます(つまり、異なるID(アドレス)を持つ別のオブジェクトが作られます)。

そのため、一般的に「これこそが値渡し」と考える方にとって、「Pythonも値渡しです」と述べた私の表現に違和感を覚えるのは自然かもしれません。

パターン② 値渡し+別オブジェクトを代入

次に、通常の値渡しを行い、関数内で別のオブジェクトを生成し、それを代入するパターンについて説明します。
(変更箇所以外は省略します。以下同様です。)

void functest(Person p) {
    std::cout << "functest内(開始):" << std::endl;
    p.display(); 
    
    auto child = std::make_shared<Person>("田中花子", 5);
    Person person("田中太郎", 30, child);
    
    std::cout << "functest内(生成オブジェクト):" << std::endl;
    person.display(); 

    p = person;
    
    std::cout << "functest内(終了):" << std::endl;
    p.display();        
}

 

結果は以下の通りです。

main内(functest呼び出し前):
オブジェクトID: ffffed403f40, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 00003f663ec0
functest内(開始):
オブジェクトID: ffffed403ee8, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 00003f663ec0
functest内(生成オブジェクト):
オブジェクトID: ffffed403e40, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 00003f664f20
functest内(終了):
オブジェクトID: ffffed403ee8, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 00003f664f20
main内(functest呼び出し後):
オブジェクトID: ffffed403f40, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 00003f663ec0

 

同じ「値渡し」なので、パターン①と同様に呼び出し元には影響しません。

ここで注目していただきたいのは、関数内での挙動です。

オブジェクトIDを確認するとわかるように、新たに生成したオブジェクトを元のオブジェクトに代入すると属性は変更されますが、元のオブジェクトのID(アドレス)は変更されません。

一方で、Java、C#、Pythonなどでは、このような場合、参照先が変更され、新たに生成されたオブジェクトのIDが代入されることで参照が入れ替わります。この挙動の違いは理解しておいた方が良いでしょう。

パターン③ 値渡し+属性の属性を変更

ここでは、属性そのものを変更するのではなく、属性内の子オブジェクト(Personオブジェクト)を取得し、その属性を変更するパターンについて説明します。

void functest(Person p) {
    std::cout << "functest内:" << std::endl;
    auto child = p.getChild();
    child->setName("佐藤花子");
    child->setAge(8);
    
    p.display();
}

 

結果は以下の通りです。

main内(functest呼び出し前):
オブジェクトID: ffffdb238d60, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 000004875ec0
functest内:
オブジェクトID: ffffdb238d08, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤花子 (8歳), 子供のオブジェクトID: 000004875ec0
main内(functest呼び出し後):
オブジェクトID: ffffdb238d60, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤花子 (8歳), 子供のオブジェクトID: 000004875ec0

 

少し間違い探しのようでわかりにくいかもしれませんが、オブジェクトIDは関数呼び出し前後で変わらない一方で、子オブジェクトの年齢が関数内で設定した値(8)に変更されています。

つまり、渡されるオブジェクト自体はコピーされていますが、その属性として持つオブジェクトは参照のコピーが行われているため、呼び出し元と関数内で同じ子オブジェクトを参照しています。

「値渡し」であっても、参照型の属性を持つオブジェクトを扱う場合、操作が呼び出し元に影響を与えることがあるため注意が必要です。

このようなケースで、属性として持つオブジェクトの参照先を含めた完全なコピーを得るためには、意図的にディープコピーしたものを渡す必要があります。

パターン④ 参照渡し+属性を変更

C++では、引数に&をつけることで参照渡しが可能です。ここでは、参照渡しを行い、関数内で属性を変更するパターンについて説明します。

void functest(Person &p) {
    std::cout << "functest内:" << std::endl;
    p.setName("田中太郎");
    p.setAge(30);
    p.setChild(std::make_shared<Person>("田中花子", 5));
    
    p.display();     
}

 

結果は以下の通りです。

main内(functest呼び出し前):
オブジェクトID: fffff66e6760, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 00001979fec0
functest内:
オブジェクトID: fffff66e6760, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 0000197a0f20
main内(functest呼び出し後):
オブジェクトID: fffff66e6760, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 0000197a0f20

 

この場合、関数内での変更が呼び出し元にも反映されていることが確認できます。また、オブジェクトID(アドレス)も同じであり、関数内と呼び出し元で完全に同じオブジェクトを操作していることがわかります。

パターン⑤ 参照渡し+別オブジェクトを代入

次に、参照渡しを使用し、関数内で新たなオブジェクトを生成して代入するパターンについて説明します。

void functest(Person &p) {
    std::cout << "functest内(開始):" << std::endl;
    p.display(); 
    
    auto child = std::make_shared<Person>("田中花子", 5);
    Person person("田中太郎", 30, child);
    
    std::cout << "functest内(生成オブジェクト):" << std::endl;
    person.display(); 

    p = person;
    
    std::cout << "functest内(終了):" << std::endl;
    p.display();        
}

 

結果は以下の通りです。

main内(functest呼び出し前):
オブジェクトID: ffffd6296050, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 000008748ec0
functest内(開始):
オブジェクトID: ffffd6296050, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 000008748ec0
functest内(生成オブジェクト):
オブジェクトID: ffffd6295f90, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 000008749f20
functest内(終了):
オブジェクトID: ffffd6296050, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 000008749f20
main内(functest呼び出し後):
オブジェクトID: ffffd6296050, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 000008749f20

 

ここでも、呼び出し元に関数内の状態が反映されるのは当然ですが、注意すべき点は、関数内での代入によって新たに生成されたオブジェクトが呼び出し元のオブジェクトにコピーされていることです。この挙動により、関数内で生成したオブジェクトの内容が呼び出し元に反映されつつも、オブジェクトIDは変更されない点に注意してください。

パターン⑥ 参照渡し+属性の属性を変更

参照渡しを使用した場合に、属性そのものを変更するのではなく、属性内の子オブジェクト(Personオブジェクト)を取得し、その属性を変更するパターンを説明します。

void functest(Person &p) {
    std::cout << "functest内:" << std::endl;
    auto child = p.getChild();
    child->setName("佐藤花子");
    child->setAge(8);
    
    p.display();        
}

 

結果は以下の通りです。

main内(functest呼び出し前):
オブジェクトID: ffffcbb59640, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 00002dfe8ec0
functest内:
オブジェクトID: ffffcbb59640, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤花子 (8歳), 子供のオブジェクトID: 00002dfe8ec0
main内(functest呼び出し後):
オブジェクトID: ffffcbb59640, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤花子 (8歳), 子供のオブジェクトID: 00002dfe8ec0

 

参照渡しを行っているため、この場合も関数内の変更がそのまま呼び出し元に反映されます。この挙動は、参照渡しの性質上、同じオブジェクトを操作しているために当然の結果と言えるでしょう。

パターン⑦ ポインタ渡し+属性を変更

次に、Cでお馴染みのポインタ渡しの場合にどうなるかを確かめてみましょう。

void functest(Person *p) {
    std::cout << "functest内:" << std::endl;
    p->setName("田中太郎");
    p->setAge(30);
    p->setChild(std::make_shared<Person>("田中花子", 5));
    
    p->display();     
}
    // functestにオブジェクトを渡す
    functest(&person);

 

結果は以下の通りです。

main内(functest呼び出し前):
オブジェクトID: ffffff4d4c10, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 00001565dec0
functest内:
オブジェクトID: ffffff4d4c10, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 00001565ef20
main内(functest呼び出し後):
オブジェクトID: ffffff4d4c10, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 00001565ef20

 

この方法ではオブジェクトのアドレスを渡しているため、関数内での操作がそのまま呼び出し元に反映されます。つまり、ポインタ渡しは参照渡しと同様の挙動を示し、同じオブジェクトを共有していることが確認できます。

パターン⑧ ポインタ渡し+別オブジェクトを代入

次に、ポインタが示す先に別のオブジェクトを代入した場合の挙動を確認してみましょう。

void functest(Person *p) {
    std::cout << "functest内(開始):" << std::endl;
    p->display(); 
    
    auto child = std::make_shared<Person>("田中花子", 5);
    Person person("田中太郎", 30, child);
    
    std::cout << "functest内(生成オブジェクト):" << std::endl;
    person.display(); 

    *p = person;
    
    std::cout << "functest内(終了):" << std::endl;
    p->display();        
}
    // functestにオブジェクトを渡す
    functest(&person);

 

結果は以下の通りです。

main内(functest呼び出し前):
オブジェクトID: ffffe77801c0, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 000009db8ec0
functest内(開始):
オブジェクトID: ffffe77801c0, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 000009db8ec0
functest内(生成オブジェクト):
オブジェクトID: ffffe7780100, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 000009db9f20
functest内(終了):
オブジェクトID: ffffe77801c0, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 000009db9f20
main内(functest呼び出し後):
オブジェクトID: ffffe77801c0, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 000009db9f20

 

「パターン⑤ 参照渡し+別オブジェクトを代入」と同様の挙動になります。

この場合、ポインタが指しているアドレス自体は変更されず、そのアドレスの先に新しいオブジェクトが代入されます。そのため、呼び出し元ではオブジェクトIDは変わりませんが、変更が反映されるのは当然の結果と言えるでしょう。

パターン⑨ ポインタ渡し+別オブジェクトの参照を代入

パターン⑧では、ポインタが指す先のオブジェクトを生成した新しいオブジェクトに入れ替える代入を行いましたが、このパターンではポインタが指すアドレスそのものを新しいオブジェクトのアドレスに置き換える場合について説明します。

void functest(Person *p) {
    std::cout << "functest内(開始):" << std::endl;
    p->display(); 
    
    auto child = std::make_shared<Person>("田中花子", 5);
    Person person("田中太郎", 30, child);
    
    std::cout << "functest内(生成オブジェクト):" << std::endl;
    person.display(); 

    p = &person;
    
    std::cout << "functest内(終了):" << std::endl;
    p->display();        
}
    // functestにオブジェクトを渡す
    functest(&person);

 

結果は以下の通りです。

main内(functest呼び出し前):
オブジェクトID: ffffe7ffbb00, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 0000345f8ec0
functest内(開始):
オブジェクトID: ffffe7ffbb00, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 0000345f8ec0
functest内(生成オブジェクト):
オブジェクトID: ffffe7ffba40, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 0000345f9f20
functest内(終了):
オブジェクトID: ffffe7ffba40, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 0000345f9f20
main内(functest呼び出し後):
オブジェクトID: ffffe7ffbb00, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 0000345f8ec0

 

この挙動は、まさにPython、Java、C#で見られるような振る舞いに近いと言えます。

内部ではオブジェクトが別のオブジェクトに入れ替わりますが、呼び出し元には影響しません。これは、新たに別のアドレスを指した時点で「別物」として扱われるためです。

なお、ポインタ渡しでポインタ自体のアドレスを変更することはできません。

パターン⑩ ポインタの参照渡し+別オブジェクトの参照を代入

ここまでのパターンで共通している点は、値の変更の有無にかかわらず、呼び出し元のオブジェクトのID(アドレス)が呼び出し前後で変わらないことです。

しかし、C++は柔軟な「なんでもあり」言語であるため、アドレスそのものを変えるパターンも実現できます。これが、ポインタの参照を渡すパターンです。

void functest(std::shared_ptr<Person> *p) {
    std::cout << "functest内(開始):" << std::endl;
    (*p)->display(); 

    auto child = std::make_shared<Person>("田中花子", 5);
    auto person = std::make_shared<Person>("田中太郎", 30, child);

    std::cout << "functest内(生成オブジェクト):" << std::endl;
    person->display(); 

    (*p) = person;
    
    std::cout << "functest内(終了):" << std::endl;
    (*p)->display();       
}

int main() {

    auto child = std::make_shared<Person>("佐藤太郎", 3);
    auto person = std::make_shared<Person>("佐藤一郎", 25, child);

    std::cout << "main内(functest呼び出し前):" << std::endl;
    person->display();

    // functestにオブジェクトを渡す
    functest(&person);

    std::cout << "main内(functest呼び出し後):" << std::endl;
    person->display();

    return 0;
}

 

結果は以下の通りです。

main内(functest呼び出し前):
オブジェクトID: 00002e585f10, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 00002e585ec0
functest内(開始):
オブジェクトID: 00002e585f10, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 00002e585ec0
functest内(生成オブジェクト):
オブジェクトID: 00002e586fc0, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 00002e586f70
functest内(終了):
オブジェクトID: 00002e586fc0, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 00002e586f70
main内(functest呼び出し後):
オブジェクトID: 00002e586fc0, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 00002e586f70

 

少々複雑ですが、親オブジェクト自体をスマートポインタで生成すると、そのポインタを参照渡しできるようになり、関数内でアドレスを変更することが可能になります。つまり、呼び出し元のオブジェクトのアドレスを変更できます。

なお、ダブルポインタも似た概念を持っていますが、C++でスマートポインタとダブルポインタを組み合わせるのは一般的に推奨されません。(スマートポインタを使わない場合、ヒープ領域に生成したオブジェクトのメモリ解放が手間になるため、ここでは取り扱いません。)

C#コードサンプル

次に、C#での実装を見ていきます。ここでは、これまで例示してきたC++の挙動とどのように異なるかに注目して解説します。

パターン① 値渡し+属性を変更

ここでは、通常の「値渡し」を行い、関数内で属性を変更するパターンを見ていきます。

using System;

class Person
{
    private string name;
    private int age;
    private Person child;

    public Person(string n, int a)
    {
        name = n;
        age = a;
        child = null;
    }

    public Person(string n, int a, Person c)
    {
        name = n;
        age = a;
        child = c;
    }

    public void SetName(string n) { name = n; }
    public void SetAge(int a) { age = a; }
    public void SetChild(Person c) { child = c; }

    public string GetName() { return name; }
    public int GetAge() { return age; }
    public Person GetChild() { return child; }

    public void Display()
    {
        Console.Write($"オブジェクトID: {this.GetHashCode():X8}, 名前: {name}, 年齢: {age}");
        if (child != null)
        {
            Console.Write($", 子供: {child.GetName()} ({child.GetAge()}歳), 子供のオブジェクトID: {child.GetHashCode():X8}");
        }
        Console.WriteLine();
    }
}

class Program
{
    static void Functest(Person p)
    {
        Console.WriteLine("functest内:");
        p.SetName("田中太郎");
        p.SetAge(30);
        p.SetChild(new Person("田中花子", 5));
        p.Display();
    }

    static void Main(string[] args)
    {
        Person child = new Person("佐藤太郎", 3);
        Person person = new Person("佐藤一郎", 25, child);

        Console.WriteLine("main内(functest呼び出し前):");
        person.Display();

        // functestにオブジェクトを渡す
        Functest(person);

        Console.WriteLine("main内(functest呼び出し後):");
        person.Display();
    }
}

 

結果は以下の通りです。

main内(functest呼び出し前):
オブジェクトID: 9104B64E, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 79EF55D9
functest内:
オブジェクトID: 9104B64E, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 80345E89
main内(functest呼び出し後):
オブジェクトID: 9104B64E, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 80345E89

 

C#では、オブジェクトは「参照を値渡し」するため、関数内での属性変更が呼び出し元にも反映されます。この挙動は「パターン④ 参照渡し+属性を変更」と同様です。

「ではこれは参照渡しではないか」と感じる方もいるかもしれませんが、C#のデフォルトは「値渡し」です。

ここでの「値渡し」とは、オブジェクトへの参照(アドレス)のコピーを関数に渡すことを指します。

したがって、関数内で参照先の属性を変更すると呼び出し元にも反映されますが、参照先そのものを新しいオブジェクトに置き換えても呼び出し元には影響しません。(と言うことを以前の記事に書きました。)

しかし、C++のコードをそのまま移植すると、挙動が異なる点に注意が必要です。

純粋な「参照渡し」refキーワードを使用した場合に該当します。(後述)

パターン② 値渡し+別オブジェクトを代入

ここでは、通常の「値渡し」を行い、関数内で別のオブジェクトを生成して代入するパターンを説明します。

    static void Functest(Person p)
    {
        Console.WriteLine("functest内(開始):");
        p.Display();
        
        Person child = new Person("田中花子", 5);
        Person person = new Person("田中太郎", 30, child);
        
        Console.WriteLine("functest内(生成オブジェクト):");
        person.Display();
        
        p = person;

        Console.WriteLine("functest内(終了):");
        p.Display();
    }

 

結果は以下の通りです。

main内(functest呼び出し前):
オブジェクトID: 1334B64E, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: FC1F55D9
functest内(開始):
オブジェクトID: 1334B64E, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: FC1F55D9
functest内(生成オブジェクト):
オブジェクトID: DAF5F0A3, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: C3E0902E
functest内(終了):
オブジェクトID: DAF5F0A3, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: C3E0902E
main内(functest呼び出し後):
オブジェクトID: 1334B64E, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: FC1F55D9

 

呼び出し元に影響がない点では、C++の「パターン②『値渡し+オブジェクトを代入』」と同様ですが、C#では代入時の挙動に違いがあります。関数内でオブジェクトに=で代入する際、そのオブジェクトIDは代入前後で異なるものになります。

これは、C#のデフォルトの挙動として、「=」による代入が「参照の付け替え」として機能するためです。

したがって、この挙動はC++の「パターン⑨『ポインタ渡し+別オブジェクトの参照を代入』」と類似しており、関数内で新しいオブジェクトの参照を代入しても、呼び出し元には影響がない点が特徴です。

パターン⑤ 参照渡し+別オブジェクトを代入

C++のパターン③とパターン④は結果が自明なので省略し、ここでは同じ「パターン⑤」との比較のために「参照渡し+別オブジェクト代入」のケースを検証します。

    static void Functest(ref Person p)
    {
        Console.WriteLine("functest内(開始):");
        p.Display();
        
        Person child = new Person("田中花子", 5);
        Person person = new Person("田中太郎", 30, child);
        
        Console.WriteLine("functest内(生成オブジェクト):");
        person.Display();
        
        p = person;

        Console.WriteLine("functest内(終了):");
        p.Display();
    }
        // functestにオブジェクトを渡す
        Functest(ref person);

 

結果は以下の通りにです。

main内(functest呼び出し前):
オブジェクトID: 5E8CB64E, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 477755D9
functest内(開始):
オブジェクトID: 5E8CB64E, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 477755D9
functest内(生成オブジェクト):
オブジェクトID: 264DF0A3, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 0F38902E
functest内(終了):
オブジェクトID: 264DF0A3, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 0F38902E
main内(functest呼び出し後):
オブジェクトID: 264DF0A3, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 0F38902E

 

おわかりいただけただろうか?

この挙動はC++の参照渡しと見た目は似ていますが、内部の動作に違いがあります。

C#の場合、呼び出し元のオブジェクトIDそのものが変更されます。

これは、先述のように、C#の「=」による代入がオブジェクトの上書きではなく「参照の付け替え」として動作するためです。

結果として、この挙動はC++の「パターン⑩『ポインタの参照渡し+別オブジェクトの参照を代入』」に相当し、参照そのものが呼び出し元で置き換わるため、呼び出し元のオブジェクトIDも変更されることが特徴です。

pythonコードサンプル

最後に、本サイトをご覧の方にはPythonに馴染みのある方が多いと思いますので、Pythonのコードも掲載します。挙動はC#と同じため、結果の説明は割愛します。ただし、Pythonには「参照渡し」がなく、引数はすべて「値渡し」として渡されますので、ここでは値渡しの2パターンのみを示します。

パターン① 値渡し+属性を変更

通常の「値渡し」をして関数内で属性を変更するパターンです。

class Person:
    def __init__(self, name, age, child=None):
        self.name = name
        self.age = age
        self.child = child

    def set_name(self, name):
        self.name = name

    def set_age(self, age):
        self.age = age

    def set_child(self, child):
        self.child = child

    def get_name(self):
        return self.name

    def get_age(self):
        return self.age

    def get_child(self):
        return self.child

    def display(self):
        print(f"オブジェクトID: {id(self):X}, 名前: {self.name}, 年齢: {self.age}", end="")
        if self.child:
            print(f", 子供: {self.child.get_name()} ({self.child.get_age()}歳), 子供のオブジェクトID: {id(self.child):X}", end="")
        print()

def functest(p):
    print("functest内:")
    p.set_name("田中太郎")
    p.set_age(30)
    p.set_child(Person("田中花子", 5))

    p.display()

def main():
    child = Person("佐藤太郎", 3)
    person = Person("佐藤一郎", 25, child)

    print("main内(functest呼び出し前):")
    person.display()

    # functestにオブジェクトを渡す
    functest(person)

    print("main内(functest呼び出し後):")
    person.display()

if __name__ == "__main__":
    main()

 

結果は以下の通りです。

main内(functest呼び出し前):
オブジェクトID: 106603BB0, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 106603C10
functest内:
オブジェクトID: 106603BB0, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 106603B20
main内(functest呼び出し後):
オブジェクトID: 106603BB0, 名前: 田中太郎, 年齢: 30, 子供: 田中花子 (5歳), 子供のオブジェクトID: 106603B20

パターン② 値渡し+別オブジェクトを代入

通常の「値渡し」をして関数内で別のオブジェクトを生成して代入するパターンです。

def functest(p):
    print("functest内:(開始)")
    p.display()

    child = Person("田中花子", 3)
    person = Person("田中太郎", 25, child)
    print("functest内:(オブジェクト生成)")
    person.display()

    p = person

    print("functest内:(終了)")
    p.display()

 

結果は以下の通りです。

main内(functest呼び出し前):
オブジェクトID: 10140BC10, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 10140BC70
functest内:(開始)
オブジェクトID: 10140BC10, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 10140BC70
functest内:(オブジェクト生成)
オブジェクトID: 10140BB20, 名前: 田中太郎, 年齢: 25, 子供: 田中花子 (3歳), 子供のオブジェクトID: 10140BB80
functest内:(終了)
オブジェクトID: 10140BB20, 名前: 田中太郎, 年齢: 25, 子供: 田中花子 (3歳), 子供のオブジェクトID: 10140BB80
main内(functest呼び出し後):
オブジェクトID: 10140BC10, 名前: 佐藤一郎, 年齢: 25, 子供: 佐藤太郎 (3歳), 子供のオブジェクトID: 10140BC70

まとめ

以上、C++をベースに、関数引数の挙動や代入操作に関するサンプルコードを通して、異なる言語での挙動を比較してきました。

冒頭でも述べたように、「値渡し」「参照渡し」といった言葉の定義に踏み込むときりがなく、むしろ各言語での実際の挙動を理解することが重要です。

異なるプログラミング言語では、同じコードでも異なる結果になる場合があり、その違いを正確に理解しておくことで、思わぬバグや動作の違いに対する対応力が高まります。

各サンプルコードの実行結果が示す事が紛れもない事実であり、特にC、C++とC#、Python、Javaの挙動の違いは、実務でも混乱を招きやすいポイントです。

本記事で「挙動の事実」を学んで、皆様が実践で理解を深める一助になれば幸いです。