C++对象行为

C++中对象的行为

C++中对象的行为就是对象的函数。在C++中对象有以下几类函数:

  • 构造函数

    • 默认构造
    • 有参构造
    • 拷贝构造
    • 移动构造
  • 赋值运算符函数

    • 拷贝赋值
    • 移动赋值
  • 普通函数调用

    • 覆盖函数
    • 重载函数
  • 虚函数调用

    • 纯虚函数
    • 非纯虚函数
  • 析构函数

    • 虚析构函数
    • 非虚析构函数

    由于静态函数是属于类的,而不是对象。所以,静态函数不能算是对象的行为。

  • 三/五原则

对象移动

​ 为了支持对象的移动,需要引入一个新的引用类型 – 右值引用。

  • 在C++中有两种类型的值:

    • 左值:持久、表示一个对象的身份,例如:变量

    • 右值:短暂、表示一个对象的值,例如:表达式计算过程中产生的临时对象字面常量

    • 左值引用

      • 对左值的引用
      • 不可以绑定到右值
      • const的左值引用可以绑定到右值上
      • 左值引用之间可以相互赋值
      int &r1 = i + 10; // 错误,i是一个变量 是左值,但是 i + 10 这个表达式是右值
      const int &r2 = i + 10; // 正确
      
    • 右值引用

      • 对右值的引用
      • 不可以绑定到左值上
      • 右值引用之间不可以相互赋值
      int &&rr1 = 10; // 正确,字面量10是一个右值
      int &&rr2 = rr1; // 错误,rr1是个变量 是左值,尽管rr1是右值引用
      
  • 标准库move函数

    ​ 将一个左值转换为对应的右值引用

    int &&rr1 = 10;
    int &&rr2 = std::move(rr1);
    // 对左值rr1调用move后,不对rr1中的内容做保证,所以不要再使用rr1的值
    

构造函数

​ 构造函数的意义在于完成对象的初始化工作。在继承中,构造函数会调用基类的构造函数完成对象中基类部分的初始化。但是,类的构造函数应该只负责完成自己的成员的初始化

  1. 默认构造函数

    struct Base { Base() { ... } } // 自定义默认构造函数
    struct Base { ... } // 隐式使用编译器版本
    struct Base { Base() = default; } // 显示使用编译器合成版本
    /* 调用 */
    Base b;
    Base b(); 
    Base b = Base(); // 三者都会调用默认构造函数
    
    • 使用new运算符调用构造函数
    /* 内置类型 */
    int *a = new int; // 未初始化,对象的值不确定
    int *a = new int(); // 初始化,对象的值为0
    /* 自定义类型 */
    Base *b = new Base;
    Base *b = new Base(); // 对于自定义类型,两者都会调用默认构造函数
    
    • 与有参带默认值的构造函数会产生歧义
    /* 在调用默认构造函数时,编译器会报错 */
    Base();
    Base(int a = 10); 
    
  2. 有参构造函数

    // 参数可以带默认值,但是带默认的参数必须放在不带默认值的参数之后
    Base(int a, int b = 10); 
    /* 调用有参构造函数 */
    Base b = Base(1, 2);
    Base b = {1, 2}; // 初始化列表调用
    Base *b = new Base(1, 2);
    

    要注意带默认值的参数可能导致调用产生歧义的情况。

  3. 拷贝构造函数

    拷贝构造函数是使用一个已经存在的对象来构造另一个对象。

    Base(const Base&);
    Base(const Base&, int); // 拷贝构造函数可以有其它参数
    
    • 编译器会提供一个合成的拷贝构造函数,对于不同类型的成员执行不同的操作:

      • 内置类型:值拷贝

      • 自定义类型:调用自定义类型的拷贝构造函数

  4. 移动构造函数

    拷贝构造函数在运行时会产生临时对象,分配新内存。所以为了提高效率,使用移动构造函数不会分配新内存。但是,不保证被拷贝对象的内容。

    Base(Base &&b) { ... } // 这里不能用const修饰,因为会修改b绑定的对象的内容
    

    编译器会自动提供合成的移动构造函数,但在以下情况不会合成移动构造函数:

    • 类自己定义了拷贝控制成员(拷贝构造、拷贝赋值、析构)

    以下情况类的移动构造函数被定义为删除的:

    • 类的非静态成员的移动构造函数不能访问或者是删除的
    • 类的析构函数不可访问或者是删除的
    • 类的非静态成员是const或是引用

    移动操作会影响拷贝操作:

    • 定义了移动操作的类,也必须定义自己的拷贝操作。否则,拷贝操作默认是删除的
  • 临时对象

    Base b = Base(); // 这里没有产生临时对象
    b = Base(); // 产生了临时对象
    
  • 隐式类型转换

    Base(int a);
    Base b = 10; // 发生隐式类型转化,int型通过隐式调用Base(int)构造函数,转换成Base型
    Base b = Base(10);
    /* 禁止隐式类型转换 */
    explicit Base(int a);
    Base b = 10; // 编译器报错
    
  • 列表初始化

    列表初始化实际上也是利用了隐式类型转换。

    Base(int a, int b);
    Base b = {1, 2}; // 实际调用了Base b = Base(1, 2)
    /* 如果对应的构造函数是explicit,那么则不能使用列表初始化 */
    explicit Base(int a,int b);
    Base b = {1, 2}; // 编译器报错,在转换为Base(1, 2)时失败
    
  • 传递形参为非引用类型的参数,调用拷贝构造

    void func(Base b) { ... }
    func(Base()); // 没有调用拷贝构造
    Base b;
    func(b); // 调用拷贝构造
    
  • 编译器可以跳过拷贝/移动构造函数

    Base b = Base(); // 实际上应该执行拷贝构造函数,但是编译器跳过该函数,执行了默认构造函数Base b(),但是拷贝构造函数还是必须存在。
    
  • 继承体系下构造函数的行为

    派生类的所有构造函数默认会先调用基类的默认构造函数,但是也可以显示地调用基类其它版本的构造函数。

    struct Base {
        Base() { LOG(); }
        Base(const Base&) { LOG(); }
    };
    struct D: public Base {
        D() { LOG(); }
        D(const D&) { LOG(); }
    };
    struct F: public D {
        F() { LOG(); }
        F(const F&, D &d): D(d) { LOG(); }
    };
    F f; // Base::Base() -> D::D() -> F::F() 
    D d; // Base::Base() -> D::D()
    F f2 = f; // Base::Base() -> D::D(const D&) -> F::F(const F&, const D&)
    

赋值函数

​ 定义赋值运算符函数,赋值运算符重载属于运算符重载中的一种,而运算符重载函数也属于类中的普通函数。

  • 拷贝赋值
Base& operator=(const Base&) { ... }
/* 调用拷贝赋值 */
Base b; // 调用构造函数
b = Base(); // 调用构造函数后,调用拷贝赋值
  • 移动赋值
Base& operator=(Base&&) { ... }
/* 调用移动赋值 */
Base b;
b = std::move(Base());

普通函数

  • 重载

    在一个类中,函数同名但是参数列表不同,返回值并不参与重载。

    struct A {
        void run() { LOG(); }
        void run(int arg) { LOG(); }
        void run(unsigned int arg) { LOG(); }
        void run(float arg) { LOG(); }
        // int run(int arg) { LOG(); } 返回值不参与重载
    };
    A a;
    int arg = 10; unsigned int uarg = 12; float farg = 1.0; double darg = 2.0;
    a.run(arg); 
    a.run(uarg);
    a.run(farg);
    a.run(darg); // 报错
    /* 调用重载函数时,首先执行精准匹配;没有匹配到时,执行类型转换;如果类型可以转换成多个重载函数都匹配的类型,则报错 */
    
  • 覆盖(重写)

    派生类会继承基类的函数。不过,如果派生类定义了与基类中同名的函数,则派生类定义的函数会覆盖基类中的版本(包括重载的函数)。

    struct B: public A { };
    B b;
    b.run(); // 调用 A::run()
    b.run(1); // 调用 A::run(int)
    struct B: public A { void run() {...} };
    b.run(); // 调用 B::run()
    b.run(1); // 报错,因为B::run() 覆盖了A::run的所有版本
    

    覆盖会影响派生类

    struct C: public B { };
    C c;
    c.run(); // 调用 B::run()
    c.run(1); // 调用 B::run(int)
    /* 派生类只会继承其直接基类的函数 */
    

虚函数

​ 基类与派生类也叫父类与子类。派生类拥有基类的全部成员(属性和函数),当时需要按照基类中设置的访问权限访问。

  • 函数

    在一个类中的非静态成员函数可以分为两类:

    1. 普通成员函数
      • 被派生类直接继承,不需要改变。
      • 编译时解析。
      • 派生类可以覆盖(隐藏)它。
    2. 虚函数
      • 基类希望派生类覆盖。
      • 除构造函数外,所有的非静态函数都可以被声明为虚函数。
      • 运行时解析。
  • 非虚函数的重载与虚函数

    class Base {
    public:
        virtual void run() { ... }
    };
    class D1: public Base {
    public:
        void run(int a) { ... } // 隐藏基类虚函数
    };
    class D2: public D1 {
    public:
        void run(int a) { ... } // 隐藏非虚函数
        void run() { ... } // 覆盖虚函数
    };
    
  • 类型转化

    • 指针和应用
      • 派生类向基类转换是隐式的
      • 基类向派生类转化,需要调用请求类型转化(static_cast或dynamic_cast)
    • 对象
      • 派生类向基类转化,会切割掉派生类的成员,保留基类成员
      • 基类向派生类转化,编译报错
  • 派生类构造函数

    ​ 每个类负责自己成员的初始化,派生类初始化基类成员,需要调用基类的构造函数(默认调用基类的默认构造函数)。

  • 基类静态成员

    ​ 派生类可以访问基类的静态成员,但是需要符合访问权限。

  • 动态绑定

    ​ 动态绑定发生在对一个指针或者引用调用虚函数。

    • 动态绑定 = 指针/引用 + 覆盖(override)虚函数
    • 静态绑定 = 对象 + 虚函数;对象 + 非虚函数;指针/引用 + 非虚函数
  • 虚函数的默认参数

    ​ 在调用一个虚函数的时候,虚函数默认参数的值由调用的静态类型决定。

    class A { public: virtual void print(int a = 10) {...} };
    class B: public A { public: void print(int a = 20) {...} };
    int main() 
    {
    	A *a = new B;
        a->print(); // 默认参数为10
        ...
    }
    
  • 回避虚函数机制

    ​ 使用作用域运算符 :: 可以强制使用虚函数的某个实现。

  • 名字查找与继承

    p->mem(); 
    该调用的查找步骤:
    1. 确定p的静态类型
    2. 在静态类型的继承链中由下至上的查找名字为mem的函数,找不到则报错。
    3. 找到mem后,进行类型检查。
    	3.1 mem是虚函数且通过指针或引用调用的该函数,则依据动态类型调用虚函数的版本。
    	3.2 mem不是虚函数或者通过对象调用的该函数,则进行常规函数调用。
    

析构函数

​ 析构函数执行与构造函数相反的操作,即释放对象占用资源和销毁对象的非静态数据成员。在析构函数中,首先执行函数体,然后销毁成员。

​ 析构函数只负责销毁对象自身的成员,基类的成员由基类对象的析构函数销毁。

struct A {
    ~A() { LOG(); }
};

struct B: public A {
    ~B() { LOG(); }
};

struct C {
    ~C() { LOG(); }
    B b;
};
C c; // ~C() -> ~B() -> ~A()
  • 析构函数执行条件:

    • 变量离开作用域,

    • 对象被销毁,其内部的成员被销毁

    • 容器被销毁,其内部的元素被销毁

    • 动态分配的对象,delete指向其的指针时销毁

    • 临时对象,完整表达式结束时对象销毁

      当一个指针或者引用离开作用域时,不会执行析构函数。

  • 析构函数的函数体并不直接销毁数据成员,而是在函数体执行完成后隐含的析构阶段销毁数据成员。

  • 虚析构函数

    一般在继承中,基类的析构函数应该是一个虚函数。因为在发生动态绑定时,变量的静态类型和动态类型可能不匹配,所以需要使用虚析构函数执行实际指向对象的析构函数。

    struct A { ~A() { LOG(); } };
    struct B: public A {  ~B() { LOG(); } };
    A *a = new B();
    delete a; // 只会执行~A(),然而实际对象时B,所以B中独有的成员没有被正确销毁。
    struct A { virtual ~A() { LOG(); } };
    struct B: public A {  ~B() { LOG(); } };
    A *a = new B();
    delete a; // ~B() -> ~A(),B中的成员也会被正确销毁。
    

三/五原则

​ 三个基本操作可以控制对象的拷贝:拷贝构造函数、拷贝赋值运算符、析构函数。在C++11中又新添了两个操作:移动构造函数、移动赋值运算符。一般而言,定义了其中一个操作,同时也要定义其它的几个操作。

  • 只定义了析构函数
struct  A 
{
    ~A() { if (p != NULL) { delete p; p = NULL; } }
    int *p;
};
void func(A arg)
{
    LOG();
}
A a;
a.p = new int(10);
cout << *a.p << endl; // 输出10
func(a); // 传递参数时使用了合成的拷贝构造函数。func函数的参数在离开函数作用域的时候会调用对象的析构函数,此时两个对象的成员指针p指向的是同一段内存,所以func函数外部对象a中的成员指针p为NULL。
cout << *a.p << endl; 

C++对象行为_第1张图片

  • 定义了拷贝,也要定义赋值,反之亦然。
  • 析构函数不能是删除的或不可访问的。

你可能感兴趣的:(C++)