《More Effictive C++》学习笔记 — 技术(五)
- 条款30 — proxy classes
- 1、二维数组的表现
- 2、区分 operator[] 的读写动作
- 3、代理类
- (1)operator[]
- (2)代理类的读写区分
- 4、限制
- (1)取地址符结果的差异
- (2)其他写操作的重载
- (3)被代理类方法的调用
- (4)无法作为左值
- (5)隐式类型转换
- 5、评估
条款30 — proxy classes
1、二维数组的表现
C++中能够直接创建多维数组吗?当每一维的大小都为常量时可以;若为变量,则无论是直接声明多维数组或是在堆上创建多维数组都不可以:
int iDem1, iDem2;
int array2D[iDem1][iDem2]; // invalid
int array2D1 = new int[iDem1][iDem2]; // invalid
多维数组在任何语言中都有用,在C++中也一样。那么我们该如何尝试模拟它呢?C++中最广泛也最标准的做法就:产生一个类,用以表现我们有需要但被语言遗漏的对象。我们可以为二维数组定义一个类模板。
template<typename T>
class CLS_Array2D
{
public:
CLS_Array2D(size_t dim1, size_t dim2);
...
};
那么我们可以使用这个模板类来定义我们需要的二维数组了:
CLS_Array2D<int> data(5, 6);
CLS_Array2D<float>* pData = new CLS_Array2D<float>(3, 4);
然而,这些数组对象的使用并不十分直截了当。为了保持C和C++共同的语法传统,我们希望能够以方括号表现数组索引:
cout << data[1][2];
可以明确的是,我们并不能重载 operator[][]。那么仔细思考二维数组的本质,我们就知道任何一个n维数组,都可以认为是n-1维数组,其中每个元素都是由
d
i
m
n
dim_{n}
dimn 个元素所组成的一维数组。以 data 数组为例,它是由5个元素所组成的一维数组,每个元素本身又是由6个元素组成的一维数组。
因此,我们可以在 CLS_Array2D 中使用相同的技巧:将 operator[] 重载,令它返回一个 CLS_Array1D 对象。然后,我们在对 CLS_Array1D 重载其 operator[],令它返回原先二维数组中的一个元素:
template<typename T>
class CLS_Array2D
{
public:
class CLS_Array1D
{
public:
T& operator[](int _index);
const T& operator[](int _index) const;
...
};
CLS_Array2D(size_t dim1, size_t dim2) ;
CLS_Array1D& operator[](int _index);
const CLS_Array1D& operator[](int _index) const;
...
};
如此一来,我们就可以像访问普通数组一样访问该类了。CLS_Array2D 的用户不要知道 CLS_Array1D 的存在。CLS_Array1D 所象征的一维数组对象,观念上对于 CLS_Array2D 的用户而言并不存在。凡用来代表其他对象的对象,常被称为代理对象,而用以表现代理对象者,我们称为代理类。这里的 CLS_Array1D 就是一个典型的代理类,代表观念上并不存在的一维数组。
2、区分 operator[] 的读写动作
利用代理的观念来制作类,可以帮助我们解决许多问题,包括我们在学习条款5时使用的内部类 CLS_Size,通过代理类的方式阻止了隐式类型转换(从编译器可识别的一次隐式转换变为两次);以及区分 operator[] 的读写动作。
回顾我们前面学习的引用计数,我们最后一版的代码还是无法区分 operator[] 的读写 — 它只是区分了常量性 (const 对象和 non-const 对象调用)以区分是否需要创建新对象。按照书中的说法,对于 non-const operator[],我们有两种使用情况:作为左值的写操作以及作为右值的读操作。我们希望的是可以区分该操作的左值运用和右值运用。
如果熟悉C++11的新语法,你可能首先会想到使用引用限定的方法区分左值和右值运用的 operator[]。然而这是因为作者的说法误导了我们(毕竟左值右值的概念已经发生了修改)。关于新标准中的定义可以参考 《C++ Primer》学习笔记 — 基础知识补充。我也特意回去翻看了下。我们这里实际上想区分的是作为 lvalue 的 operator[] 返回对象出现在赋值运算符的哪侧。
作者的想法是:只要将我们所要的处理动作延缓,直到我们知道其返回值的结果究竟如何被使用为止。我们所要关心的就是如何延缓我们的决定。这是缓式评估的一个应用。
代理类让我们得以买到我们所需要的时间。我们可以修改 operator[],令其返回字符串中自负的代理类,而不返回字符本身。然后我们可以等待,看看这个代理类如何被应用。其使用方式决定了我们如何处理它(就像处理 operator[] 的读写动作一样)。
对于代理类,我们需要:
(1)产生它;
(2)若它被赋值,则按照写方式处理被代理对象;
(3)对于其他使用方式,按照读方式处理被代理对象。
3、代理类
使用代理类的 CLS_MyString 声明如下:
class CLS_MyString
{
public:
CLS_MyString(const char* initValue = "");
class CLS_CharProxy
{
public:
CLS_CharProxy(CLS_MyString& _str, int _index);
CLS_CharProxy& operator=(const CLS_CharProxy& rhs);
CLS_CharProxy& operator=(char rhs);
operator char() const;
private:
CLS_MyString& m_refString;
int m_iCharIndex;
};
const CLS_CharProxy operator[](int index) const;
CLS_CharProxy operator[](int index);
friend class CLS_CharProxy;
private:
...
};
代理类提供的方法很简洁,也能满足我们的需求。这里我们需要注意对于 operator[],我们不能再返回引用类型。因为每次调用,我们都需要重新创建一个代理对象。
对于 operator[] const 也返回代理对象,我个人觉得没必要(除非你有洁癖,一定要保证它们的一致性),因为代理类还是有一些不方便的地方的。既然它是为了区分读写而设计,我们就没必要在不需要的地方使用。
(1)operator[]
operator[] 的实现如下:
const CLS_MyString::CLS_CharProxy CLS_MyString::operator[](int index) const
{
return CLS_MyString::CLS_CharProxy(const_cast<CLS_MyString&>(*this), index);
}
CLS_MyString::CLS_CharProxy CLS_MyString::operator[](int index)
{
return CLS_MyString::CLS_CharProxy(*this, index);
}
为了在 operator[] const 中构造一个 CLS_CharProxy 对象,我们不得不使用 const_cast。因为其构造函数的参数要求为 CLS_MyString& 而非 const CLS_MyString&。但是,我们将返回值设置为 const,这也避免了该对象被赋值的情况。如果更严谨一些(毕竟用户同样可以通过 const_cast 去除常量性),我们应该将 CLS_CharProxy 设置为模板类,使其能够支持 const 和 non-const 两个版本。当然,最简单的方法还是返回 char,毕竟实现对用户都应该是隐藏的。
(2)代理类的读写区分
代理类的读写区分都集中于赋值函数中。这里我们给出代理类的方法定义:
CLS_MyString::CLS_CharProxy::CLS_CharProxy(CLS_MyString& _str, int _index):
m_refString(_str),
m_iCharIndex(_index)
{
}
CLS_MyString::CLS_CharProxy::operator char() const
{
return m_refString.pData->pcData[m_iCharIndex];
}
CLS_MyString::CLS_CharProxy& CLS_MyString::CLS_CharProxy::operator=(const CLS_CharProxy& rhs)
{
if (m_refString.pData->isShared())
{
m_refString.pData = new StringValue(m_refString.pData->pcData);
}
// 修改保存的字符为rhs中的字符
m_refString.pData->pcData[m_iCharIndex] = rhs.m_refString.pData->pcData[rhs.m_iCharIndex];
return *this;
}
CLS_MyString::CLS_CharProxy& CLS_MyString::CLS_CharProxy::operator=(char c)
{
if (m_refString.pData->isShared())
{
m_refString.pData = new StringValue(m_refString.pData->pcData);
}
m_refString.pData->pcData[m_iCharIndex] = c;
return *this;
}
当然,我们应该把公共代码进行提取:
CLS_MyString::CLS_CharProxy& CLS_MyString::CLS_CharProxy::operator=(const CLS_CharProxy& rhs)
{
makeCopyAndMarkUnshareable();
// 修改保存的字符为rhs中的字符
m_refString.pData->pcData[m_iCharIndex] = rhs.m_refString.pData->pcData[rhs.m_iCharIndex];
return *this;
}
CLS_MyString::CLS_CharProxy& CLS_MyString::CLS_CharProxy::operator=(char c)
{
makeCopyAndMarkUnshareable();
// 修改保存的字符为rhs中的字符
m_refString.pData->pcData[m_iCharIndex] = c;
return *this;
}
void CLS_MyString::CLS_CharProxy::makeCopyAndMarkUnshareable()
{
if (m_refString.pData->isShared())
{
m_refString.pData = new StringValue(m_refString.pData->pcData);
}
m_refString.pData->markUnshareable();
}
我们直接操作 CLS_MyString 的私有智能指针对象 pData,这是为什么 CLS_CharProxy 被设置为友元类。
4、限制
代理类很适合用来区分 operator[] 的读写操作,但是这项技术并非没有缺点。
(1)取地址符结果的差异
我们最初为 StringValue 增加一个共享标志,是因为如下的代码实现:
CLS_MyString s1 = "Hello";
char* pc = &s1[1];
CLS_MyString s2 = s1;
*pc = 't';
这导致在对某个位置的字符串引用取址后,我们无法检验其是被读还是写。我们通过共享标志解决了此问题。然而,现在这样的代码却无法编译通过。因为这里取址得到的数据类型为 CLS_CharProxy*,不再是 char*(当然,这里如果我们使用 auto 定义这个变量可以解决此问题,但是那可能不是用户想要的,而且这样的变量无法传递给需要 char* 类型入参的函数中)。
为了解决这个问题,我们可以重载 CLS_CharProxy 的取址操作符:
class CLS_MyString
{
class CLS_CharProxy
{
char* operator&();
const char* operator&() const;
}
}
char* CLS_MyString::CLS_CharProxy::operator&()
{
makeCopyAndMarkUnshareable();
return &m_refString.pData->pcData[m_iCharIndex];
}
const char* CLS_MyString::CLS_CharProxy::operator&() const
{
return &m_refString.pData->pcData[m_iCharIndex];
}
我们很容易会发现,这个解决方式同样不完善。我们再次遇到了无法区分读写操作的情况。在客户取地址之后,我们无法区分用户是想读取还是写入该地址。因此我们选择无论如何都拷贝一份内存。这个问题我们还真没办法解决。既然客户使用的是内置对象指针,我们想要监控它就需要重载内置对象指针的 operator*,这是一个不合理也无法实现的需求。从另一个方面讲,如果客户选择使用 auto 声明变量,我们可以借助代理类解决此问题(通过提供 operator char*)。
(2)其他写操作的重载
代理类最明显的一个缺点就是它需要检测所有写操作(就如同 js 中的 Object.defineProperty 提供的 set 函数)。除了赋值以外,还包括 ++、+= 等等。如果我们想让这些内置的操作符在代理类和被代理类上有相同的展示,我们就需要一一重载它们。当然,我们也可以利用工厂方法模式解决此问题。我这里简单尝试写了个初版实现了 operator++ 和 operator+=,其他操作符类似:
/*
* Proxy 代理类
* Proxied 被代理类型
*/
template<typename Proxy, typename Proxied>
class CLS_ProxyHelper
{
public:
/*
* @brief 拷贝并且设置为不可共享
*/
virtual void makeCopyAndMarkUnshareable() = 0;
/*
* @brief 获取被代理对象实值
*/
virtual Proxied& getProxied() const = 0;
CLS_ProxyHelper& operator++();
template<typename ProxiedOther>
CLS_ProxyHelper& operator+=(const CLS_ProxyHelper<Proxy, ProxiedOther>&);
};
模板基类中显示要求派生类中实现 makeCopyAndMarkUnshareable 和 getProxied 接口,用以在实值需要改变的同时修改引用计数。一开始我尝试使用 operator Proxied&(),但是这样会在转换时引发二义性。
为了泛化实现,operator+= 支持同一代理类型不同被代理类型的对象相加。这里可以将该操作符的模板参数设置为两个,即可支持不同代理类型不同被代理类型的对象相加。当然,前提是被代理对象自身支持+=操作符。
此类的实现如下:
template<typename Proxy, typename Proxied>
CLS_ProxyHelper<Proxy, Proxied>& CLS_ProxyHelper<Proxy, Proxied>::operator++()
{
this->makeCopyAndMarkUnshareable();
++this->getProxied();
return *this;
}
template<typename Proxy, typename Proxied>
template<typename ProxiedOther>
CLS_ProxyHelper<Proxy, Proxied>& CLS_ProxyHelper<Proxy, Proxied>::operator+=(const CLS_ProxyHelper<Proxy, ProxiedOther>& rhs)
{
this->makeCopyAndMarkUnshareable();
this->getProxied() += rhs.getProxied();
return *this;
}
其实很简单,他们只是单纯的标记为不可共享,并将操作请求转发给被代理对象。
想要使用这个类也很简单,我们需要扩展下 CLS_CharProxy 的接口(makeCopyAndMarkUnshareable原本就已经实现,不再列出):
class CLS_CharProxy : public CLS_ProxyHelper<CLS_CharProxy, char>
{
private:
virtual char& getProxied() const;
};
char& CLS_MyString::CLS_CharProxy::getProxied() const
{
return m_refString.pData->pcData[m_iCharIndex];
}
这个方法被实现为私有方法。这是为了防止外部通过此方法获取内部引用,直接修改实值。这就违背了我们使用代理类的初衷。
CLS_ProxyHelper 这个类的名字有点简单。如果想起的更好一些,大概可以叫 CLS_ProxyBaseForOriginalWritingOperators。相应地,makeCopyAndMarkUnshareable 也可以改为 beforeWritingOperatorsCall。毕竟这个类及内部方法并不是只为引用计数功能服务的。
(3)被代理类方法的调用
(2)中的问题是针对被代理类的操作符重载而言。我们通过工厂方法解决此问题的前提是所有操作符已知而且同名,不会出现用户自定义的操作符。然而,如果被代理类是我们自定义的对象,我们如何通过代理类调用被代理类的方法呢?考虑下列类:
template<typename T>
class Array
{
public:
class Proxy
{
public:
Proxy(Array& array, int index);
Proxy& operator=(const T& rhs);
operator T() const;
};
const Proxy operator[](int index) const;
Proxy operator[](int index);
...
};
class Rational
{
public:
Rational(int numerator = 0, int denominator = 1);
int getNumerator() const;
int getDenominator() const;
...
};
我们使用 Array 模板类模拟一个数组,其支持引用计数功能,并通过模板类区分 operator[] 读写操作。如果我们使用一个有理数类 Rational 来实例化此数组,那么通过 operator[] 得到的数据就是代理类对象。对用户而言,此行为应该是透明的。用户应该能像使用 Rational 对象一样使用代理类对象,然而实际情况却是下面的代码无法编译通过:
int main()
{
Array<Rational> arrayRational;
arrayRational[1].getNumerator();
}
是的,这显然没法编译通过。毕竟 getNumerator 并不是代理类的方法。如果我们仍旧想通过提供一个模板基类解决此问题,我们该在模板基类中实现哪些行为呢?我们无法确定。因此我们只能选择在代理类中将每一个函数加以重载。
(4)无法作为左值
不难发现,作为函数返回值的 CharProxy 对象是一个右值,也就是说它无法被当做左值使用。这带来的问题就是无法在需要 char& 类型参数的函数中直接使用 operator[]:
void testCharRef(char&) {}
int main()
{
CLS_MyString s1 = "Hello";
testCharRef(s1[0]); // invalid
}
(5)隐式类型转换
代理类可以帮助我们通过多一次的类型转换阻止转换构造函数的调用,但是这同样也是它的一个问题。毕竟这和被代理类对象的表现是不一致的:
class testImplicitConvert
{
public:
testImplicitConvert(char) {};
};
void test(const testImplicitConvert& obj) {}
int main()
{
CLS_MyString s1 = "Hello";
test('H');
test(s1[0]); // invalid
}
5、评估
代理类允许我们完成某些十分困难或几乎不可能的行为。多维数组是其中之一,读写区分是其中之二,压抑隐式转换是其中之三。
然而,代理类也有缺点。当类的身份从与真实对象合作变为与替身对象合作,往往会造成类语义的改变。因为代理类对象的行为常常和真正对象的有些细微差异。有时候这会造成代理类在系统设计中的一个弱势。不过通常我们很少需要用到代理类和对象有异的行为,因此,许多情况下代理类确实可以完美的取代真正对象。