写在前面
条款24与46都提到了隐式类型转换,给出的例子都是重写版本的’*’运算符,这里简单做个总结。
什么是隐式类型转换
首先,隐式类型转换是编译器自动完成的操作,通常出现在混合表达式中。例如,我们经常使用的除法操作:
int a = 5;
auto value = a/1.0f;
上述表达式就会发生隐式类型转换,混合类型的a/1.0 中的a会被转换成浮点数参与除法运算,因此value的类型也是float。此外,隐式类型转换还会发生在条件表达式以及初始化/赋值操作过程。
类相关的隐式类型转换
上述所谓的隐式类型转换说明了一般情况下的类型转换规则。关于类类型,我们先举如下一个例子:
class AB;
AB a = 5;
如何能够使上述代码通过编译?从代码本身来看,我们使用一个int类型的对象为一个类类型赋值,我们知道,如果构造函数没有被explicit修饰,上述隐式类型转换就会发生,也就是说 5 会被隐式转换为 AB,并调用拷贝构造函数完成初始化:
class AB {
public:
AB(int a= 2){};
};
用户自定义的类的隐式类型转换不在这篇文章的讨论范围,感兴趣的可以自己去查看相关内容,按照effective C++的说法,这种操作不被推荐。
运算符重写下的隐式类型转换
effective C++ 条款 24 的名称:若所有参数都需要隐式类型转换,请为此定义非成员函数。对于运算符来讲,这通常对应双目运算符(原文提到乘法满足交换律,而减法操作不满足交换律但仍然满足这种情况的定义,所以交换律并非必须)。我们这里给出一个比原文更常见的操作:字符串的拼接。
我们知道,’+’法操作可以连接std::string 和const char*,在给出正确的定义之前,我们先猜想一下可能的定义:
首先,成员函数的定义方式不满足要求,因为我们知道 “abc”+s(s为std::string 对象)是可以通过编译的,若为成员函数,s作为隐含的操作对象必须在运算符左侧,也即是 s.operator+(“abc”),显然,对+号操作符的重写不可能是成员函数,这也符合我们上面给出的双目运算符规律。对于非成员函数的定义,按照惯常的理解,我们似乎需要定义两个函数来对应上述两种操作,一种string在左侧,一种string在右侧,大概类似这种情况:
string operator+(const string& s1,const char*s2);
string operator+(const char*s1,const string&s2);
按照条款3,这里的返回值string应该是const,否则一个运算了+法操作返回的临时变量仍然可能被赋值(string s1, s2;(s1 + “abc”) = s2; //合法操作)。然而,标准库中源代码并未定义为const,并不知道这样做的原因,先按照标准库的模板来写。
如果没有隐式类型转换,我们基本上就只能按照上述的方式来满足上述的两种情况,但是在类类型的隐式类型转换帮助下,我们只需要给出一种定义:
class string {
public:
string(const char*){...};
}; // 模拟的定义,实际肯定不是这样的写法
// 非成员函数
string operator+(const string& s1,const string&s2) {}
无论什么形式的string与const char*的相加,都会通过隐式类型转换转换为两个string的求和,并调用上述的加法运算符。
但是,这种隐式类型转换有一个必须的前提,至少有一个参数是string类型,否则,编译器不会默认进行这种隐式类型转换,因为它认为不存在可以调用的函数。因此,两个const char*是不可能直接相加的(除非自定义相关的重载加法操作)。这也从根本上解释了为什么加法操作必须有一个string的原因。
模板类型的操作符重载
模板的情况稍有不同,主要原因在于,模板不会进行隐式类型转换,只有在模板具现化为真正的类之后,编译器才会进行隐式类型转换,关于模板的很多特殊情况,包括继承情况下模板不会查找基类成员函数,这其实都是跟模板编译期表现的一些性质相关,这一点可以查看effective C++ 模板章节,本节只讨论操作符重载。
本节采用的例子均来自effective C++ 条款46。
我们先给出一个Rational 模板的定义,该模板主要做一些数的相关操作。
template <class T>
class Rational {
public:
Rational(const T& numerator = 0, const T& denominator = 1);
const T numerator() const;
const T denominator() const;
};
按照与非模板类似的写法,我们可能写出如下的重载*运算符:
template <class T>
const Rational<T> operator*(const Rational<T>& lhs, const Rational<T>& rhs);
但上述操作不支持混合运算,如下的操作就不会通过编译:
Rational<int> a;
a*2;
编译器不会对模板进行隐式的类型转换,在类型推导过程中,因为operator*函数并未被具现化,2并不会被隐式转换为Rational,即使它确实可以被转换为上述类型。所以不存在这样的混合操作。
解决上述问题的方法就是,找到一种策略,使得重载的运算符在类型定义时就被具现化,从而使隐式类型转换发生作用,一个显而易见的解法就是,将该重载操作定义为类的友元函数:
template <class T>
class Rational {
public:
Rational(const T& numerator = 0, const T& denominator = 1);
const T numerator() const;
const T denominator() const;
friend const Rational operator*(const Rational& lhs, const Rational& rhs);
};
我们已经在类内声明了友元函数,惯常的做法是在类外提供该友元函数的定义,但事实上,这对模板类行不通:具现化的友元函数找不到类外的正确定义。正确的做法是,在模板类内部提供友元函数的定义!事实上,这也是在类内定义非成员函数的做法。
// 以下代码在模板类内
friend const Rational operator*(const Rational& lhs, const Rational& rhs) {
return Rational(lhs.numerator()*rhs.numerator(),lhs....);
}
额外的补充
在本节的末尾,书中提到,因为所有在类内定义的函数都自动加上inline,为了抵消inline可能带来的代码膨胀,通常会在类外定义一个辅助函数,而重载的运算符只是简单的调用这个辅助函数(关于为什么会造成代码膨胀,请查看对应章节),事实上,这也是标准库的做法:
// 先给出Rational的声明式:
template<class T>
class Rational;
template<class T>
const Rational<T> doMulti(const Rational<T> &lhs, const Rational<T> &rhs)
{
// 做真正的操作
return Rational(lhs.numerator()*rhs.numerator(),lhs....);
}
// 以下代码在模板类内
friend const Rational operator*(const Rational& lhs, const Rational& rhs) {
return doMulti(lhs,rhs);
}
模板的定义通常也放在头文件内(所以要同时包含声明和定义)。