c++的陷阱


正确的初始化对象

对于内置数据类型,系统不会去初始化, 因为这会增加运行成本.
所以, 永远在使用内置类型前将它们手动初始化;

对于内置类型以外的东西, 系统会自动调用构造函数来初始化。
我们要确保每个构造函数都将对象的每一个成员做了初始化.

初始化对象的成员时,必须分清初始化和赋值的区别,防止二次初始化。
using std::string;
class Player{
        Player(string &s, int x, int y);
private:
        string _name;
        int _x;
        int _y;
}

错误的示例1:
Player::Player(string &s, int x, int y){
        _name = s;//这是赋值,而不是初始化,string已经被初始化过了。
        _x = x;
        _y = y;
}

正确的示例2,使用成员初值表 :
Player::Player(string &s, int x, int y) :_name(s), _x(x), _y(y){
}
为了避免遗漏,必须在初值表中列出所有成员。

错误的示例3:
Player::Player(string &s, int a) :_name(s), _y(a), _x(_y){
}
C++有着固定的成员初始化次序: 
基类的初始化在派生类之前, 成员变量按照声明顺序被初始化(而不是初值表里的顺序).
示例3原意是用a来初始化_y, 再用_y来初始化_x, 实际结果确导致_x是一个未知数。
成员初值表里的顺序必须和声明的顺序相同.

有争议的示例4:
Player::Player(string &s, int x, int y) :_name(s){
        _x = x;
        _y = y;
}
这样的编码方式带来了一些隐患,比如成员被遗漏,但也带来了某些好处:
由于内置类型不会被自动初始化,所以它们在成员初值表和构造函数体内进行初始化, 在性能和结果上都是一样的。
对于有多个构造函数的类, 每个构造函数都应该有自己的成员初值表.
这会导致不受欢迎的重复. 如果我们把初始化内置数据类型成员的部分放到一个通用的函数内,就可以消除部分重复代码。

什么时候需要一个虚析构函数

class Base{
public:
    Base(){printf("Base constructor\n");}
    ~Base(){printf("Base distructor\n");}
};
class Derived: public Base{
public:
    Derived(){printf("Derived constructor\n";}
    ~Derived(){printf("Derived distructor\n";}
};
可以看出,Base没有虚函数,不具有多态性质。
如果写出这样的代码:
Base* pBase = new Derived();
delete pBase;
运行结果如下:
Base constructor
Derived constructor
Base distructor
可以看到, Derived的析构函数没有被执行,在实际运用这可能导致很严重的问题。比如内存泄漏

如果基类不具有多态性质,就不能进行多态编码。
如果我们希望基类带多态性质, 或者类中至少有一个虚函数, 就应该申明虚析构函数.
但也不要无端的将所有类的析构函数都声明为虚, 因为这会增加对象的体积, 并降低代码的可移植性。


绝不在构造函数和析构函数中调用虚函数

class Base{
public:
    Base(){Fun();}
    virtual ~Base(){Fun();}
    virtual Fun(){printf("Base Fun\n");}
};
class Derived: public Base{
public:
    Derived(){}
    virtual ~Derived(){}
    virtual Fun(){printf("Derived Fun\n");}
};

Derived *p = new Derived();
delete p;
运行结果如下:
Base Fun
Base Fun
可见, 不管是构造还是析构,都没有实现多态。原因是:
要实例化 Derived , 首先会调用 Base 的构造函数, 遇到 Fun, 由于 Fun是虚函数, 
应该下降调用 Derived 的 Fun函数, 但是Derived 此时还没有被实例化.
实际上只能调用的是 Base 的Fun 函数.
析构的过程是一样的。

程序员应该避免这种不合理的设计出现. 不要再构造函数和析构函数中调用虚函数。
在这两个过程中,多态无法实现。
而且, 构造函数和析构函数调用的函数里也不能嵌套 virtual 函数.


防止隐式调用构造函数带来的意外

如果构造函数只有一个参数, 或者有多个参数但除第一个参数外其余参数都有默认值, 那么这个构造函数承担了两个角色:
1 是个构造器 ,
2 是个默认且隐含的类型转换操作符。
所以, 有时候在我们写下如 A = X, 这样的代码, 且恰好X的类型正好是A单参数构造器的参数类型, 这时候编译器就自动调用这个构造器, 创建一个A的对象。
这样看起来好象很酷, 很方便。 但在某些情况下, 却违背了我们的本意。 
这时候就要在这个构造器前面加上explicit修饰, 指定这个构造器只能被明确的调用,不能作为类型转换操作符被隐含的使用。
class Test1{
public:
        Test1(int n):_num(n){}//普通构造函数
private:
        int _num;
};
class Test2{
public:
        explicit Test2(int n): _num(n){}//explicit(显式)构造函数
private:
        int _num;
};
Test1 t1=6;//隐式调用其构造函数,成功
Test2 t2= 6;//编译错误,不能隐式调用其构造函数
Test2 t2(6);//显式调用成功

了解编译器可能自动生成那些成员函数

如果你写下:
class Empty{};

编译器会自动为你写下这些代码:
class Empty {
public: 
        Empty(){...}                                                           // 构造函数
        Empty(const Empty& rhs){...}                              // 拷贝构造函数
        Empty& operator=(const Empty& rhs){...}        // 赋值函数
        ~Empty(){...}                                                        // 析构函数
 };
如果你自己声明了某个函数, 编译器就不再默认创建它


区分拷贝构造函数和赋值函数,并正确的实现它们


拷贝构造函数和赋值函数非常容易混淆,常导致错写、错用。

拷贝构造函数是在对象被创建时调用的,而赋值函数只能被已经存在了的对象调用。

以下程序中,第三个语句和第四个语句很相似,你分得清楚哪个调用了拷贝构造函数,哪个调用了赋值函数吗?
  String a(“hello”);
  String b(“world”);
  String c = a; // 调用了拷贝构造函数,最好写成 c(a);
  c = b; // 调用了赋值函数
本例中第三个语句的风格较差,宜改写成String c(a) 以区别于第四个语句。


如果不主动编写拷贝构造函数和赋值函数,编译器将以“位拷贝”的方式自动生成缺省的函数。
倘若类中含有指针变量,那么这两个缺省的函数就隐含了错误。

以类String 的两个对象a,b 为例,假设a.m_data 的内容为“hello”,b.m_data 的内容为“world”。

现将a 赋给b,缺省赋值函数的“位拷贝”意味着执行b.m_data = a.m_data。这将造成三个错误:

一是b.m_data 原有的内存没被释放,造成内存泄露;

二是b.m_data 和a.m_data 指向同一块内存,a 或b 任何一方变动都会影响另一方;

三是在对象被析构时,m_data 被释放了两次。


正确的实现类String 的拷贝构造函数与赋值函数
// 拷贝构造函数
String::String(const String &other){

  // 允许操作other 的私有成员m_data
  int length = strlen(other.m_data);
  m_data = new char[length+1];
  strcpy(m_data, other.m_data);
}
// 赋值函数
String & String::operator =(const String &other){
  // (1) 检查自赋值
  if(this == &other)
         return *this;
  // (2) 释放原有的内存资源
  delete [] m_data;
  // (3)分配新的内存资源,并复制内容
  int length = strlen(other.m_data);
  m_data = new char[length+1];
  strcpy(m_data, other.m_data);
  // (4)返回本对象的引用
  return *this;
}
类String 的赋值函数比构造函数复杂得多。因为它要检查自我赋值。
你可能会认为多此一举,难道有人会愚蠢到写出 a = a 这样的自赋值语句!

这可不好说。而且,间接的自我赋值很有可能出现,例如
  a[i] = a[j]; //i,j可能相等

       *px = *py;//两个指针指向同一个地址

自我赋值会导致严重的问题。看看第二步的delete,自杀后还能复制自己吗

所以,如果发现自赋值,应该马上终止函数。注意不要将检查自赋值的if 语句
  if(this == &other)
  错写成为
  if( *this == other)
注意函数strlen 返回的是有效字符串长度,不包含结束符‘\0’。函数strcpy 则连‘\0’一起复制。
返回本对象的引用,目的是为了实现象 a = b = c 这样的链式表达。


如果我们实在不想编写拷贝构造函数和赋值函数,又不允许别人使用编译器生成的缺省函数,怎么办?
只需将拷贝构造函数和赋值函数声明为私有函数,并不予实现。


更多自我赋值的思考

我们通常用“证同测试”来避免自我赋值,比如这样
String & String::operator =(const String &other){
  if(this == &other)
         return *this;
  delete [] m_data;
  m_data = new char[strlen(other.m_data)+1];
  strcpy(m_data, other.m_data);
  return *this;
}
实际上,自我复制的发生频率很低,这样证同测试就有点影响效率了。
有一个办法既可以避免自我赋值,又不需要证同测试。
String & String::operator =(const String &other){
       char* p = m_data;
  m_data = new char[strlen(other.m_data)+1];
  strcpy(m_data, other.m_data);
       delete p;
  return *this;
}
还有一个十分巧妙并且更高效的方法:
String & String::operator =(String other){//注意这里是传值
       char* p = other.m_data;
       other.m_data = m_data;
       m_data = p;
       return *this;
}
酷!它把数据拷贝动作从函数体内转移到函数参数的构造阶段,可以让编译器生成更高效的代码。
但是它牺牲了清晰性,请酌情使用。


正确理解对象占用的内存

理论上, 空对象不占用内存, 但是系统为了区分不同的空对象, 仍然分配了一小块地址, 通常是1字节.
系统为每个类维护了一个方法表., 所以方法不会占用对象的内存, 如果一个对象只有方法没有变量, 它占用的内存也是1字节.
类只需要为它的每个静态变量维护一个实例, 所以静态变量也不会占用对象的内存.

如果类包含虚函数, 系统需要为它维护一个虚函数表. 为了实现多态, 对象必须保存它所属的类的虚函数表的指针.
所以一个只有虚函数的类, 它的对象占用的内存是一个指针大小, 4字节.

虚函数表是一个在内存上连续的函数指针数组, 函数指针分别指向类中的每一个虚函数.
虽然一个子类的对象地址可以被赋值给一个基类指针, 由于这个对象保存了它真正的类的虚函数表地址, 所以它总能正确的调用自己的虚函数.


正确的在循环中利用迭代器删除map中的元素

错误的方法, 方法 1:
for (iterator it=map.begin(); it!=map.end(); it++){
        if (it->first == a){
            map.erase(it);
        }
}

正确的方法, 方法 2:
for (iterator it=map.begin(); it!=map.end(); ){
        if (it->first == a){
            map.erase(it++);
        }else{
            it++;
        }
}

这样也可以, 方法 3:
for (iterator it=map.begin(); it!=map.end(); it++){
        if (it->first == a){
            it = map.erase(it);
            it--;
        }
}

为什么方法2是对的? 方法1和方法2不是一样的吗?
it++操作主要做三件事情:
1、首先把it备份一下。
2、把it加上1。
3、返回第一步备份的it。

因此,map.erase(it++);

在执行erase之前,it已经被加一了。

erase会使得以前那个未被加一的it失效,而加了一之后的新的it是有效的。

这段代码的真正等效代码是

iterator iterTemp = it;
++it;
map.erase(iterTemp);


反之,如果这样:

map.erase(it);
it++;

那么erase操作直接让it失效,对失效的it进行加一操作也是失效的。


删除map中的一个节点后, map会发生什么变化?

map中其它节点的地址不会发生变化, 变化的只是某些节点的左右节点指针.


明白string带来的额外内存开销

观察一块测试代码

string s;

cout<<sizeof(s)<<endl;//28

cout<<s.size()<<","<<s.capacity()<<endl;//0,15

s.resize(8);

cout<<s.size()<<","<<s.capacity()<<endl;//8,15

s.resize(16);

cout<<s.size()<<","<<s.capacity()<<endl;//16,31

s.resize(1000);

cout<<s.size()<<","<<s.capacity()<<endl;//1000,1007

一开始, string 是空的, 它的 size 是 0, 

但是在实例化的时候, 它已经预先申请了一块内存备用.

这块内存的大小是 15, 我推测, 应该是 16, 有一个字节被内部使用了.

另外, string 的内部数据占用了 28 个字节, 所以一个空string, 也要使用 44 个字节的内存. 有点心疼呀.....

随着 string 里数据的增加, 申请的内存总是 16 的倍数. 










评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值