Effective C++ 第二版 45)幕后行为 46)编译链接和运行时错误 47)非局部静态对象初始化

56 篇文章 0 订阅

杂项

要写出高效的软件, 必须知道编译器在背后做了些什么, 怎样保证非局部的静态对象在被使用前已经被初始化, 标准库的信息, 如何深入理解语言底层的设计思想;


条款45 弄清C++在幕后为你所写, 所调用的函数

一个空类什么时候不是空类? C++编译器会自动声明下列函数:

一个拷贝构造函数, 一个赋值运算符, 一个析构函数, 一对取址运算符; 如果你没有声明任何构造函数, 它也会为你声明一个缺省构造函数; 所有这些函数都是公有的;

e.g.

1
class Empty{};

 和下面的结果是一样的:

1
2
3
4
5
6
7
8
9
class Empty {
public:
    Empty(); // 缺省构造函数
    Empty(const Empty& rhs); // 拷贝构造函数
    ~Empty(); // 析构函数 ---- 是否为虚函数看下文说明
    Empty& operator=(const Empty& rhs); // 赋值运算符
    Empty* operator&(); // 取址运算符
    const Empty* operator&() const;
};

如果需要, 这些函数就会被生成:

1
2
3
4
5
6
7
8
const Empty e1; // 缺省构造函数
// 析构函数
Empty e2(e1); // 拷贝构造函数
e2 = e1; // 赋值运算符
Empty *pe2 = &e2; // 取址运算符
// (非const)
const Empty *pe1 = &e1; // 取址运算符
// (const)

假设编译器为你写了函数, 实际上缺省构造函数和析构函数什么也不做, 它们只是让你能够创建和销毁类的对象;(对编译器来说, 将一些幕后行为的代码放进去也很方便--条款33, M24);

Note 生成的析构函数一般是非虚拟的(条款14), 除非它所在的类是从一个声明了虚析构函数的基类继承而来; 缺省取址运算符只是返回对象的地址;

实际上的定义就像:

1
2
3
4
inline Empty::Empty() {}
inline Empty::~Empty() {}
inline Empty * Empty::operator&() { return this; }
inline const Empty * Empty::operator&() const return this; }


对于拷贝构造函数和赋值运算符, 官方规则是: 缺省拷贝构造函数(赋值运算符)对类的非静态数据成员进行"以成员为单位的"逐一拷贝构造(赋值)[位拷贝 bitwise copy]; 

即: 如果m是类C中类型为T的非静态数据成员, 并且C没有声明拷贝构造函数(赋值运算符), 如果T有拷贝构造函数(赋值运算符)的话, m将会通过类型T的拷贝构造函数(赋值运算符)被拷贝构造(赋值); 如果T没有, 规则递归应用到m的数据成员, 直至找到一个拷贝构造函数(赋值运算符)或固定类型(int, double, 指针...)为止; 

默认情况下, 固定类型的对象拷贝构造(赋值)时是从源对象到目标对象的"逐位"拷贝; 对于从别的类继承而来的类来说, 这条规则适用于继承层次结构中的每一层; 所以用户自定义的构造函数和赋值运算符无论在哪一层声明, 都会被调用;

e.g. NamedObject模板, 实例是可以将名字和对象联系起来的类;

1
2
3
4
5
6
7
8
9
10
template<class T>
class NamedObject {
public:
    NamedObject(const char *name, const T& value);
    NamedObject(const string& name, const T& value);
...
private:
    string nameValue;
    T objectValue;
};

>因为NamedObject声明了构造函数, 编译器不会生成缺省构造函数; 但因为没有声明拷贝构造和赋值运算符, 编译器将生成这些函数(当需要的时候);

1
2
NamedObject<int> no1("Smallest Prime Number", 2);
NamedObject<int> no2(no1); // 调用拷贝构造函数

编译器生成的拷贝构造函数必须分别用no1.nameValue和no1.objectValue来初始化no2.nameValue和no2.objectValue; nameValue的类型是string, string有一个拷贝构造函数(std中的string), 所以no2.nameValue初始化时将调用string的拷贝构造函数, 参数为no1.nameValue; 另一方面, NamedObject<int>::objectValue的类型是int(模板实例中的T是int), int没有定义拷贝构造, 所以no2.objectValuue是通过no1.objectValue拷贝每一个比特bit 而被初始化的;

编译器为NamedObject<int>生成的赋值运算符也以同样的方式工作; 但通常, 编译器生成的赋值运算符想要像上面所述的那样工作, 于此相关的所有代码必须合法, 行为合理, 否则编译器将拒绝生成operator=, 编译时会收到一些诊断信息;

e.g. 假设NamedObject的定义: nameValue是一个string的引用, objectValue是一个const T:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<class T>
class NamedObject {
public:
// 这个构造函数不再有一个const 名字参数,因为nameValue
// 现在是一个非const string 的引用。char*构造函数
// 也不见了,因为引用要指向的是string
    NamedObject(string& name, const T& value);
... // 同上,假设没有
// 声明 operator=
private:
    string& nameValue; // 现在是一个引用
    const T objectValue; // 现在为const
};

使用时:

1
2
3
4
5
6
7
string newDog("Persephone");
string oldDog("Satch");
NamedObject<int> p(newDog, 2); // 正在我写本书时,我们的
// 爱犬 Persephone 即将过她的第二个生日
NamedObject<int> s(oldDog, 29); // 家犬Satch 如果还活着,
// 会有 29 岁了(从我童年时算起)
p = s; // p 中的数据成员将会发生些什么呢?

>赋值之前, p.nameValue指向某个string对象, s.nameValue也指向一个string, 但是不同的两个对象; 赋值后, p.nameValue应该指向"被s.nameValue所指向的string"么? 引用本身应该被修改么: 或者p.nameValue所指的string对象应该被修改么? 如果是, 含有"指向string的指针或引用"的其他对象也会受影响; 即是说, 和赋值没有直接关系的其他对象也会受影响;


Note C++不能让一个引用指向另一个不同的对象(M1);


面对这样的情况, C++会拒绝编译这段代码; 如果想让一个包含引用成员的类支持赋值, 就需要自己定义赋值运算符, 对于包含const 成员的类(例如上面被修改的类中的objectValue), 编译器的处理也类似; 因为修改const成员是不合法的, 所以编译器在隐式生成赋值函数时也不知道该怎么做; 还有, 如果派生类的基类将标准赋值运算符声明为private, 编译器也将拒绝为这个派生类生成赋值运算符; 因为编译器为派生类生成的赋值运算符也应该处理基类部分(条款16, M33), 就得调用对派生类来说无权访问的基类成员函数;

问题: 如果想禁止使用这些函数, 就是说, 假如永远不想让类的对象进行赋值, 有意不声明operator=, 该怎么做? 条款27; 指针成员和编译器生成的拷贝构造函数及赋值运算符之间的相互影响: 条款11;


条款46 宁可编译链接时出错, 也不要运行时出错

除了极少数情况下C++会抛出异常(例如内存耗尽, 条款7), 运行时错误的概念和C++没什么关系. 像C一样, 没有下溢, 上溢, 除零检查; 没有数组越界检查... 一旦程序通过编译和链接, 就只有靠自己了; 这种行为背后的动机在于效率: 没有运行时检查, 程序会更小更快;

另一些语言有不同的处理方法, 如Smalltalk, LISP通常在编译链接期间只是检查极少一些错误, 但却提供强大的运行时系统来处理执行期间的错误, 不像C++, 这些语言几乎都是解释型的, 在提供额外灵活性的同时, 也带来性能上的损失;

C++编程要避免运行时错误, 就要让出错检查从运行时退回到链接时, 或者最理想的: 编译时;

这种方式的好处不仅仅在于程序的大小和速度, 还有可靠性; 如果程序通过了编译和链接而没有产生错误信息, 就可以确信程序中没有编译器和链结器能检查得到的任何错误;

对于运行时错误来说, 一次运行期间没有产生错误, 不能保证另一次不同的运行期内不会产生错误; 在一次运行中, 以不同的顺序做事, 采用不同的数据, 或者运行更长或更短时间等等; 不论怎么测试, 可能还是无法覆盖所有的可能性; 因此运行时发现错误比在编译链接期间检查错误更不保险;

通常, 对设计做一点改动, 就可以在编译期间消除可能产生的运行时错误; 这常常涉及到在程序中增加新的数据类型(M33);

e.g. 假设写一个类来表示时间中的日期:

1
2
3
4
5
class Date {
public:
    Date(int day, int month, int year);
...
};

实现构造函数, 面临的问题是对day, mont的合法性检查; 对于month值, 明显的方法是采用枚举类型代替整数:

1
2
3
4
5
6
enum Month { Jan = 1, Feb = 2, ... , Nov = 11, Dec = 12 };
class Date {
public:
    Date(int day, Month month, int year);
...
};

但这样没有多少好处, 因为枚举类型不需要初始化:

1
2
Month m;
Date d(22, m, 1857); // m 是不确定的

所以Date构造函数还是不得不验证month参数的值;

既想免除运行时检查, 又要保证安全性, 就得用一个类来表示month, 保证只有合法的month才被创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Month {
public:
    static const Month Jan() { return 1; }
    static const Month Feb() { return 2; }
...
    static const Month Dec() { return 12; }
    int asInt() const // 为了方便,使Month
    return monthNumber; } // 可以被转换为int
private:
    Month(int number): monthNumber(number) {}
    const int monthNumber;
};
class Date {
public:
    Date(int day, const Month& month, int year);
...
};

工作方式: 1) Month构造函数是私有的, 防止用户创建新的month; 可供使用的只能是Month的静态成员函数返回的对象, 加上它们的拷贝; 2) 每个Month对象为const, 不能被改变(否则有些地方会把一月改成六月, 特别在北半球); 3) 得到Month对象唯一的办法是调用函数或拷贝现有的Month(隐式拷贝构造--条款45); 这样就可以在任何时间任何地方使用Month对象, 不必担心无意中使用了没有被初始化的对象(条款47);

有了这些类, 用户几乎不可能指定非法的month, 但是还是有下面这种可恶的情况:

1
2
Month *pm; // 定义未被初始化的指针
Date d(1, *pm, 1997); // 使用未被初始化的指针!

通过未被初始化的指针取值, 结果是不确定的(条款3); 遗憾的是, 没有办法防止或检查这种行为, 除了这种情况外, Date构造函数对Month参数就可以免于合法性检查; 另一方面, 构造函数必须检查day参数的合法性---每个月有不同的天数;


Date的例子将运行时检查用编译时检查来取代; 链接时检查不是经常出现; C++用链结器来保证所需要的函数只被定义一次(条款45); 还使用链接器来保证静态对象(条款47)只被定义一次; 我们可以用同样的方法使用链器; 例如条款37说明, 对于一个显式声明的函数, 如果想有意禁止对它进行定义, 链器检查很有用;

但是想消除所有的运行时检查是不可能的; 例如: 任何允许交互式输入的程序都要进行输入验证, 同样地, 某个类中如果包含需要执行上下限检查的数组, 每次访问数组都要对下标进行检查; 尽管如此, 将检查从运行时转移到编译或链接时是值得努力的目标, 只要切实可行, 这样做会使得程序更小, 更快, 更可靠; [增加了类型, 增加了步骤, 应该会让程序变大吧? 可能是因为是省下了Exception代码;]


条款47 确保非局部静态对象在使用前被初始化

使用未初始化的对象无异于蛮干[瞎弄]; 构造函数可以确保对象在创建时被初始化; 

在某个特定的被编译单元(源文件)中, 可能没有问题, 但如果在某个编译单元中, 一个对象的初始化依赖于另一个被编译单元中的另一个对象的值, 而且第二个对象本身也需要初始化, 事情就会很复杂;

e.g. 假设已经写了一个程序库, 提供一个文件系统的抽象, 其中包括一个功能, 使得互联网上的文件看起来就像在本地一样; 既然程序库使得整个事件看起来像一个单独的文件系统, 你就可以在程序库的名字空间中创建一个专门的对象theFileSystem, 这样用户任何时候需要和程序库提供的文件系统交互就可以使用它:

1
2
class FileSystem { ... }; // 在个类在你的程序库中
FileSystem theFileSystem; // 程序库用户和这个对象交互

因为theFileSystem表示的是很复杂的东西, 所以它的构造很重要; theFileSystem没构造之前就使用它会造成不可确定的行为;(参考M17, theFileSystem这样的对象, 其初始化可以被有效, 安全地延迟)

假设某个程序库的用户创建了一个类, 表示文件系统中的目录, 使用了theFileSystem:

1
2
3
4
5
6
7
8
9
class Directory { // 由程序库的用户创建
public:
    Directory();
...
};
Directory::Directory()
{
//通过调用 theFileSystem 的成员函数创建一个 Directory 对象;
}

用户想为临时文件专门创建一个全局Directory对象:

1
Directory tempDir; // 临时文件目录

初始化顺序的问题: 除非theFileSystem在tempDir之前被初始化, 否则tempDir的构造函数将会去使用还没有被初始化的theFileSystem; 但theFileSystem和tempDir是不同的人在不同的时间, 不同的文件中创建的, 怎样确认theFileSystem在tempDir之前被创建?


任何时候, 如果在不同的被编译单元中定义了"非局部静态对象", 并且这些对象的正确行为依赖于他们被初始化的某一特定顺序, 这类问题就会产生; 

非局部静态对象: 定义在全局或名字空间范围内(例如theFileSystem, tempDir), 在一个类中被声明为static, 或在一个文件范围内被定义为static;

对于不同被编译单元中的非局部静态对象, 你一定不希望自己的程序行为依赖于他们的初始化顺序, 因为你无法控制这种顺序 -- 无法控制不同被编译单元中非局部静态对象的初始化顺序;

确定非局部静态对象初始化的"正确"顺序很困难; 即使在最普通的形式下: 多个被编译单元, 多个通过隐式模板实例化所生成的非局部静态对象(隐式模板实例化问题); 不仅是确定正确的初始化顺序, 往往连找一个可确定正确顺序的特殊情况都很费力;

"混沌理论"领域有一个原理称为"蝴蝶效应", 对于某种系统, 输入的微小干扰会导致输出彻底的变化;

软件系统的开发表现了自身的蝴蝶效应, 一些系统对需求的细节高度敏感, 需求发生细小变化, 实现系统的难易程度就会发生巨大变化; 例如条款29, 将一个隐式转换的要求从"String到char*"改为"String到const char*", 可以将一个运行慢, 易出错的函数用一个运行快, 且安全的函数代替;

确保非局部静态对象在使用前被初始化的问题也一样, 对实现细节十分敏感; 如果不强求一定要访问非局部静态对象, 而愿意访问具有和非局部静态对象相似行为的对象, 问题就变得简单;

单例模式Singleton pattern; 1) 把每个非局部静态对象转移到函数中, 声明为static; 2) 让函数返回这个对象的引用; 这样用户将通过函数调用来指明对象; 用函数内部的static对象取代了非局部静态对象(M26);


虽然关于"非局部"静态对象什么时候被初始化, C++几乎没有说明[程序启动, 加载符号表时?], 但对于函数中的静态对象(局部静态对象), C++明确指出: 他们在函数调用过程中初次碰到对象的定义时被初始化; 所以如果不对非局部静态对象直接访问, 而用返回局部静态对象引用的函数调用来代替, 就能保证从函数得到的引用指向的是被初始化的对象; 另一个好处是, 如果这个模拟非局部静态对象的函数没有被调用, 就不会带来对象构造和销毁的开销; [对于非局部静态对象肯定会被构造, 如果这部分程序/库被调用]

e.g. 使用这一技术:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class FileSystem { ... }; // 同前
FileSystem& theFileSystem() // 这个函数代替了theFileSystem 对象
{
    static FileSystem tfs; // 定义和初始化
// 局部静态对象(tfs = "the file system")
    return tfs; // 返回它的引用
}
class Directory { ... }; // 同前
Directory::Directory()
{
//同前,除了 theFileSystem 被theFileSystem()代替;
}
Directory& tempDir() // 这个函数代替了tempDir 对象
{
    static Directory td; // 定义和初始化
// 局部静态对象
    return td; // 返回它的引用
}

>用户和以前一样的编程, 只是用theFileSystem()/tempDir()代替了theFileSystem/tempDir; 所用的是返回对象引用的函数, 而不是对象本身;

返回引用于的函数本身很简单, 第一行定义并初始化局部静态对象, 第二行返回它; 因为简单, 你可能想要把它声明为inline, 条款33指出, 对于C++语言规范来说, 这是一个有效的实现策略; 但同时指出, 使用前一定要确认编译器和标准中的要求一致; 否则, 可能造成函数以及函数内部静态对象有多份拷贝;

为了使这技术有效, 一定要给对象一个合理的初始化顺序; 如果A必须在B之前初始化, 同时又让A的初始化依赖于B已经被初始化, 就会有麻烦; [initialize()]

---YC---

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值