《More Effictive C++》学习笔记 — 技术(一)
条款25 — 将 constructor 和 non-member function 虚化
考虑一个处理时事新闻(包括图片和文本)的类结构:
class NLComponent{};
class TextBlock : public NLComponent{};
class Graphic : public NLComponent{};
class NewsLetter
{
private:
list<NLComponent*> components;
};
1、构造函数虚化
将构造函数虚化其实就是应用了我们常用的设计模式 — 工厂模式。在构造对象的时候,我们可能没法确认到底需要的是什么对象。其类型可能是从配置文件获取的,可能是用户输入。我们可以使用工厂模式进行动态对象的创建:
class NewsLetter
{
private:
static NLComponent* readComponent(istream& is);
};
NewsLetter::NewsLetter(istream& is)
{
while (is)
{
components.push_back(readComponent(is));
}
}
这里的readComponent就是一个”虚构造函数“,其根据输入确定输出的数据类型。
2、拷贝构造函数虚化
拷贝构造函数虚化也对应着一种设计模式 — 原型模式。在我们尝试复制一个容器类时,对于不同的类需要调用拷贝构造函数拷贝对应数据。然而,拷贝构造函数是属于具体类的,不具备多态性质。因此,我们需要提供虚拷贝构造函数:
class NLComponent
{
public:
virtual NLComponent* clone() const = 0;
};
class TextBlock : public NLComponent
{
public:
NLComponent* clone() const
{
return new TextBlock(*this);
}
};
class Graphic : public NLComponent
{
public:
NLComponent* clone() const
{
return new Graphic(*this);
}
};
在拷贝NewsLetter的时候我们只需要调用clone函数实现拷贝。而clone函数的实现完全是由直接调用拷贝构造函数实现。这也保持了拷贝的一致性,简化了我们的实现。这样我们可以轻松为NewsLetter实现一个拷贝构造函数:
NewsLetter::NewsLetter(const NewsLetter& other)
{
for (auto pCom : other.components)
{
components.push_back(pCom->clone());
}
}
3、非成员方法虚化
考虑为打印NewsLetter,那么我们需要为NLComponent的每个派生类都实现输出的方法。我们首先会想到将 << 操作符作为虚函数重载:
class NLComponent
{
public:
virtual ostream& operator<<(ostream& os) const = 0;
};
...
然而,这样的调用违背常规。因为我们需要这样使用:
TextBlock t;
t << cout;
ostream对象成了右操作数,不符合常规的使用。另一种做法是声明一个虚打印函数:
class NLComponent
{
public:
virtual ostream& print(ostream& os) const = 0;
};
...
然后我们使用普通函数重载 << 操作符。为了使其通用,我们可以将左操作数设置为NLComponent引用:
inline ostream& operator<<(ostream& os, const NLComponent& c)
{
return c.print(os);
}
条款26 — 限制某个类所能产生的对象数量
在实际应用中,我们可能希望某个类的对象只有一个(如工具类对象),或者有限的几个(如数据库连接池和线程池)。
1、允许一个对象
这种要求比较容易实现。我们可以直接使用设计模式中的单例模式实现此方式。在C++中,一个典型的懒汉式实现是:
class CLS_Test
{
public:
friend CLS_Test& getInstance();
private:
CLS_Test() = default;
CLS_Test(const CLS_Test&) = default;
};
inline CLS_Test& getInstance()
{
static CLS_Test test;
return test;
}
这个设计中使用了几个小技巧。首先,我们将类的构造函数和拷贝构造函数设置为私有,防止了外部直接构建对象;其次,全局函数被声明为友元,因此可以访问构造函数;最后,我们在函数内创建了一个static对象。需要注意,这样的静态对象当且仅当该函数第一次被调用时初始化(在构造过程无异常的情况下),正如C++标准中所说:
Dynamic initialization of a block-scope variable with static storage duration or thread storage duration is performed the first time control passes through its declaration;
意思是具有静态存储持续性的局部变量的动态初始化发生在控制权第一次被移交给该变量声明的地方。
当然,我们也可以选择不污染全局命名空间。一种方法是将函数声明为类的静态成员;另一种方法是将该类和友元函数移动到一个命名空间中。
(1)为什么不将静态对象声明为类静态成员?
一方面,类的静态成员一定会初始化,不管你是否需要使用该成员。这不符合C++的哲学:你不该为你不使用的东西付出任何代价;另一方面,位于不同编译单元中的全局静态成员的初始化顺序是不固定的。也就是我们没法保证哪些静态成员先执行,哪些静态成员后执行。这导致某些依赖其他数据的静态成员无法正常初始化。
(2)为什么可以将函数声明为inline?
这里涉及到inline修饰符和链接性的关系。C++标准中指出:
The inline keyword has no effect on the linkage of a function.
这就是说inline关键字并不会影响函数的链接性。然而如果我们尝试写出以下代码:
#pragma once
void test();
#include "test.h"
extern void testExt();
void test()
{
testExt();
}
#include "test.h"
#include <iostream>
inline void testExt()
{
std::cout << "testExt" << std::endl;
}
int main()
{
test();
}
在这种情况下代码无法通过编译。如果我们将inline修饰符去掉,则可以编译通过。这说明inline修饰符会使得函数的链接性变为内部(这和作者说的C++委员会给出的inline函数的默认链接性为外部以及C++标准中所说的似乎矛盾,如果有朋友知道具体原因或是C++标准中对应的原文请帮忙指出)。我们姑且先认为:使用inline会使单定义函数的链接性变为内部链接性。
按照我们的结论,岂不是每个包含CLS_Test的头文件都会有一份getInstance的拷贝,这样静态函数不就是多份了吗?并不是,这个结论还不完整。我们修改下上面的代码:
#include <iostream>
inline void testExt()
{
static int obj;
std::cout << "testExt234567 " << &obj << std::endl;
}
void test()
{
testExt();
std::cout << "test " << &testExt << std::endl;
}
inline void testExt()
{
static int obj;
std::cout << "testExt " << &obj << std::endl;
}
int main()
{
testExt();
std::cout << "main " << &testExt << std::endl;
test();
}
 这里,我们可以补充下我们的结论,在不同编译单元中,具有完全相同函数签名及返回值的内联函数将具有外部链接性。如果它们的实现不全相同,真正被调用的函数取决于编译器先遇到哪个函数。注意,这里函数声明式要完全相同。我们可以尝试将test.cpp中函数的定义分别改为:
inline int testExt()
{
static int obj;
std::cout << "testExt234567 " << &obj << std::endl;
return -1;
}
或
inline void testExt(int i = 1)
{
static int obj;
std::cout << "testExt234567 " << &obj << std::endl;
}
这都将导致该函数在两个源文件中的链接性变为内部。因此其输出结果为:  这样我们就理解为什么定义在头文件中的函数默认为内联函数,而且其函数实现完全相同了。这样的内联函数是具有外部链接性的。对于具有外部链接性的内联函数中的静态局部对象,根据C++标准:
An inline function or variable with external or module linkage shall have the same address in all translation units. [Note: A static local variable in an inline function with external or module linkage always refers to the same object. A type defined within the body of an inline function with external or module linkage is the same type in every translation unit. — end note]
这就是说这些内联函数的地址及在内联函数中声明的静态变量地址都相同。这也印证了我们上面打印static local object地址的结果。
2、构造函数与对象个数
当然,除了使用单例模式,我们还可以在构造函数中对对象个数进行限制。毕竟如果说哪里可以清楚知道对象构建了多少个的话,那一定是在构造函数中。如果我们在构造过多对象时抛出异常,那么其实现大致是:
class CLS_Test
{
public:
CLS_Test()
{
if (objNums >= 1)
{
throw overflow_error("too many objects");
}
++objNums;
}
~CLS_Test()
{
--objNums;
}
private:
static size_t objNums;
CLS_Test(const CLS_Test&) = delete;
};
size_t CLS_Test::objNums = 0;
这个方法的优点在于它很容易被一般化,使对象的最大数量可以设定为1以外的值。
(1)不同的对象构造状态
这种策略也是有问题的。例如,我们想要扩展该类,因此实现了一个派生类。那么当我们尝试创建基类和派生类对象时:
class CLS_TestDerived : public CLS_Test {};
int main()
{
CLS_Test test;
CLS_TestDerived test2;
}
基类对象中的计数器值为2。这涉及到一个准则:避免具体类继承其他的具体类。然而,这样的设计准则并不能完全解决这类问题。考虑CLS_Test对象作为另一个类成员时:
class CLS_TestOuter
{
public:
CLS_Test test;
}
int main()
{
CLS_Test test;
CLS_TestOuter test2;
}
问题出现在一个CLS_Test对象可能以三种不同状态生存:(1)当前类的实例;(2)派生类的实例中的base成分;(3)作为包含CLS_Test成员的类实例的成分。这导致我们希望计算的对象个数与编译呈现的对象个数并不相同。
(2)私有化构造函数
如果我们只希望计算当前类的实例个数,而不包括作为其他类附属成分的实例个数,我们可以将其构造函数设置为私有。这样,其不能被继承,也不能以对象形式作为其他类的成分:
class CLS_Test
{
public:
static CLS_Test* makeObj()
{
return new CLS_Test;
}
static CLS_Test* makeObj(const CLS_Test& other)
{
return new CLS_Test(other);
}
...
private:
CLS_Test(const CLS_Test&) = default;
CLS_Test() {...};
...
};
这样要求用户每次创建CLS_Test都要显式调用makeObj,也就是说他们明确地表示他们想要创建该类的实例。这里我们最好使用智能指针以防止忘记释放。
3、允许对象生生灭灭
前面我们分别解决了记录对象个数以及限制对象个数的记录于本类对象两个问题,现在我们可以将它们结合在一起:
class CLS_Test
{
public:
static CLS_Test* makeObj()
{
return new CLS_Test;
}
~CLS_Test()
{
--objNums;
}
private:
CLS_Test(const CLS_Test&) = default;
CLS_Test()
{
if (objNums >= 1)
{
throw overflow_error("too many objects");
}
++objNums;
}
static size_t objNums;
};
size_t CLS_Test::objNums = 0;
当然,这里如果申请了过多对象,我们也可以选择不抛出异常而是返回空指针:
static optional<CLS_Test*> makeObj()
{
try
{
return make_optional(new CLS_Test());
}
catch(...)
{
return nullopt;
}
}
这种实现很容易被扩展到限制任意个数的对象。只需增加一个变量用于保存对象个数上限即可。
4、一个用来计算对象个数的基类
学习Meyers的书,不难发现他很喜欢将一些功能类设置为基类(或模板基类)。我们也最好养成这种习惯,时刻思考代码的可复用性:
template<class T>
class CLS_Counted
{
public:
static int getObjNums()
{
return objNums;
}
~CLS_Counted()
{
--objNums;
}
protected:
CLS_Counted(const CLS_Counted&)
{
init();
}
CLS_Counted()
{
init();
}
private:
static size_t objNums;
static const size_t maxObjNums;
void init()
{
if (objNums >= maxObjNums)
{
throw overflow_error("too many objects");
}
++objNums;
}
};
template<class T>
size_t CLS_Counted<T>::objNums;
如果我们想要使用这个类:
class CLS_Test : private CLS_Counted<CLS_Test>
{
public:
static CLS_Test* makeObj()
{
return new CLS_Test;
}
static CLS_Test* makeObj(const CLS_Test& other)
{
return new CLS_Test;
}
~CLS_Test() {}
using CLS_Counted<CLS_Test>::getObjNums;
private:
CLS_Test() = default;
CLS_Test(const CLS_Test&) = default;
};
const size_t CLS_Counted<CLS_Test>::maxObjNums = 0;
有几个细节可以关注下: (1)我们继承CLS_Counted类并将派生类作为其模板类型参数。这是一种常见的手法,目的是为了使不同模板基类的静态对象相互独立 (2)我们这里使用的是私有继承(体现借…实现的目的)。如果我们使用公有继承,则需要将模板基类的析构函数设置为虚函数,而那会带来一个vptr的额外开销,实在没有必要。 (3)我们对于基类中的两个静态变量的初始化方式是不同的。对于对象初始个数,我们直接在其实现文件中初始化为0即可;而对于最大对象个数,基类没办法确定。因此,每个使用此基类的用户需要显式指定该值,否则将无法编译通过。
|