C++对象浅谈
last-edit-by: lungangfang 12/03/2006>
引言
感谢 training team 给我这个机会和大家交流我学习、使用C++的一点心得体会,
也感谢大家抽出宝贵的时间来和我交流。
面向对象是C++最重要的性质之一。既然是面向对象,自然就离不开对象的创建、销
毁和修改。今天我就想和大家探讨一下这方面的内容。
今天的内容是这样安排的:首先想讲一下“对象的生成”其中包括“对象生成时机”
和“限制对象的生成”,接下来再讲“对象的销毁”。然后再介绍一个充分利用构造、
析构函数避免内存泄漏的的技巧:“resource acquisition is initialization”。
之后再谈谈利用 std::auto_ptr 扩展这个技巧的使用范围。接着是异常处理机制中
和对象创建、销毁相关的一些注意事项。最后是“对象的修改”,这一部分主要讨
论用关键字 const 和 mutable 还有c++风格的强制类型转换来贯彻最小权限原则。
对象的生成
C++提供的以构造函数和析构函数为主的对象生成、销毁机制最大好处是使得程序员
可以定制对象生成或销毁时的行为,编译器会确保这些行为在相应的情况下执行。
这既简化了代码又避免了手工调用这些操作可能出现的遗漏和错误。不仅如此,程
序员还可以控制是否允许对象被构造、复制。
对象生成时机
首先,我们看看什么时候会生成新的对象。
class B;
class C;
变量定义
C a; C b = a;
函数调用
参数、返回值传值调用
B f(C input){ B ret;
return ret; }
类型转换
AClass a;
BClass b = static_cast<BClass>(a);
前面几种场景相对来说还比较容易发现,自动类型转换产生临时对象相对而言就十
分隐蔽了。
class AClass{
public:
AClass(int i);
};
AClass a = 5; a == 3;
作为成员变量或基类
初始化列表
调用构造函数时,进入函数体之前,会先构造所有的成员变量(静态成员变量除外)
和基类部分。
这个时候我们常常会用到初始化列表:
DerivedClass::DerivedClass(T a1, T a2)
: Base(1), m1(a1), m2(a2)
{ }
初始化列表的好处:
- 可以给成员变量、基类的构造造函数传递参数;
- 可以用来初始化成员常量;
- 避免了“先构造-再赋值”带来的问题(效率、复杂度)。
成员变量的初始化(构造)顺序
需要注意成员变量是按照它们在类定义中出现的顺序的被构造,而不是在初始化列
表中的顺序。因为:初始化列表可以有多个(每个构造函数都可以有自己的初始化
列表),但类定义只有一个。
对象的生命周期从构造函数后开始
所有成员构造完成后,对象还不算构造完成。只有构造函数的函数体成功执行完毕
后一个对象才算是生成了。换句话说,构造函数没执行完,一些针对对象的机制不
一定会作用在这个半拉子对象上。例如栈解退(stack unwinding)就不会析构这个
对象。
限制对象的生成
从上一节的讨论可以看出:程序复杂到一定程度后,程序员很容易在无意间复制对
象。有时这无关紧要,但有时这些意料之外的对象构造会带来严重后果。我们需要
利用C++的语言特性来确保不发生不期望的对象构造。
使用关键字explicit——避免自动类型转换
用关键字"explicit"修饰只有一个参数的构造函数,避免它被用于自动类型转换:
class AClass
{
public:
explicit AClass(int i):m_i(i){ };
};
AClass a(1); AClass b = static_cast<AClass>(2);
阻止编译器自动生成函数——避免拷贝构造和赋值
如果我们没有为某个类定义构造函数、拷贝构造函数(还有赋值操作符、析构函
数),编译器会自动生成它们。这个设计在大多数情况下方便了程序员,但也带来
相应的问题:一定要确保每个类要么有合适的拷贝构造函数和赋值操作符,要么就
根本没有这两个函数,千万别允许编译器为你生成不合用的函数。
如果确实不需要这些函数,或者说某个类的拷贝构造、复制操作没有意义怎么办呢?
这种情况下,一般的做法是将它们的拷贝构造函数和赋值操作符声明成私有成员,
而且不定义它们(即只有函数声明没有函数体)。这样,如果对该类对象作相应操
作,编译时就会报"没有权限"的错误。即使有权限,也会在链接时报"找不到函数体
"的错误。
class AClass{
public:
AClass();
private:
AClass(const AClass&);
AClass& operator=(const AClass&);
};
抽象类——禁止实例化该类
有时候为某个类创建对象没有任何意义:
- 接口类:一个类被作为抽象的公共接口;
- 工具类:一个类提供了若干通用工具(函数)。
这两种情况下,我们可以将这个类定义成“抽象类(abstract class)”,也就是
为其定义至少一个纯虚函数(pure virtual function)使之不能实例化。
class AClass{ virtual void f() = 0; };
BTW: “此类能且只能生成一个对象”的要求应当用设计模式中的单例模式来实现。
对象的销毁
对象生存期结束时,程序会调用它的析构函数。
生存期结束的几种情况
- 自动(auto)变量出了作用域
- 全局变量退出main函数之后
- 自动变量被栈解退(其实和第一种类似)
- 动态创建的变量被显式地(explicitly)delete
析构函数
基类的析构函数必须定义成虚函数
为什么?看例子:
int main(){
BaseClass* b = new DerivedClass();
delete b;
b = 0;
}
基类的析构函数无从知道子类的构造函数中又申请了哪些资源、做了哪些操作,也
就无法做相应的清理工作。如果忘记将基类的析构函数定义成虚函数,程序很可能
会core dump。
纯虚析构函数也必须定义函数体
基类的虚构函数也可以根据需要定义成纯虚函数。但是,即便是纯虚函数,也必须
为这个析构函数定义函数体。
反过来,它的子类则不必明确定义虚构函数:编译器会帮我们生成一个。
class BaseClass{
public:
virtual ~BaseClass() = 0;
};
BaseClass::~BaseClass(){}
class DerivedClass : public BaseClass {
public:
};
继承类的析构
不论基类的析构函数是虚函数还是纯虚函数,执行继承类的析构函数时都会调用基
类的析构函数。
resource acquisition is initialization
C++程序中有个避免资源泄漏的常见方法巧妙地利用了构造函数和析构函数,那就是
被称为“resource acquisition is initialization”的技巧。其核心思想是利用
编译器会自动构造、析构自动变量这一特点,让编译器记得帮我们释放资源,以避
免“手工操作”造成遗漏或错误。具体做法是定义一个资源对象,在其构造函数中
申请资源,其析构函数中释放资源。这样需要申请资源时就定义一个资源对象,资
源对象超出生存期后,编译器会去调用资源对象的析构函数来释放资源。
试比较下列三种方法:
方案一:易出错,可读性也不好(略)
多处负责释放资源;每处释放的资源和想申请的资源还不一样;各处释放资源数目
也不一样;还有多个return语句。
if (! getResource1()){
return;
}
if (! getResource2()){
return;
}
if (! getResource3()){
return;
}
方案二:出错概率稍低,但代码可读性更差(略)
多处释放资源,但每处只要释放自己申请的资源。
if ( getResource1()){
if ( getResource2()){
if (getResource3()){
} else { }
free resource2
} else { }
free resource1
} else {
}
方案三:安全性、可读性大幅提高
采用“resource acquisition is initialization”(但也存在控制流程跳转的问
题)
try {
ResClass1 a; ResClass2 b; ResClass3 c; }
catch (ex1& e){
}
catch (ex2& e){
}
catch (ex3& e){
}
实例: mutex Lock
{
MutexLock lock(mutex);
}
std::auto_ptr
前面提到的“resource acquisition is initialization”有个应用前提是资源对
象为自动变量。如果对象是动态创建(在heap上)的,就无法直接应用这个技巧了。
因为编译器不会去自动销毁这个对象。这时可以把这个资源再封装一下,封装到一
个局部变量中。STL已经为我们提供了这样的工具类:std::auto_ptr。
std::auto_ptr是STL中一个很重要的工具,它可以用来持有动态创建的对象。出作
用域的时候它会自动删除所持有的对象。这样我们就可以把动态创建的对象看成是
自动变量,不用担心忘记销毁它们。所以每次动态创建对象的时候,我们都应该看
看是否能利用std::auto_ptr避免资源泄漏。
用法示例
void f(){ std::auto_ptr<AClass> ap (new AClass());
}
设计思路
From STL4.6.2 _auto_ptr.h:
explicit auto_ptr(_Tp* __px) { this->__set(__px); }
~auto_ptr() { delete this->get(); }
_Self& operator=(auto_ptr_ref<_Tp> __r) {
reset(__r.release()); return *this;
}
注意事项
- 它不能用来存放数组。可能是因为大部分时候std::vector和std::string就已
经够用了,所以STL中没有与auto_ptr相对应的auto_array。不过如果真的有特
殊需要的话,也很容易仿照std::auto_ptr写个auto_array处理动态数组。
- 它不能作为标准容器的元素:它采用了所有权转移的机制,因此不符合STL容器
对元素的要求。(copyable、assignable and destroyable)
- 函数调用中的std::auto_ptr
传入:
auto_ptr<T> ap(new T(3));
f(ap);
传出:
auto_ptr<T> AClass::f() {
return m_ap; }
对象与异常
栈解退(stack-unwinding)
- “栈”解退而非“堆”解退
- “解退”的是 完整的 对象: 正在构造的对象不会被析构
写“异常安全(exception-safe)”的代码
牢记“异常无处不在”,每写一行代码都要考虑是否可能会抛出异常
for (;;){
delete p;
p = 0; p = new Object();
}
避免构造函数泄漏资源
既然正在构造的对象不会被析构,构造函数申请的资源它自己要负责释放掉
AClass::AClass(){
applyResource();
ResourceClass res;
try {
} catch (ex& ) {
freeResource(); throw;
}
}
别让析构函数抛出异常
析构函数中抛出异常是一件很危险的事情:堆栈解退及异常处理模块中不允许再抛
出任何异常。所以,析构函数应当不抛出任何异常,除非你已做好程序崩溃的心理
准备。
~AClass{
try {
}
catch (...){ }
}
避免构造异常对象时抛出异常
- 异常类构造函数不抛异常
- “catch block”传引用
try {
}
catch (ex& ){
}
全局变量
全局变量除了“初始化顺序无法控制”、“操作流不明显”等一贯就有问题外,对
“异常安全性”也有影响:没有方法能处理全局对象构造函数抛出的异常。
“不抛出异常”的相对性
实际上很多操作都可能出错,很多函数都可能“throw”。但是我认为,我们讨论
“异常”应该限定在我们“想处理”的错误中。例如:理论上申请一片动态内存是
有可能出错的,但是一般的应用都假定不会出现这种错误或者是即使出现了错误也
不做任何处理,因此一般讨论的“异常”并不包含分配内存这种情况。在这个前提
下,异常处理过程中因申请内存失败而抛出异常也就可以接受了。
对象的修改
最小权限原理
权限越大,“能”犯的错误就越多。所以有最小权限原则,即只赋予工作者尽量小
的权限,理想情况下工作者具有的权限足够完成任务但又无法干其它任何事情。
C++的关键字“const”和C++风格强制类型转换就体现了这一点。
关键字 const 和 mutable
const 修饰常量与形式参数
const 可以而且应当尽量用来修饰常量(或函数的形式参数)告诉编译器该数据不
会改变。将常量用“const”标明的最大好处是可以在编译器就发现不期望的修改。
而且也有助于代码优化——不论是编译器还是程序员自己进行优化。比如说“copy
on write”。
const 对传值调用也有意义
void f(const int i){
++i; }
基本类型的常量可以定义在头文件中
const常量默认为“internal linkage”,所以const常量定义在头文件中是合法的。
但是这么做会导致每个包含该头文件的编译模块拥有一份该常量的拷贝。
一般来说,基本类型的常量(尤其是整型)可以定义在头文件中,好处是:
- 可以确保各个编译模块中的常量保持一致。
- 可以用整型常量来定义静态数组。
静态数组长度一般用整型字面常量或者相应的宏表示(T array[LEN])字面常量的缺
点显而易见。宏应用在此处虽然没什么大问题但毕竟还是存在一定缺点。用整型常
量相对来讲是最好的选择。可是由于定义静态数组时数组长度必须已知,所以整型
常量的值必须在静态数组定义时可见。如果整型常量定义在另一个.cc文件中,就无
法用该常量来定义数组。反之,如果该常量定义在头文件中且该头文件又被包含则
可。
错误:
extern const int ARRAY_LEN;
#include "a.h"
const int ARRAY_LEN = 10;
#include "a.h"
int a[ARRAY_LEN];
正确:
const int ARRAY_LEN = 10;
#include "a.h"
int a[ARRAY_LEN];
但是复合类型常量别定义在头文件中
例子(跳过?)
test.h
#include <iostream>
#ifndef TEST_H
#define TEST_H 1
using namespace std;
class CA
{
public:
static int i;
CA() { ++ CA::i;}
};
const CA a;
#endif
test1.cc
#include "test.h"
void f()
{
cout << a.i << endl;
}
test.cc
#include "test.h"
void f();
int CA::i = 0;
int main(int argc, char argv[]){
f();
cout <<a.i <<endl;
return 0;
}
compile & result (cygwin)
g++ test.cc test1.cc
result:
2
2
const 修饰成员函数
const 还可以用来修饰成员函数以表明它不会破坏对象的逻辑不变性。
物理不变与逻辑不变
逻辑不变(Logical Constness)指的是对象的呈现给用户的状态不变,但它的成员
变量是否变化则不一定。与逻辑不变相对应的还有物理不变(Physical
Constness)。所谓物理不变指的是对象的任何成员变量都不作任何改动。有时两者
是一致的,但有很多时候两者并不一致。例如:假设有如下多线程环境下的set类,
它的成员函数getData()获取指定键值的元素。
例子: MtSet
template <typename _K_,
typename _Compare_=
std::less<_K_> >
class MtSet{
public:
_K_ getData(const key_type& key) const{
MutexLock lock(_mutex_);
return (_set_.find(key));
}
public:
std::set<key_type> _set_;
mutable Mutex _mutex_;
};
显然,从逻辑上说getData()不修改调用它的对象,即不破坏对象的逻辑不变性;但
是为了线程同步,getData()必然要先锁住互斥锁(_mutex_),也就必然破坏对象
的物理不变性。
关键字 mutable
上一节讲到const成员函数应当保持对象逻辑上不变。但是一个成员函数被定义成
const成员后,编译器禁止它修改对象的任何属性。如果成员函数确实需要在不破坏
对象逻辑不变性的前提下修改某一属性就需要借助关键字 mutable 了。
关键字 mutable 表示被修饰者在任何情况下都不为常量。上例中MtSet把_mutex_定
义成mutable变量。不论MtSet的对象是否为常量,_mutex_都是一个“变”量。
C++风格的强制类型转换
static_cast, const_cast, dynamic_cast 和 reinterpret_cast
细分应用场景,增加检查
unsigned char u2 = (unsigned char)("hello");
unsigned char u0 = reinterpret_cast<unsigned char>( "hello");
const unsigned char u1 =
reinterpret_cast<const unsigned char>("hello");
语法形式上更明显
少用强制类型转换
尽管相对于C,C++的强制类型转换有改进,仍然建议尽量不要用强制类型转换。这
也是C++ 强制类型转换的语法故意设计得如此繁琐的原因之所在:提醒大家少用它。
当然,如果必须要用,用C++风格的,别用C风格的。
参考书目
- The C++ Programming Language (Special Edition) by Bjarne Stroustrup
- C++ Standard Library: A Tutorial and Reference by Nicolai M. Josuttis
- C++ Primer (3rd Edition) by Stanley B. Lippman and Josée Lajoie
- Generic Programming and the STL: Using and Extending the C++ Standard
Template Library by Matthew H. Austern
- Thinking in C++, Volume 1: Introduction to Standard C++ (2nd Edition) by
Bruce Eckel
- Data Structures, Algorithms, and Applications in C++ by Sartaj Sahni
- Exceptional C++ by Hurb Sutter
- More Effective C++ by Scott Meyers
- Effective C++ by Scott Meyers
Q & A
Thank you !