はじめに
この記事はC++ Advent Calendar 2022の最終日、12月25日の記事です。昨日は、@TunaProductsさんがC++を使った流体シミュレーションを紹介してくださいました。
この記事では、普段あまり顧みられることのないユーザー定義型変換についてまとめています。前半では、型変換演算子・型変換コンストラクタの基本的な実装方法と機能について説明しています。後半では、複数の候補が見つかった場合の呼び出し解決のルールと、それを悪用うまく組み合わせることで、自作templateクラスに型変換警告を導入することが出来ることをご紹介します。
発端:お前いい縮小型変換警告持ってんな。俺にも使わせろよ!
大抵のC++コンパイラには、危険な暗黙の型変換には警告を出してくれる機能(-Wconversion
など)がついています。気が付かない間に小数点以下が消えていた!そもそもなんでここint
にdouble
を代入してるんだっけ?といったミスを防げる、とても素敵な警告機能です。
//浮動小数点型は整数型の値も表現できるので代入してもOK
int a=1;
double b = a;
//整数型へ浮動小数点型の値を代入すると、小数点以下の情報が消し飛ぶので警告!
double c=1.0;
int d = c; //warning: conversion from 'double' to 'int' may change value
//明示的に型変換すれば、警告は出ない
double e=1.0;
int f = static_cast<int>(e);
ところで皆さんはint
やdouble
など、任意の算術型が使えるテンプレートクラスを作った経験はないでしょうか?例えば二次元上の点(x, y)を任意の算術型T
で表すpoint<T>
を考えてみましょう。
template<typename T>
struct point{
T x;
T y;
public:
point()=default;
point(T x_, T y_):x(x_),y(y_){}
//必要に応じてoperator+などを定義
};
int main(){
point<int> p(3,5);
p.x += 5;
p.y -= 3;
}
さて、こんな型を作ってしまうと、通常の算術型と同様、int
とdouble
の間で相互に代入できるようにしたくなります。少なくともpoint<int>
からpoint<double>
への変換くらいはできてほしいですね。欲を言えば逆の時、つまりpoint<double>
からpoint<int>
への縮小変換の場合には警告してくれれば文句なしです。
//浮動小数点型は整数型の値も表現できるので代入できてほしい
point<int> ip1(1,2);
point<double> dp1 = ip1;
//整数型へ浮動小数点型の値を代入すると、小数点以下の情報が消し飛ぶので警告してほしい!
point<double> dp2(1.0,2.0);
point<int> ip2 = dp2; //warning: conversion from 'point<double>' to 'point<int>' may change value
//明示的に型変換すれば、警告を出さずに代入できてほしい
point<double> dp3=1.0(1.0,2.0);
point<int> ip3 = static_cast<point<int>>(dp3);
はたして、こんなことは可能なのでしょうか?
型変換の基本
二つのユーザー定義型変換
まず、自作クラスにおける型変換の基本を押さえてみましょう。型変換機能の実装には、二つの方法が提供されています。
一つは型変換演算子と呼ばれる、operator T()
を用意する方法です。これは、変換「元」のクラスのメンバ関数として定義します。
struct to_type;
struct from_type{
//from_type -> to_typeの型変換演算子
operator to_type()const{
return to_type(...);
}
};
もう一つは、変換元のクラスのみを引数としてとるようなコンストラクタを変換「先」のクラスで定義する方法です。このようなコンストラクタを、本記事では便宜上「型変換コンストラクタ」と呼びます。
struct from_type;
struct to_type{
//from_type -> to_typeの型変換コンストラクタ
to_type(const from_type&){
...
}
};
定義する場所が変換元のクラスか変換先のクラスか、の違いはありますが、関数としての性質は型変換演算子と型変換コンストラクタとでは、ほとんど同等の扱いです。
「融通」の利くユーザー定義型変換
さて、これらユーザー定義型変換は、変換元・変換先の型が定義と多少違っても「融通」をきかせてくれます。例えば、受け取った数をダース単位で数えてくれるクラスを考えてみましょう。
#include<iostream>
//ダース単位で数えてくれるクラス
struct dozen_type{
int dozen;
int remainder;
dozen_type()=default;
//int型からダース単位への型変換コンストラクタ
dozen_type(int num_):dozen(num_/12),remainder(num_%12){}
//ダース単位からint型への型変換演算子
operator int()const{return dozen*12+remainder;}
};
int main(){
dozen_type doz = 41; //int型を型変換コンストラクタでダース単位に
std::cout << doz.dozen <<"ダースと"<< doz.remainder <<"つ"<< std::endl;
//出力:"3ダースと5つ"
int i = doz; //ダース単位を型変換演算子でint型に
// i==41
}
型変換コンストラクタを使って、int
からdozen_type
への型変換、型変換演算子を使ってdozen_type
からint
への型変換を実現しています。では、こんなケースではどうでしょうか?
int main(){
long long ll = 41; // long long型で数字を準備
dozen_type doz = ll; // long long型からダース単位に型変換?
unsigned short us = doz;// ダース単位からunsigned short型へ変換?
}
変換元がlong long
、変換先がunsigned short
へと変わりました。しかし、実はコンパイルエラーなく実行可能です(ただし、WConversionを使っていれば、これらは縮小変換を含むと警告してくれるはずです)。変換前・変換先の型が整数型なら、int
とdozen_type
の間の型変換を上手く代用してくれるのです。こうした機能があるのは便利な気がしますね。
しかし、こんなケースではどうでしょうか?
#include<string>
#include<vector>
//緯度経度から座標を設定する関数
void set_location(double latitude, double longitude);
int main(){
dozen_type doz = true; //trueをダース単位に変換?
set_location(35.69, doz); //北緯35.69度 東経ダース単位?
std::string str = "test";
str[0] = doz; //文字列の先頭をダース単位に?
std::vector<double> vec(doz,doz); //ダース単位をダース単位個用意した配列?
if(doz){ // ダース単位をif文の条件式に?
...
}
}
奇妙に思えるかもしれませんが、実は上記表現もすべてコンパイルが通り実行できます。コンパイルオプションによっては警告すら出ません。果たして何が起きているのでしょうか?
ユーザー定義型変換前後の標準変換
原因を探るために、C++規格書を覗いてみます。
規格書のユーザー定義型変換の解決[over.ics.user]での記述よれば、ユーザー定義型変換が呼ばれる前後に、それぞれ一度ずつ標準型変換(standard conversion sequence)と呼ばれる変換が入るとあります。
標準型変換[over.ics.sc]の欄には、ややこしいことが色々と書いていますが、ざっくりと言えば以下のものを指すようです。
- 左辺値右辺値変換
-
const
,volatile
の追加 - 整数型、浮動小数点型、
bool
型間の相互変換 - 配列や関数からポインタへの変換、ポインタ同士の変換
つまり、ユーザー定義型変換が呼ばれる前後に一度ずつ上記の変換を適用することが許されているのです。先ほどの例では、int
からdozen_type
への型変換コンストラクタが呼ばれる「前」に浮動小数点型やbool
からint
への暗黙の型変換や、dozen_type型からint型への変換「後」に浮動小数点型やbool
へ型変換が実行されても、規格上何の問題もないのです。
先ほどの例に標準型変換を追記すると、以下のようになります。
int main(){
dozen_type doz = true; //trueをint型へ変換してから型変換コンストラクタ呼び出し
set_location(35.69, doz); //int型へ変換後、double型へ標準型変換
std::string str = "test";
str[0] = doz; //int型へ変換後、char型へ標準型変換
std::vector<double> vec(doz,doz);
//呼び出されているのは vector<double>(std::size_t size, double inival)
//一つ目のdozは、int型へ変換後、個数を指定するstd::size_t型へと標準型変換
//二つ目のdozは、int型へ変換後、doubleの初期値へと標準型変換
if(doz){ // int型へ変換後、bool型へと標準型変換
...
}
}
explicitによる暗黙の型変換の抑止
このように、特にint
やdouble
といった組み込み型への型変換を用意すると、前後に生じる標準型変換によって思わぬ処理が行われることがあります。こうした動作は、記述ミスに未然に気づき、意図せぬ動作を防ぐうえで好ましくありません。
C++では、こうした事態を防ぐ機能としてexplicit
があります。ユーザー定義型変換をexplicit
を使って修飾すると、明示的な型変換を経由しないと呼び出すことができなくなります。明示的な型変換としては、static_cast
を始めとするキャストや、コンストラクタであることがはっきりする形での呼び出しが挙げられます。
//ダース単位で数えてくれるクラス
struct dozen_type{
int dozen;
int remainder;
dozen_type()=default;
//explicit修飾されたint型からダース単位への型変換コンストラクタ
explicit dozen_type(int num_):dozen(num_/12),remainder(num_%12){}
//explicit修飾されたダース単位からint型への型変換演算子
explicit operator int()const{return dozen*12+remainder;}
};
int main(){
dozen_type doz1 = 1; //===コンパイルエラー=== 代入は暗黙の型変換
dozen_type doz2(41); //明示的に型変換コンストラクタを呼ぶのはOK
dozen_type doz3= static_cast<dozen_type>(41); //あるいは、static_castを明示的に呼ぶのでもOK
int i1 = doz2; //===コンパイルエラー=== 代入は暗黙の型変換
int i2 = doz2.operator int(); //明示的に型変換演算子を呼ぶのはOK
int i3 = static_cast<int>(doz3); //あるいは、static_castを明示的に呼ぶのでもOK
}
explicitを付けることによりシンプルな代入による変換はできなくなりますが、少なくとも「気が付かないうちに変な型に変換されていた」という事態は防げるわけです。
複数の型変換候補の解決
以上が型変換の基礎でしたが、発端の話に戻るためには、もう少しだけ型変換について深掘りする必要があります。ここから少々ややこしい話が続きますので、原理はどうでもいい、という方は「結局どうすればいいの?」までジャンプしていただければと思います。
複数の型変換候補
型変換には型変換演算子と型変換コンストラクタ、二つの実装方法がありました。それでは、ここでクイズです。もし以下のように、変換元と変換先のクラスで型変換演算子と型変換コンストラクタをそれぞれ用意すると、どちらが選ばれるのでしょうか?
struct to_type;
struct from_type{
operator to_type(); // fromからtoへの型変換演算子
};
struct to_type{
to_type() = default;
to_type(const from_type&){} // fromからtoへの型変換コンストラクタ
};
from_type::operator to_type(){return to_type();}
int main(){
from_type from;
to_type to=from; //呼ばれるのは型変換演算子? 型変換コンストラクタ?
const from_type cfrom;
const to_type cto = cfrom; //呼ばれるのは型変換演算子? 型変換コンストラクタ?
}
答えはこちら
上記の例では一つ目のケースでは型変換演算子が、二つ目のケースでは型変換コンストラクタが呼ばれます。型変換のオーバーロードの解決
上の答えが当たっていた方は、おそらくこの節を読む必要はありません。そうでない皆さん、もうしばらくお付き合いください。この問題を解くためには、複数の呼び出し候補がある場合のオーバーロード解決を理解する必要があります。
規格書の[over.best.ics]によれば、複数の定義が存在する場合、[over.ics.rank]に従って候補間の優劣を決め、呼び出す関数を決定せよ、とあります(優劣の差がない場合はコンパイルエラーとなります)。問題はこの優劣の決め方です。[over.ics.rank]を要約すると、今回のようなユーザー定義型変換に関わるルールとしては以下のものが挙げられます。
- 呼び出し可能でない関数は候補から外す。
- 呼び出しに際して必要なcv修飾がより少ないものが優先される。
- 呼び出しが参照の場合、右辺値なら右辺値参照、左辺値なら左辺値参照のものが優先される。
- 呼び出し後に生じる標準型変換がより高ランク([over.ics.sc]のTable 16)。
最後の「標準型変換がより高ランク」というのは、「int型をdouble型に変換するよりはshort型の方がまだ近い、short型にするよりはlong long型にする方が値の欠損がない、long long型にするよりは無変換で済むint型への代入のほうが良い」といった、標準型変換がどの程度大きな変換を行うかを順位付けした表に従うもので、大体直感通りの順序です。
先ほどの問題の例では、型変換演算子は非const関数、型変換コンストラクタの引数はconst参照として定義されていました。つまり、一つ目のケースでは、型変換演算子の場合はfrom_type&
型のまま呼び出せますが、型変換コンストラクタの場合にはconst from_type&
へ一度標準型変換する必要があります。これが、上述の二番目のルールに引っかかり、型変換演算子が呼ばれたのです。一方、二つ目のケースでは、cFrom
はconst from_type&
のため、そもそも型変換演算子を呼び出すために必要なfrom_type&
への型変換が不可能です。このため自動的にもう一方の型変換コンストラクタが呼び出されたのです。
では、型変換演算子が非const関数ではなくconst関数として定義されていた場合はどうなるでしょう?
struct to_type;
struct from_type{
- operator to_type(); // fromからtoへの型変換演算子
+ operator to_type()const; // fromからtoへの型変換演算子
};
struct to_type{
to_type() = default;
to_type(const from_type&){} // fromからtoへの型変換コンストラクタ
};
-from_type::operator to_type(){return to_type();}
+from_type::operator to_type()const{return to_type();}
int main(){
from_type from;
to_type to=from; //呼ばれるのは型変換演算子? 型変換コンストラクタ?
const from_type cfrom;
const to_type cto = cfrom; //呼ばれるのは型変換演算子? 型変換コンストラクタ?
}
この場合、型変換演算子、型変換コンストラクタ、いずれの場合も最初の標準型変換がconst from_type&
への変換になります。つまり両者の間には上にあげたルールによって優先順位をつけることができないため、一つ目と二つ目のケースはともに関数のオーバーロードの解決があいまいだとするコンパイルエラーとなります。
明示的型変換の場合
先ほどは暗黙の型変換によるfrom_type
からto_type
への変換でした。では、明示的な型変換を使った場合はどうでしょう?
struct to_type;
struct from_type{
operator to_type()const; // fromからtoへの型変換演算子
};
struct to_type{
to_type() = default;
to_type(const from_type&){} // fromからtoへの型変換コンストラクタ
};
from_type::operator to_type()const{return to_type();}
int main(){
from_type from;
- to_type to = from; //呼ばれるのは型変換演算子? 型変換コンストラクタ?
+ to_type to = static_cast<from_type>(from); //呼ばれるのは型変換演算子? 型変換コンストラクタ?
const from_type cfrom;
- const to_type cto = cfrom; //呼ばれるのは型変換演算子? 型変換コンストラクタ?
+ const to_type cto = static_cast<from_type>(cfrom); //呼ばれるのは型変換演算子? 型変換コンストラクタ?
}
実は、上記の例ではコンパイルエラーとならず両方とも型変換コンストラクタが呼ばれます。これは、明示的型変換時の型変換方法を探す際の評価順序が影響しています。明示的型変換が選択された場合、引数となる評価式に基づいてまずコンストラクタの呼び出しが検討されます。コンストラクタが見つからなかった場合に限り、その他の型変換演算子が探索されます([dcl.init])。この評価順序のため、cv修飾や右辺値左辺値参照といった呼び出し条件が同じ型変換コンストラクタと型変換演算子があった場合、明示的型変換では常に前者が優先されます。
結局どうすればいいの?
話が込み入ってきたので、型変換における要点をまとめてみます。
- 自作クラスにおける型変換の定義方法には、型変換演算子と型変換コンストラクタがある。
- 定義が変換「元」か「先」かの違い以外は、両者は本質的に同じ扱いとなる。
-
explicit
を付加すると、暗黙の型変換では呼び出されなくなる。 - 型変換に複数の候補が見つかった場合、
- 呼び出し不可能なもの(
const
変数からの非const関数呼び出しや暗黙の型変換時のexplicit
関数)は除外される。 - 呼び出しに伴う
const
,volatile
修飾の変更がより少ない方が優先順位は高い。 - 明示的型変換の場合、型変換コンストラクタの方が優先順位が高い。
- 優先順位が同じものが残った場合、関数のオーバーロードの解決があいまいによるコンパイルエラーとなる。
- 呼び出し不可能なもの(
では、自作クラスにおいてはどのように設計し使うのが良いのでしょうか? 以下では一般的なTIPSをまとめてみます。
- 特段の理由がない、限りユーザー定義型変換には
explicit
付加するのが良いとされています。- 暗黙の型変換は、関数の引数の型との組み合わせによって予測困難な呼び出しが生じうるなど、意図と異なる型変換を介したバグの温床になります。
- 特に
bool
やint
のような組み込み型との型変換を用意する場合、ほとんどすべての組み込み型への暗黙の型変換が呼び出されうるため、explicit
は必須と言えます。
- ユーザー自身が定義したクラス同士の型変換の場合、型変換コンストラクタと型変換演算子どちらでも定義できますが、一般的には型変換コンストラクタを使う方がよいとされているようです。
- これは、他のコンストラクタによる構築と同時に定義できるため、設計の見通しが良くなることがあるようです。
- 特にエラー発生時には、型変換「後」はともかく、型変換「前」の型情報が分からない事も多いため、型変換演算子にバグを埋め込んでしまうと、原因のコードがどのクラスにあるのか特定できず、デバッグが面倒なことになります。
- なお、上述の通り複数の型変換候補が見つかった場合の解決ルールは非常に複雑なため、同一の型変換が複数の方法で定義されうる設計は避けるべきです。
- どうしても両方同時に定義したい、という場合はconstや参照の有無などを統一し、意図しないオーバーロード解決が行われないよう細心の注意を払う必要があります。
本題:算術型が使える自作テンプレートクラスの型変換
ここまで長い寄り道をしてきましたが、ようやく発端に戻れます。ある意味ここからが本題です。
上に述べた原則ルールに従って、任意の算術型が使える自作テンプレートクラスに、型変換を用意してみましょう。
template<typename T>
struct point{
T x;
T y;
point() = default;
point(T x_,T y_):x(x_),y(y_){}
//型変換コンストラクタ explicitが望ましいが、ひとまず暗黙で準備
template<typename U>
point(const point<U>& other)
: x(other.x)
, y(other.y){
}
//型変換演算子まで定義すると、T->Uの型変換が二種類定義されることになるので、避ける
};
型変換コンストラクタ内では、明示的な型変換をメンバ変数には適用していません。これで、point<double>
型からpoint<int>
型への変換時には、型変換コンストラクタ内でpoint<double>.x
からpoint<int>.x
への暗黙の型変換が発生するため、縮小型変換警告を出してくれるはずです。
int main(){
//浮動小数点型は整数型の値も表現できるので代入できてほしい
point<int> ip1(1,2);
point<double> dp1 = ip1; // OK
//整数型へ浮動小数点型の値を代入すると、小数点以下の情報が消し飛ぶので警告してほしい!
point<double> dp2(1.0,2.0);
point<int> ip2 = dp2; //warning: conversion from 'point<double>' to 'point<int>' may change value
//明示的に型変換すれば、警告を出さずに代入できてほしい
point<double> dp3(1.0,2.0);
point<int> ip3 = static_cast<point<int>>(dp3); //これもwarningになってしまう。。。
}
ところが、この実装だとstatic_cast
を明示的に使っても警告は消えません。static_cast
したところで呼び出されるのはpoint<int>(const point<double>&)
なので、int
からdouble
への変換は暗黙のままだからです。
かといって、型変換コンストラクタ内でstatic_cast
を使ってしまうと、今度は暗黙の型変換でも縮小型変換警告を出してくれなくなります。
template<typename T>
struct point{
T x;
T y;
point() = default;
point(T x_,T y_):x(x_),y(y_){}
//型変換コンストラクタ explicitが望ましいが、ひとまず暗黙で準備
template<typename U>
point(const point<U>& other)
- : x(other.x)
- , y(other.y){
+ : x(static_cast<T>(other.x))
+ , y(static_cast<T>(other.y)){
}
//型変換演算子まで定義すると、T->Uの型変換が二種類定義されることになるので、避ける
};
int main(){
//浮動小数点型は整数型の値も表現できるので代入できてほしい
point<int> ip1(1,2);
point<double> dp1 = ip1; // OK
//整数型へ浮動小数点型の値を代入すると、小数点以下の情報が消し飛ぶので警告してほしい!
point<double> dp2(1.0,2.0);
point<int> ip2 = dp2; // これもOKになってしまう。。。
//明示的に型変換すれば、警告を出さずに代入できてほしい
point<double> dp3(1.0,2.0);
point<int> ip3 = static_cast<point<int>>(dp3); // OK
}
さて、実は一つだけこの問題を解決する方法があります。それは、explicit付型変換コンストラクタとexplict無型変換演算子を両方定義してやるのです。「同一の型変換が複数の方法で定義されうる設計は避けるべき」とか書いておいてちゃぶ台返しもいいところですが、縮小型変換警告を受けるためにはやむをえません。
template<typename T>
struct point{
T x;
T y;
point() = default;
point(T x_,T y_):x(x_),y(y_){}
//型変換コンストラクタはexplict付きで用意
// 明示的型変換担当なので縮小型変換警告は吐かせないようstatic_castを使用
template<typename U>
explicit point(const point<U>& other)
: x(static_cast<T>(other.x))
, y(static_cast<T>(other.y)){
}
//型変換演算子はexplict無しで用意
// 暗黙の型変換担当なので縮小型変換警告を吐きうるようにそのまま代入
template<typename U>
operator point<U>()const{
return point<U>(x,y);
}
};
int main(){
//浮動小数点型は整数型の値も表現できるので代入できてほしい
point<int> ip1(1,2);
point<double> dp1 = ip1; //OK
//整数型へ浮動小数点型の値を代入すると、小数点以下の情報が消し飛ぶので警告してほしい!
point<double> dp2(1.0,2.0);
point<int> ip2 = dp2; //warning: conversion from 'point<double>' to 'point<int>' may change value
//明示的に型変換すれば、警告を出さずに代入できてほしい
point<double> dp3(1.0,2.0);
point<int> ip3 = static_cast<point<int>>(dp3); //OK
}
なぜ、うまくいくのでしょうか? 実は、ここまで長々と書いてきた内容が関係しています。
用意した型変換演算子と型変換コンストラクタは、どちらもconst
修飾付きです。このままどちらもexplicit
を付けないと、「オーバーロード解決があいまい」のコンパイルエラーが出るのですが、今回は型変換コンストラクタにexplicit
修飾を付けています。このため、暗黙の型変換の文脈では型変換演算子のみが有効な選択肢となり、型変換演算子が呼ばれます。型変換演算子内部ではメンバ変数に対して暗黙の型変換を適用するため、型縮小変換警告が出うるようになっています。
一方、明示的型変換を行った場合には、型変換コンストラクタと型変換演算子両方が有効となります。ところが、明示的型変換の場合、型変換コンストラクタの方が優先順位が高くなるため、この場合もオーバーロード解決があいまいのコンパイルエラーとならず、型変換コンストラクタが呼び出されます。型変換コンストラクタではメンバ変数に対してstatic_cast
による明示的な型変換を行うため、いかなる場合でも型縮小変換警告は出ません。
ちなみに、explict
とstatic_cast
を適用するのを型変換コンストラクタではなく型変換演算子にした場合、うまくいきません。明示的型変換でも暗黙の型変換でも、型変換コンストラクタが呼び出されてしまうためです。また、もちろん型変換演算子と型変換コンストラクタどちらかのconst修飾を消してもうまくいかなくなります(両方消すことは可能です)。
まとめ
以上まとめると以下の通りです。
- 代入の形で使える型変換の定義方法には、型変換演算子と型変換コンストラクタがある。
-
explicit
を付加することで、暗黙の型変換を禁ずることができる。 - 呼び出し可能な複数の型変換の候補が見つかった場合、以下のルールに基づいて解決される。
-
const
,volatile
修飾の変更がより少ない。 - 明示的型変換で呼び出した場合、型変換コンストラクタの方が優先順位が高い。
- 同等の優先順位の候補が複数残る場合、「オーバーロード解決があいまい」のコンパイルエラーとなる。
-
- 型変換演算子と型変換コンストラクタを
悪用うまく定義してやると、任意の算術型を受け取るような自作テンプレートクラスに、型縮小変換警告を実装できる。
これにて今年の C++ Advent Calendar は終了です。今年も楽しませていただきました。参加者の皆様、ありがとうございました。どうぞよいお年を。
参考文献
http://bob-mk2.hateblo.jp/entry/2012/07/04/002201
http://d.hatena.ne.jp/gintenlabo/20140617/1402993524
https://stackoverflow.com/questions/45130166/constructor-is-always-used-instead-of-explicit-conversion-operator
https://stackoverflow.com/questions/74865435/overload-resolution-of-user-defined-type-conversion/74866095#74866095