オーバーロード | Programming Place Plus 新C++編

トップページ新C++編

先頭へ戻る

このページの概要

このページでは、オーバーロードという機能について取り上げます。オーバーロードは、同じスコープ内に、同じ名前の別の関数を作る機能です。これまでのページですでに登場している機能ではありますが、詳細な解説を避けてきたので、ここでまとめて解説することにします。また、演算子をオーバーロードすることについて取り上げます。

以下は目次です。要点だけをさっと確認したい方は、「まとめ」をご覧ください。



オーバーロード

オーバーロード (overload) は、同じスコープ内に、同じ名前の別の関数を複数作る機能です。

オーバーロードはすでに「コンストラクタ」のページで取り上げていますが、基本的にコンストラクタに対する話しかしていません。また、「文字列操作」のページでは、std::string のさまざまなメンバ関数がオーバーロードされている様を紹介していますが、一覧を示して、それらを使うことしか説明していません。このページでは、自分でオーバーロードを行う方法や、それらをどのように呼び分けられるかを取り上げます。

オーバーロード宣言

オーバーロードを行うには、同じ名前の関数の宣言と定義を複数書けばいいだけです。オーバーロードの各宣言のことを、オーバーロード宣言 (overloaded declarations) と呼びます。

class C {
public:
    // コンストラクタのオーバーロード
    C(int a);
    C(int a, int b);

    // メンバ関数のオーバーロード
    int f();
    int f(int a);
    long long int f(int a, int b);
};

// 関数のオーバーロード
void f(int a);
void f(double a);

オーバーロードが可能であるかどうかにはルールがあって、以下の関係性の関数どうしでのオーバーロードはできません1(未解説の事項が含まれています)。

//TODO: 関数の仮引数に [] を使うことについて、実は解説していない?

参照修飾子は未解説です。

4つ目以降の項目は、要するに関数の型としてみたときに実質同じであることが理由になっています。

最外部 (outermost level) の CV修飾子とは、たとえば const int のような型の記述における const のことです。関数の仮引数で intconst int を使い分けたとしても、関数の型としては同一とみなされます(関数の本体としては、その仮引数の値を書き換えられないという違いをもちますが、呼び出す側としては何も違いはないということ)。一方、const int* は constポインタを記述するための const であり、これは最外部にある const ではありません(int* const の場合は最外部の const です)。 //TODO: 最外部 について、規格上の解説を探す

以下の違いはオーバーロードを妨げません。

constメンバ関数と非constメンバ関数のオーバーロード

constメンバ関数か非constメンバ関数かという違いだけでもオーバーロードできることは知っておくといいです。

class C {
public:
    // 以下のオーバーロードは OK
    int f();
    int f() const;
};

呼び出し元になるオブジェクトが constオブジェクトなら constメンバ関数版が、そうでないなら非constメンバ関数版が呼び出されます。

constメンバ関数版と非constメンバ関数版を作る事情があるということは、これらの関数はメンバ変数を書き換えることを目的としていないと思われます。メンバ変数を書き換えなければならない仕事を行うなら、constメンバ関数が存在することはあり得ないはずです。一方で、単にメンバ変数の値を出力するだけといったものでもないでしょう。それなら constメンバ関数版だけがあれば事足ります。

このようなオーバーロードを行う目的は、メンバ変数の参照やポインタを返すことである場合が多いです。

class C {
public:
    struct Data {
        int a;
        int b;
        int c;
    };

    Data& get_data()
    {
        return m_data;
    }
    const Data& get_data() const
    {
        return m_data;
    }

private:
    Data m_data;
};

constメンバ関数版では const参照で、非constメンバ関数版では非const の参照で返すようになっています。本来、戻り値だけの違いではオーバーロードできませんが、constメンバ関数・非constメンバ関数という違いがあることによって、これが可能になっています。

ここでは return m_data; の1行だけですが、もしここに記述するコードがもっと長大だったり複雑だったりすると、同じコードの重複が問題になります。あとでどちらか片方だけを修正するというよくあるミスの元です。

同じコードになるなら、別のメンバ関数に共通化すればいいと考えると思いますが、その共通関数を constメンバ関数にも非constメンバ関数にもできません。

class C {
public:
    Data& get_data()
    {
        return common_get_data();
    }
    const Data& get_data() const
    {
        return common_get_data();
    }

private:
    // これが constメンバ関数だったら、非constメンバ関数版の get_data() が返したい Data& を返せない。
    // これが非constメンバ関数だったら、constメンバ関数版の get_data() から呼べない。
    ??? common_get_data() ???
    {
        return /* 複雑なコード */;
    }
};

そこでよく使われる方法は、constメンバ関数版のほうにやりたいコードを書いて、非constメンバ関数版から強引に呼び出すというものです。

class C {
public:
    Data& get_data()
    {
        // constメンバ関数版を呼び出し、戻り値の const を外して返す。
        return const_cast<Data&>(static_cast<const C*>(this)->get_data());
    }
    const Data& get_data() const
    {
        return /* 複雑なコード */;
    }
};

非constメンバ関数版がやっていることはこうです。

  1. *this を constオブジェクトにするため、static_cast で this を constポインタにする
  2. 1 で得たポインタを使って同名のメンバ関数を呼ぶことで、constメンバ関数版を呼び出す
  3. 返された結果(const Data&)から const を取り外すため、const_cast を行う
  4. 3 で得た結果を返す

非constメンバ関数版の本体では、thisポインタは C* という型ですから、そのまま get_dataメンバ関数を呼び出すと、自分自身を呼び出す結果になってしまいます。constメンバ関数版を呼び出すには、強引に自分自身を const にしてしまえばよく、そのために thisconst C* にキャストしています(*thisconst C& にすることでも可能です)。このキャストは static_cast で行えます(このあと登場する const_cast でも可能)。

constメンバ関数が返す結果は const が付加された const Data& という型ですから、非constメンバ関数が返したい Data& にするためには const を取り外す必要があります。参照やポインタの const は const_cast で取り外せます。const_cast の文法は static_cast や reinterpret_cast(「配列とポインタ」のページを参照)と同様です。

const_cast<>()

const_cast は、参照やポインタについている const や volatile(未解説)を取り外したり、付加したりできます。これ以外の場面で登場する const を操作することはできません。

const を取り外してしまうことに不安を感じるかもしれませんが、もともと、非constメンバ関数版を呼び出すことからスタートしているので、このオブジェクトが書き換えられる可能性は想定内といえます。意外にもこの方法は基本的に安全です。

【上級】とはいえ const_cast は必要悪のようなもので、できれば避けたい方法ではあります。代わりの方法として、メンバ関数テンプレートにコードを共通化する方法があります。2

オーバーロード解決

オーバーロードされている関数を呼び出すとき、どの関数を選ぶのかに関するルールが定められており、オーバーロード解決 (overload resolution) と呼ばれています。この判断はコンパイル時点で行われます。

オーバーロード解決のルールは非常に複雑ですが3、ほとんどの場合で自然だと感じられる結果になるように設計されているので、正確に理解しておかなければならないものではありません。ここでは概略だけを示すことにします。

オーバーロード解決の基本的なステップは以下のようになっています。

  1. 名前探索を行い、候補関数の一覧を探す
  2. 候補関数の中から、呼び出すことができる関数を、実行可能関数として絞り込む
  3. 実引数から仮引数への暗黙的な型変換を考慮して、最適な実行可能関数を決定する

名前探索を行い、候補関数を探す

名前探索 (name lookup) は、ソースコード上で使用されている名前が、実際には何を意味したものなのかを決定することをいいます。名前探索自体も相当に複雑なので4、これについても概要だけ示すことにします。

名前探索のルールは、スコープ解決演算子(::) による修飾を行っているかどうかによって異なります。

スコープ解決演算子で修飾されている名前に対する名前探索は、修飾の名前探索 (qualified name lookup) と呼ばれます。この場合、:: の左側に記述したクラス、名前空間、scoped enum から該当する名前を探索します。:: の左側に何もない場合はグローバル名前空間から探索します。

スコープ解決演算子で修飾されていない名前に対する名前探索は、非修飾の名前探索 (unqualified name lookup) と呼ばれます。この場合、その名前が使われている場所から発見できる名前を探索します。usingディレクティブ(「スコープと名前空間」のページを参照)や using宣言(「スコープと名前空間」のページを参照)の効力の影響を受けます。

非修飾の名前探索には、実引数依存の名前探索 (ADL: Argument Dependent name Lookup) と呼ばれる更なるルールが存在します。これは、関数呼び出しの際、その関数を探すときには、実引数に指定した内容も考慮に加えるというものです。たとえば、f(T x); のように関数f を呼び出すとき、この呼び出しが記述されている場所から見えている f だけでなく、型T が所属している名前空間も探索範囲に加えるということです。

namespace n1 {
    struct T {
        int a;
        int b;
    };
    void f(T x);
}

namespace n2 {
    void g()
    {
        n1::T x {};
        f(x);  // OK. x が n1::x であることから、n1 も探索し、n1::f を見つける
    }
}

こうした名前探索を行った結果、いくつかの関数を見つける可能性があります。これらの関数のことを候補関数 (candidate functions) と呼びます。

候補関数は名前の一致は確認されていますが、その関数が呼び出し可能であるかどうかを考慮していません。たとえば、以下のような関数であっても候補関数には選ばれます。

候補関数を1つ以上発見できた場合は、実行可能関数を絞り込むステップへ進みます。候補関数が1つも発見できなければ、使おうとしている名前が何であるのか決定できないため、コンパイルエラーとなります。

実行可能関数を絞り込む

候補関数の中から、実際に呼び出せる関数、つまり実行可能関数 (viable functions) を絞り込みます。

「viable functions」に対する訳語には、有効な関数、適切関数、妥当な関数などさまざまあります。

まず、仮引数の個数に対して、実引数の個数が十分であるかを調べます。その結果、以下のいずれかを満たしていれば、次の判断に進むことができます。満たさなければ実行可能関数ではありません。

【C言語プログラマー】... は printf関数などでおなじみの可変個引数のことです。

次に、実引数が、対応する仮引数の型と一致する、あるいは暗黙に型変換できるかどうかを調べます。型を一致させられるのであれば実行可能関数とみなされ、そうでなければ実行可能関数ではありません。

実行可能関数が1つだけであれば、その関数がオーバーロード解決の結果となります。複数の実行可能関数が残った場合は次のステップへ進んで1つに定めます。実行可能関数がなければコンパイルエラーです。

最適な実行可能関数を絞り込む

実行可能関数の中から、最もふさわしい関数を1つに定めます。ここで選ばれる関数を、最適な実行可能関数 (best viable functions) と呼びます。

基本的な考え方としては、実引数の型が、仮引数の型にどれだけ近いかを比較し、最も近しい関数が選び出されるということです。引数が複数あることもありますから、第1引数なら一方の関数のほうが近くても、第2引数では他方の関数のほうが近くて、同点と言わざるをえない場合もあります。そのような場合、最適な実行可能関数が1つに定めきれず、曖昧であるという主旨のコンパイルエラーになります。

型が完全に一致していれば、もっとも近いといえます。

void f(int a, int b);
void f(int a, double b);

f(100, 0);  // f(int, int) と完全一致。これが選ばれる

暗黙の型変換の内容が同じであるなら、それが必要な引数が少ない方が近いといえます。

void f(int a, int b, long c);
void f(int a, long b, long c);

f(0, 0, 0);  // f(int, int, long) は int -> long の型変換が1つだけ必要。これが選ばれる
             // f(int, long, long) は int -> long の型変換が2つ必要。

この例で、2つ目の関数f の第3引数が int であったら、暗黙の型変換が必要な個数が同じになるため、どちらがより一致しているとはいえず、コンパイルエラーになります。

void f(int a, int b, long c);
void f(int a, long b, int c);

f(0, 0, 0);  // f(int, int, long) も f(int, long, int) も、
             // int -> long の型変換が2つ必要なため曖昧である

また、暗黙の型変換は以下のように分類できます。

  1. 標準の型変換
  2. ユーザー定義の型変換
  3. エリプシス変換

上にあるものほど優先されます。つまり上にある型変換を使っているもののほうが、より近しい関数であると判定されます。

標準の型変換 (standard conversion) は、C++ の標準機能として備わっている暗黙の型変換のことです5。int から long や、double から int などはここに含まれます。

ユーザー定義の型変換 (user-defined conversion) は、プログラマーが明示的に定義した型変換のことです。変換コンストラクタ(「コンストラクタ」のページを参照)や、変換関数(後述 TODO )が該当します6

エリプシス変換 (ellipsis conversion) は、仮引数が ... となっている場合に行われる型変換のことです(これは未解説の機能です)。

オーバーロード演算子

演算子もオーバーロードできます。たとえば、加算を行う + という演算子には本来、2つの数値を加算する使い方と、配列を指すポインタを移動させる使い方(「配列とポインタ」のページを参照)があります。

算術型 + 算術型
ポインタ型 + 整数型

これを関数のように表現すると、次のように書けるでしょう。

T plus(T lhs, T rhs);
T* plus(T* lhs, T rhs);

lhs と rhs は左辺(左側)と右辺(右側)をあらわすためによく使われる表現です。それぞれ、left-hand side、right-hand side を意味しています。

これはすでに関数オーバーロードのようにみえますが、ここへ新たなパターンを追加することをよく、演算子のオーバーロードと呼んでいます。また、オーバーロードされた演算子を、オーバーロード演算子 (overloaded operators) といいます。

オーバーロード演算子を定義するには、operator 演算子 という名前の関数を定義します(あいだのスペースはあってもなくても構いません)。このような関数を、演算子関数 (operator function) と呼びます。

// 宣言
戻り値の型 operator 演算子(仮引数の並び);

// 定義
戻り値の型 operator 演算子(仮引数の並び)
{
}

演算子関数は、非メンバ関数か、staticでないメンバ関数でなければなりません(どちらかしか許されない演算子もあります)。非メンバ関数の場合、仮引数に最低1つはクラス型、クラスの参照型、列挙型、列挙型の参照型のいずれかを含んでいなければなりません。これらの制約は、int に対する演算子を書き換えてしまうような、C++ の根本的な部分を揺るがすような変更を許さないためにあります。

operator 演算子 が関数名ということになるので、メンバ関数の場合に定義をクラス定義の外に書くときは、以下のような記述になります。

戻り値の型 C::operator 演算子(仮引数の並び)
{
}

また、このような名前の関数に過ぎないので、x = operator+(a,b); のような記述で呼び出すことも可能ですが、通常このようなことをする必要はありません。演算子のオペランドが、演算子関数の仮引数と一致すれば自動的に使用されます。

オーバーロードできる演算子は以下のものに限られており、sizeof演算子やスコープ解決演算子のように、演算子と呼ばれていてもオーバーロードできないものがあります7

+   -   *   /   %
^   &   |   ~   <<  >>
=   +=  -=  *=  /=  %=  ^=  &=  |=  >>= <<=
==  !=  <   >   <=  >=
!   &&  ||
++  --
,
->  ->*
()
[]
new delete  new[]   delete[]

,newdeletenew[]delete[] は未解説の演算子です。 //TODO: コンマ演算子はどこかで解説する場面がなかっただろうか?

【C++20】オーバーロードできる演算子に、<=>co_await が追加されています8

+-*& には単項演算子と二項演算子がそれぞれ存在します。また、++-- には前置と後置がそれぞれ存在します。() は優先順位を変える括弧ではなく、関数呼び出しの括弧です。

演算子の種類によって、異なるルールが課せられます。ここからそれぞれ取り上げていきます。

添字演算子

まずは比較的分かりやすい添字演算子からみていきます。以下の演算子です。

[]

添字演算子は、配列の要素へのアクセスに用いる演算子でした。添字演算子をオーバーロードすることで、自作のクラスを配列のように取り扱えるようになります。std::vector や std::string で [] が使えているのは、添字演算子がオーバーロードされているからです。

添字演算子のオーバーロードは、必ず非静的なメンバ関数として行います。仮引数を必ず1つ持たせなければなりません。a[3] のような使い方をしたとき、3 の部分が仮引数に渡されてきます。一般的な添字のイメージからいって、仮引数は整数型が自然に思えるかもしれませんが、型に制約はありません。

特に、添字に文字列を用いる配列は連想配列と呼ばれ、よく使われるデータ構造の一種です。

以下は使用例です。

#include <iostream>

class C {
public:
    C() : mValues {}
    {}

    inline const int& operator[](std::size_t index) const
    {
        return mValues[index];
    }
    inline int& operator[](std::size_t index)
    {
        return mValues[index];
    }

private:
    int    mValues[100];
};

int main()
{
    C c {};
    c[1] = 100;
    std::cout << c[1] << "\n";
}

実行結果:

100

constメンバ関数版と、非constメンバ関数版を作っています。c[1] = 100 のような書き込みのアクセスを行うためには非constメンバ関数版が必要ですし、const なオブジェクトから要素の値を読み取るには、constメンバ関数版が必要です。

また、戻り値の型は参照型にします。c[1] = 100 という式は、c.operator[](1) = 100 を意味しますから、戻り値に対して代入が行える必要があるためです。

単項演算子

続いて単項演算子です。

+   -   *   &   |   ~

+-*& には二項演算子のものも存在しますが、それらは二項演算子のルールに従います。

単項演算子をオーバーロードする方法は2つあります。1つは静的でないメンバ関数にする方法、もう1つは非メンバ関数にする方法です。

静的でないメンバ関数にする方法では、そのクラス型のオブジェクトに対して演算子を使うと、そのメンバ関数が呼び出されます。

#include <iostream>

class DataStore {
public:
    explicit DataStore(int v) : mValue {v}
    {}

    inline operator int() const
    {
        return mValue;
    }

    inline DataStore operator+() const
    {
        return *this;
    }
    DataStore operator-() const;

private:
    int    mValue;
};

DataStore DataStore::operator-() const
{
    DataStore tmp {-mValue};
    return tmp;
}

int main()
{
    DataStore ds {123};
    std::cout << +ds << "\n"; 
    std::cout << -ds << "\n"; 
}

実行結果:

123
-123

//TODO: 非メンバ関数版

戻り値の型 operator 演算子(仮引数);

二項演算子

代入演算子

関数呼び出し演算子

クラスメンバアクセス演算子

インクリメント演算子、デクリメント演算子

new演算子、delete演算子

たとえば、+ という演算子に、2つの色を足し合わせるオーバーロードを追加するなら次のように記述します。

#include <algorithm>
#include <iostream>

struct Color {
    unsigned char  red;
    unsigned char  green;
    unsigned char  blue;
};

Color operator + (const Color& lhs, const Color& rhs)
{
    int red {lhs.red + rhs.red};
    int green {lhs.green + rhs.green};
    int blue {lhs.blue + rhs.blue};

    return {
        static_cast<unsigned char>(std::min(red, 255)),
        static_cast<unsigned char>(std::min(green, 255)),
        static_cast<unsigned char>(std::min(blue, 255)),
    };
}

int main()
{
    Color color1 {0, 150, 255};
    Color color2 {100, 150, 255};
    Color new_color {color1 + color2};  // OK

    std::cout << static_cast<int>(new_color.red) << " "
              << static_cast<int>(new_color.green) << " "
              << static_cast<int>(new_color.blue) << "\n";
}

実行結果:

100 255 255

演算子関数

まとめ


新C++編の【本編】の各ページには、末尾に練習問題があります。ページ内で学んだ知識を確認する簡単な問題から、これまでに学んだ知識を組み合わせなければならない問題、あるいは更なる自力での調査や模索が必要になるような高難易度な問題をいくつか掲載しています。


参考リンク


練習問題

問題の難易度について。

★は、すべての方が取り組める入門レベルの問題です。
★★は、自力でプログラミングができるようなるために、入門者の方であっても取り組んでほしい問題です。
★★★は、本格的にプログラマーを目指す人のための問題です。

問題1 (基本★)

解答・解説


解答・解説ページの先頭



更新履歴




はてなブックマーク に保存 Pocket に保存 Facebook でシェア
X で ポストフォロー LINE で送る noteで書く
rss1.0 取得ボタン RSS 管理者情報 プライバシーポリシー
先頭へ戻る