C++容易被忽略的法则

本文主要针对 C++ 中一些最常见惯例和 bug 进行描述。

1、构造函数

1.1 初始化和赋值

C++ 中当一个对象被创建时,会有初始化的操作;而赋值是用来修改一个已经存在的对象的值,此时没有任何新对象的产生。

Object t = x; // 初始化,有新对象的产生
t = x;        // 赋值,对已有对象的值进行修改

初始化出现在构造函数中,而赋值出现在 operator= 操作符函数中。当使用另一个对象初始化一个新对象时,复制构造函数被调用。

1.1.1 复制构造函数

当创建一个类时,如果没有定义复制构造函数则编译器会自动为我们合成一个,这个缺省的复制构造函数会将对象中的每个数据成员初始化为原对象中相应成员的值。此时的初始化为浅拷贝(只复制指针,而不复制指针指向的内容,两个指针指向同一个对象),如果对象中存在指针,往往需要深拷贝(即指针指向的内容也要复制一份),此时就要自己定义复制构造函数。

class String
{
private:
    char *data; // 指针对象
public:
    String(const char* cp = "");
    ~String(){ delete
}

String src("hello");
String dst(src);// 浅拷贝

上面的 String 类缺省复制构造函数将会仅仅对指针进行复制,最终导致 srcdst 指向同一块内存 hello,当 dst 销毁时,src 内容也被销毁,将变成悬挂的空指针。

// 自定义复制构造函数,深拷贝
String::String(const String& s)
:data(new char[strlen(s.data)+1])
{
    strcpy(data, s.data);
}

1.1.2 赋值操作符函数 operator=

一般当缺省的复制构造函数不符合要求时(浅拷贝),缺省的赋值操作符函数(编译器为我们自动合成的)也不符合要求,此时也要自定义赋值操作符。赋值操作符定义要注意自赋值s = s)操作。operator= 应返回被赋值对象的常量引用,为了满足平行赋值,如 a = b = c

// 赋值操作符函数
const String& String::operator=(const String& s)
{
    if(&s != this) // 防止自赋值
    {
        delete[] data; // 先删除原始的空间,再分配新空间
        data = new char[strlen(s.data)+1];
        strcpy(data, s.data);
    }

    return *this;
}

1.1.3 类成员初始化顺序

C++规定,类成员的初始化顺序和他们在类中声明的顺序一致,而不是构造函数初始化列表中的顺序。如果使用类中未初始化的成员来初始化其他成员,结果是未定义的。析构过程和初始化过程相反。

构造函数中,初始化列表的语句叫做初始化,总会在函数体语句前执行,而构造函数函数体中的语句叫做赋值。在执行函数体之前,即使初始化列表为空,编译器也会为类成员变量进行初始化,此时会调用成员变量的缺省构造函数(如果成员变量是对象的话),然后再执行函数体中的赋值操作。因此,最好在初始化列表中初始化对象成员变量,否则会调用两次操作(一次初始化,一次赋值操作),影响效率(基本类型影响不大)。

如果在类中的某个非静态数据成员是一个引用,因为所有的引用都必须明确的初始化,所以必须在该类的每个构造函数中使用初始化语法。

class Employee
{
private:
    String name;
    int salary;
public:
    Employee(const String& nm, int sal);
}

// 初始化列表
Employee::Employee(const String& nm, int sal)
:name(nm), salary(sal)
{}

1.1.4 静态对象的构造顺序

C++ 编译系统确保所有的静态对象在他们被使用前都会被初始化。很多编译器都是将多有的静态对象放到 main 函数之前进行初始化。

对于在同一个编译单元中出现的静态对象,初始化的顺序和他们在代码中定义的顺序一致,析构和构造顺序相反。在不同文件中的初始化操作顺序使未定义的。

2、操作符重载

按 C++ 语言定义,=[]()-> 这四个操作符必须实现为成员函数。

建议所有一元操作符以及 +=-=*=/=^=&=|=~=%=>>=<<= 定义为成员函数,而所有其他的二元操作符定义为非成员函数。

3、缺省的指针、引用和模板参数

当为指针参数提供缺省值时,连在一起的 *= 会被当做一个符号处理,因此需要在 *= 中间加一个空格。对于缺省引用 &= 也一样。

void funp(const char* = ""); // * 和 = 之间有空格
const string empty;
void fund(const string& = empty); // & 和 = 中间有空格

当模板的类型也为模板时,模板类型结尾的 >> 之间要有空格,否则会被误解为右移操作符。

vector<vector<int> > iv; // int 后面的两个 > 之间要加空格

4、继承

4.1 is-a,has-a,use-a

继承描述的是 is-a 的关系。一个对象时另一个对象的子集。

has-a 意味着包含,一个对象时另外一个对象的一部分。在 C++ 中,has-a 关系通常实现的方式是将被包含的对象作为包含它的对象的一个成员。

use-a 相互使用,在这个关系中没有一个对象是另一个对象(is-a),也没有一个对象包含另一个对象(has-a),而是程序调用彼此的成员函数进行联系。

4.2 虚函数和接口

虚函数是为了实现动态绑定,使程序根据需要使用基类的指针调用派生类中重写的虚函数。

在虚函数声明的参数列表后面加上 =0 ,该函数就变成纯虚函数。

声明了纯虚函数的类叫做抽象类。抽象类不能实例化,实例化抽象类会导致编译期错误。

C++ 中有接口的含义,却没有接口的具体实现。C++ 的接口都是通过纯虚函数来实现的。我们不需要为接口声明一个完整的实现,这些都由其之类来完成。

4.3 没有被继承的东西

  • 构造函数(包括复制构造函数)。如果我们没有声明复制构造函数,编译器就会为我们创建一个。这个被创建的复制构造函数会调用类中所有的非静态的数据成员以及基类的复制构造函数。

  • 析构函数。如果没有声明,编译器会为我们创建一个析构函数,它会调用类中所有的非静态的数据成员以及基类的析构造函数。如果类中的某个基类的析构函数是虚函数,那么合成的析构函数也是虚函数。

  • 赋值操作符。如果没有声明,编译器会为我们创建一个,它会调用类中所有的非静态的数据成员以及基类的赋值操作符。

  • 被隐藏的成员函数。如果基类中存在成员函数在派生类中没有被重写,并且在派生类中还声明了一个和该函数有着相同的名字但参数列表不同的成员函数,那么在基类中的那个成员函数就将被隐藏。

class Car
{
public:
    void steer(int degrees);
};

class Autopilot
{
public:
    Autopilot();
};

class Smart_car:public Car
{
public:
    void steer(Autopilot&); // Car::Steer(int) 被隐藏了
};

Smart_car c;
c.steer(45); // 编译错误:无法将 Autopilot 转化为 int

如果不希望隐藏基类的函数,就应该在派生类中对它进行重新声明:

class Smart_car:public Car
{
public:
    void steer(int i){ Car::Steer(i); } // 调用基类的steer()
    void steer(Autopilot&); 
};

4.4 构造函数和析构函数中调用虚函数

当在构造函数和析构函数中调用虚函数时,运行结果可能不同。

当构造函数正在创建派生类中的基类部分时,被构造的对象就视为基类(而不是派生类)的一个对象。这就是说我们调用的虚函数将会是正在被构造的基类中的那个成员函数,而不是派生类中的成员函数。这是因为对象基类部分的构造要早于其数据成员,此时派生类的数据成员还没有被构造。

class Base
{
public:
    Base(){ print();}
    virtual void print(){ cout<<"Base::Base();\n"; } // 虚函数
};

class Derived:public Base
{
public:
    Derived(){ print(); }
    void print(){ cout<<"Derived::Derived();\n"; }
};

int main()
{
    Base b;
    Derived d;
}

上面的程序输出:

Base::Base();
Base::Base();

析构函数也是一样,因为当调用基类的析构函数时,派生类中的数据成员早已经被析构了,因此,调用派生类中的虚函数也就无意义。

注意:只有在构造和析构的过程中调用虚函数才会导致这种特殊的行为。在其他情况下(即使在构造函数或析构函数中调用虚函数),虚函数的行为也是正常的。

Base::Base()
{
    print(); // 调用的是 Base::print()
    Base *bp = new Derived;
    bp->print(); // 调用的是 Derived::print()
}

4.5 多重继承

4.5.1 多重继承描述的是对象间的交集,而不是合集。

class D:public B1, public B2{};

则说明 B1 和 B2 所描述的对象间存在着一个非空的交集,否则不能使用多重继承。

4.5.2 虚基类用来避免在多重继承中派生类的同一对象出现多份基类子对象。

class A
{
public:
    int i;
}

class B:public A{};
class C:public B{};
class D:public B, public C{}; // 多重继承

上面代码中,一个 D 对象中同时存在两个不同子对象 A。D 中直接存取 i 将出现歧义。

int main()
{
    D d;
    d.i;    // 存在歧义
    d.A::i; // 歧义,是 B 中的 i, 还是 C 中的 i
    d.B::i; // 可以,B 子对象中的 A 子对象的 i
    d.C::i; // 可以,C 子对象中的 A 子对象的 i
}

把 A 当做虚基类可以解决这一问题。对象中所有虚基类的实例都是共享的

class A{};
class B:public virtual A{};
class C:public virtual B{};
class D:public B, public C{}; // 多重继承

此时,B 和 C 共享了同一份 A 子对象。

4.5.3 虚基类的初始化

虚基类的初始化是在最外层派生类中进行的。(可以想象:如果虚基类由直接派生类进行初始化,那就有可能被多个派生类分别初始化多次,最后到底选那个值?这也就失去初始化的意义)。

class Vehicle
{
public:
    Vehicle(double x = 0.0, double y = 0.0):x(x),y(y){}
    double getX() { return x; }
    double getY() { return y; }
private:
    double x, y;
};

class LandVehicle:public virtual Vehicle
{
public:
    LandVehicle(double x = 0.0, double y = 0.0)
    :Vehicle(x, y) {}
};

class Tank:public LandVehicle
{
public:
    Tank(double x = 0.0, double y = 0.0)
    :LandVehicle(x, y) {}
};

int main()
{
    Tank t(1.0, 2.0);
}

它实际上会创建一个 (x, y) = (0.0, 0.0) 的 Tank 对象。因为虚基类 Vehicle 的初始化是在最外层的派生类 Tank 中进行的,由于 Tank 的构造函数没有明确的初始化 Vehicle, 所以程序调用了 Vehicle 的缺省构造函数,LandVehicle 的构造函数中对 Vehicle 的初始化实际并没有执行(因为 LandVehicle 并不是最外层的类)。

要修正这个 bug, 就必须在每个构造函数中对虚基类进行初始化:

Tank::Tank(double x = 0.0, double y = 0.0)
    :LandVehicle(x, y), Vehicle(x, y) {}

4.5.4 多重继承基类间的初始化顺序

  • 派生类中基类的构造顺序和他们在文件中声明的顺序一致,而不是派生列表中的顺序。

  • 派生类中基类部分的析构顺序和构造顺序相反。

4.5.5 多重继承存取类型

派生时应该为每个基类指定存取类型,对于声明为 class 的派生类,缺省存取类型为私有的;对 struct 来说,缺省存取类型为共有的。

class Base1 {};
class Base2 {};

class Derived1:public Base1, Base2 {}; // Base1 为 public,Base2 为 private

class Derived1:public Base1, public Base2 {}; 
// Base1, Base2 都为 public

5、类存储空间大小

  • 空类的大小为 1。这一字节是为了表示空类的存在。

  • 类的大小只与普通数据成员(与静态数据成员、静态常量数据成员都无关)和虚函数有关,数据成员也讲究字节对齐;每个存在虚函数的类中都有且只有一个指向虚函数表的指针,每个指针占4字节,所以类的大小为:数据成员所占空间(字节对齐)+ 4多重继承时,若基类有虚函数,则派生类中就会有该基类对应的一个虚函数表,派生类中的虚函数则加在第一个虚函数表中,所以多重继承时派生类的大小为:数据成员所占空间(字节对齐)+ 4*含有虚函数的基类个数

  • 内部类不占用外部类的大小。使用 sizeof 计算外部类大小,内部类对外部类大小无影响。
class A
{
public:
    class B
    {
    public:
        B(){};
    private:
        int b;
    };
private:
    int a;
    char c;
    static int d; // 静态数据成员
};

则类 A 所占存储空间大小为 8 字节(注意字节对齐),类 B 所占存储空间大小为 4 字节。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值