C++封装思想之一:封装(1.8W字详解)

目录

C++封装

封装

封装的作用:

C的封装 VS C++封装:

C语言:

C++语言:

类和对象

类的声明

权限修饰符:public、private(set/get)、protected

构造函数

默认构造函数(无参构造函数)

有参构造函数

构造函数的重载

this指针

初始化列表

C++11支持类内初始化

隐式类型转换(不安全)

拷贝构造函数

拷贝构造函数参数

拷贝构造函数的调用情况

拷贝构造的浅拷贝和深拷贝

析构函数

类型转换构造函数

隐式转换和explicit

类型转换运算符重载

对象数组

左值、右值、左值引用、右值引用

左值、右值

左值的特点

右值的特点

左引用绑定左值

常引用既可以绑定左值,也可以绑定右值

右引用绑定右值

移动构造函数

对象移动

移动拷贝构造函数

移动构造优点

左值转右值

static_cast(对象)&&>

std::move

注意事项

赋值运算符重载

为什么返回值是对象的引用

移动赋值运算符

委托构造函数

作用

概念

执行顺序

语法

注意事项

default、delete

拷贝构造函数VS赋值运算符

成员函数、属性

static关键字

const关键字

const对象

const成员函数

空类默认生成的成员

指向类成员的指针

指向类数据成员(属性)的指针

指向类成员函数的指针

隐藏调用接口


C++封装

封装

C++封装思想之一:封装(1.8W字详解)_第1张图片

C++封装思想之一:封装(1.8W字详解)_第2张图片

封装的作用:

        通过类的封装,实现对外提供接口,屏蔽数据,对内开放数据,保证代码的独立性(内聚性),提高代码的维护性

C++的封装:class 封装的本质,在于将数据和行为,绑定在一起然后通过对象来完成操作。

C的封装 VS C++封装:

C++封装思想之一:封装(1.8W字详解)_第3张图片

C++封装思想之一:封装(1.8W字详解)_第4张图片

C语言:

  • 优点:灵活、用户自定义

性能优化更方便

  • 缺点:
    • 对外提供的接口复杂
    • 不能保证代码的独立性,无权限修饰符限制权限
    • 容易造成内存泄漏

C++语言:

  • 优点:
    • 通过访问权限,实现对外屏蔽数据,提供接口访问,保证代码的独立性
    • 提供更加简单访问接口(编译器默认处理:this)
    • 防止内存泄漏:构造函数和析构函数
  • 缺点:性能:占用更多内存空间(对象头)

性能:地址跳转访问(内部:更复杂的访问机制)

类和对象

类的声明

C++封装思想之一:封装(1.8W字详解)_第5张图片

权限修饰符:public、private(set/get)、protected

C++封装思想之一:封装(1.8W字详解)_第6张图片

构造函数

我们在编写栈类的时候,使用自定义的init函数来实现栈的初始化,但是我们在人为操作的时候可能会忘记初始化。因此C++引入了构造函数。

为什么需要构造函数?

C++封装思想之一:封装(1.8W字详解)_第7张图片

构造函数的定义及特点

C++封装思想之一:封装(1.8W字详解)_第8张图片

注意如果编写了自定义有参构造函数,会自动屏蔽默认构造函数,导致默认构造函数无法调用。可以将有参构造函数的参数设置默认参数,同时实现有参和无参的功能。

C++封装思想之一:封装(1.8W字详解)_第9张图片

C++封装思想之一:封装(1.8W字详解)_第10张图片

C++封装思想之一:封装(1.8W字详解)_第11张图片

默认构造函数(无参构造函数)

生成规则:未定义构造函数时,系统会默认生成;自定义构造函数时,系统不会默认生成

  • 如果用户不提供构造函数 编译器会自动 提供一个无参的空的构造函数。
  • 如果用户提供构造函数 编译器会自动 屏蔽默认无参的构造

要注意如Student stu1=Student();或Student stu1();会默认成员都赋值为0,而Student stu1初始化的成员数值随机。

1、如果用户自己没有定义构造函数,那么编译器会自动生成一个默认的构造函数,只是这个构造函数的函数体是空的,也没有形参,也不执行任何操作。比如 Stack 类,默认生成的构造函数如下:

Student(){}

例:

.hpp
#pragma once
#include 
#include 
using namespace std;

class Stack
{
public:
    int get_top(void);

private:
    int m_top;
    int *m_stack;
    int m_max_len;
};

int Stack::get_top(void)
{
    return m_top;
}


main.cpp
#include "stack.hpp"
using namespace std;
int main(int argc, char const *argv[])
{
    Stack s1;
    cout<

运行结果:由于默认构造函数未作任何操作,因此输出随机值

2.用户自定义了构造函数,会使用用户定义的函数

例:由于构造函数涉及了堆空间的开辟,因此需要自定义析构函数来释放空间(与默认构造函数一样,如果用户字节不定义,系统会默认生成一个析构函数)

.hpp
#pragma once
#include 
#include 
using namespace std;

class Stack
{
public:
    Stack();
    int get_top(void);
    ~Stack();
private:
    int m_top;
    int *m_stack;
    int m_max_len;
};

Stack::Stack()
{
    m_top=-1;
    m_max_len=1024;
    m_stack=new int[1024];
    cout<<"无参构造"<

运行结果:

C++封装思想之一:封装(1.8W字详解)_第12张图片

有参构造函数

 C++封装思想之一:封装(1.8W字详解)_第13张图片

 C++封装思想之一:封装(1.8W字详解)_第14张图片

 C++封装思想之一:封装(1.8W字详解)_第15张图片

构造函数的重载

和普通成员函数一样,构造函数是允许重载的。一个类可以有多个重载的构造函数,创建对象时根据传递的实参来判断调用哪一个构造函数。

构造函数的调用是强制性的,一旦在类中定义了构造函数,那么创建对象时就一定要调用,不调用是错误的。如果有多个重载的构造函数,那么创建对象时提供的实参必须和其中的一个构造函数匹配;反过来说,创建对象时只有一个构造函数会被调用。

构造函数中默认参数的使用 :

C++封装思想之一:封装(1.8W字详解)_第16张图片

C++封装思想之一:封装(1.8W字详解)_第17张图片

C++封装思想之一:封装(1.8W字详解)_第18张图片

this指针

1.this 实际上是成员函数的一个形参,在调用成员函数时将对象的地址作为实参传递给 this。不过 this 这个形参是隐式的,它并不出现在代码中,而是在编译阶段由编译器默默地将它添加到参数列表中。

C++封装思想之一:封装(1.8W字详解)_第19张图片

2、普通成员函数 默认有一个this指针 指向调用该成员函数的对象。

C++封装思想之一:封装(1.8W字详解)_第20张图片

3.this来完成链式操作

C++封装思想之一:封装(1.8W字详解)_第21张图片

初始化列表

成员变量的初始化列表只能使用在构造函数的后面,不能用在其他函数后面。

  • const成员的初始化只能在构造函数初始化列表中进行
  • 引用成员的初始化也只能在构造函数初始化列表中进行
  • 对象成员(对象所对应的类没有默认构造函数)的初始化,也只能在构造函数初始化列表中进行

C++封装思想之一:封装(1.8W字详解)_第22张图片

使用构造函数初始化列表使成员变量在定义时被初始化,在函数内部是定义之后初始化

注意,成员变量的初始化顺序与初始化列表中列出的变量的顺序无关,它只与成员变量在类中声明的顺序有关。顺序不能乱!

#include 
using namespace std;

class Student{
private:
    char *m_name;
    int m_age;
    float m_score;
public:
    Student(char *name, int age, float score);
    void show();
};

//采用初始化列表
Student::Student(char *name, int age, float score): m_name(name), m_age(age), m_score(score){
    //TODO:
}
void Student::show(){
    cout< show();

    return 0;
}

运行结果:

小明的年龄是15,成绩是92.5

李华的年龄是16,成绩是96

C++11支持类内初始化

C++封装思想之一:封装(1.8W字详解)_第23张图片

隐式类型转换(不安全)

explicit:禁止发生隐式类型转换

C++封装思想之一:封装(1.8W字详解)_第24张图片

拷贝构造函数

注意在前:

自定义的拷贝构造函数会屏蔽掉原有的默认构造函数(只实现简单的数据赋值:浅拷贝),因此如果有指针成员分配了堆区空间,在拷贝的时候会导致两个对象的指针成员指向同一块内存空间,从而会导致在析构函数执行时,同一块内存空间被释放两次。因此我们需要自定义拷贝构造函数,在自定义函数中实现堆区的申请复制。

1、使用对象s4来初始化s5,由于用户未提供拷贝构造,编译器会自动提供一个默认的拷贝构造(完成赋值动作--浅拷贝)。

因此导致初始化之后两个对象的指针成员指向了同一块内存空间,最终程序运行结束系统调用析构函数的时候,对同一块内存释放了两次。

C++封装思想之一:封装(1.8W字详解)_第25张图片

C++封装思想之一:封装(1.8W字详解)_第26张图片

2、自定义拷贝构造函数实现深拷贝,手动实现堆区空间的拷贝

C++封装思想之一:封装(1.8W字详解)_第27张图片

C++封装思想之一:封装(1.8W字详解)_第28张图片

C++封装思想之一:封装(1.8W字详解)_第29张图片

拷贝构造函数参数

1) 为什么必须是当前类的引用呢?

如果拷贝构造函数的参数不是当前类的引用,而是当前类的对象,那么在调用拷贝构造函数时,会将另外一个对象直接传递给形参,这本身就是一次拷贝,会再次调用拷贝构造函数,然后又将一个对象直接传递给了形参,将继续调用拷贝构造函数……这个过程会一直持续下去,没有尽头,陷入死循环。

只有当参数是当前类的引用时,才不会导致再次调用拷贝构造函数,这不仅是逻辑上的要求,也是 C++ 语法的要求。

2) 为什么是 const 引用呢?

拷贝构造函数的目的是用其它对象的数据来初始化当前对象,并没有期望更改其它对象的数据,添加 const 限制后,这个含义更加明确了。

另外一个原因是,添加 const 限制后,可以将 const 对象和非 const 对象传递给形参了,因为非 const 类型可以转换为 const 类型。如果没有 const 限制,就不能将 const 对象传递给形参,因为 const 类型不能转换为非 const 类型,这就意味着,不能使用 const 对象来初始化当前对象了。

拷贝构造函数的调用情况

(1)用已有对象初始化对象会调用拷贝构造函数(给对象取别名不会调用拷贝构造)

(2)当函数的形参是类的对象,调用函数时,进行形参与实参结合时使用

C++封装思想之一:封装(1.8W字详解)_第30张图片

(3)当函数的返回值是类对象,函数执行完成返回调用者时使用

Visual Studio下:

C++封装思想之一:封装(1.8W字详解)_第31张图片

在linux中:

C++封装思想之一:封装(1.8W字详解)_第32张图片

C++封装思想之一:封装(1.8W字详解)_第33张图片

对象作为函数返回值,默认编译器会优化,不会调用拷贝构造函数。我们需要关闭返回值优化(-fno-elide-constructors)。

因为拷贝构造作为返回值,在函数中先给临时对象开辟空间,出函数又会通过析构函数释放空间。编译器会认为多此一举,浪费运行时间,从而进行优化。

C++封装思想之一:封装(1.8W字详解)_第34张图片

拷贝构造的浅拷贝和深拷贝

浅拷贝和深拷贝的概念是针对于类成员中是否存在指针变量

默认的拷贝构造 都是浅拷贝。

如果类中没有指针成员, 不用实现拷贝构造和析构函数。

如果类中有指针成员, 必须实现析构函数释放指针成员指向的堆区空间,必须实现拷贝构造完成深拷 贝动作。

析构函数

当对象生命周期结束的时候 系统自动调用析构函数。

函数名和类名称相同,在函数名前加~,没有返回值类型,没有函数形参。(不能被重载)

先调用析构函数 再释放对象的空间。

class Data1
{
    public:
        int mA;
    public:
        //无参构造函数
        Data1()
         {
             mA=0;
             cout<<"无参构造函数"<C++封装思想之一:封装(1.8W字详解)_第35张图片

一般情况下,空的析构函数就足够。但是如果一个类有指针成员,这个类必须 写析构函数,释放指针 成员所指向空间。

 #include 
 #include
 #include
 class Data2
 {
  public:
       char *name;//指针成员
  public:
       Data2()
       {
         name=NULL;
       }
       Data2(char *str)
       {
         name = (char *)calloc(1, strlen(str)+1);
         strcpy(name, str);
         cout<<"有参构造 name="<

C++封装思想之一:封装(1.8W字详解)_第36张图片

类型转换构造函数

隐式转换和explicit

有参构造函数有且仅有一个参数的时候,可以按照如下写法

C++封装思想之一:封装(1.8W字详解)_第37张图片

explicit防止构造函数隐式转换

C++封装思想之一:封装(1.8W字详解)_第38张图片

类型转换运算符重载

类型强制转换运算符是单目运算符,也可以被重载,但只能重载为成员函数,不能重载为全局函数。经过适当重载后,(类型名)对象这个对对象进行强制类型转换的表达式就等价于对象.operator 类型名(),即变成对运算符函数的调用。

下面的程序对 double 类型强制转换运算符进行了重载。

#include 
using namespace std;
class Complex
{
    double real, imag;
public:
    Complex(double r = 0, double i = 0) :real(r), imag(i) {};
    operator double() { return real; }  //重载强制类型转换运算符 double
};
int main()
{
    Complex c(1.2, 3.4);
    cout << (double)c << endl;  //输出 1.2
    double n = 2 + c;  //等价于 double n = 2 + c. operator double()
    cout << n;  //输出 3.2
}

程序的输出结果是:

1.2

3.2

第 8 行对 double 运算符进行了重载。重载强制类型转换运算符时,不需要指定返回值类型,因为返回值类型是确定的,就是运算符本身代表的类型,在这里就是 double。

重载后的效果是,第 13 行的(double)c等价于c.operator double()。

有了对 double 运算符的重载,在本该出现 double 类型的变量或常量的地方,如果出现了一个 Complex 类型的对象,那么该对象的 operator double 成员函数就会被调用,然后取其返回值使用。

例如第 14 行,编译器认为本行中c这个位置如果出现的是 double 类型的数据,就能够解释得通,而 Complex 类正好重载了 double 运算符,因而本行就等价于:

double n = 2 + c.operator double();

对象数组

在构造函数有多个参数时,数组的初始化列表中要显式地包含对构造函数的调用。例如下面的程序:

class CTest{
public:
    CTest(int n){ }  //构造函数(1)
    CTest(int n, int m){ }  //构造函数(2)
    CTest(){ }  //构造函数(3)
};
int main(){
    //三个元素分别用构造函数(1)、(2)、(3) 初始化
    CTest arrayl [3] = { 1, CTest(1,2) };
    //三个元素分别用构造函数(2)、(2)、(1)初始化
    CTest array2[3] = { CTest(2,3), CTest(1,2), 1};
    //两个元素指向的对象分别用构造函数(1)、(2)初始化
    CTest* pArray[3] = { new CTest(4), new CTest(1,2) };

    return 0;
}

左值、右值、左值引用、右值引用

C++封装思想之一:封装(1.8W字详解)_第39张图片

左值、右值

左值的特点
  • 指向特定内存的具有名称的值(具有对象名或者变量名)
  • 有一个相对稳定的内存地址
  • 有一段较长的生命周期
右值的特点
  • 不指向稳定内存地址的匿名值(不具名对象)
  • 生命周期很短,通常是暂时的

如下在函数中创建的普通对象就为右值

C++封装思想之一:封装(1.8W字详解)_第40张图片

如何判断:可以通过&运算符获取地址的,就是左值;若否就是右值

左引用绑定左值

其实 C++98/03 标准中就有引用,使用 "&" 表示。但此种引用方式有一个缺陷,即正常情况下只能操作 C++ 中的左值,无法对右值添加引用。举个例子:

int num = 10;
int &b = num; //正确
int &c = 10; //错误

如上所示,编译器允许我们为 num 左值建立一个引用,但不可以为 10 这个右值建立引用。因此,C++98/03 标准中的引用又称为左值引用

常引用既可以绑定左值,也可以绑定右值

注意,虽然 C++98/03 标准不支持为右值建立非常量左值引用,但允许使用常量左值引用操作右值。也就是说,常量左值引用既可以操作左值,也可以操作右值,例如:

int num = 10;
const int &b = num;
const int &c = 10;

右引用绑定右值

我们知道,右值往往是没有名称的,因此要使用它只能借助引用的方式。这就产生一个问题,实际开发中我们可能需要对右值进行修改(实现移动语义时就需要),显然左值引用的方式是行不通的。

为此,C++11 标准新引入了另一种引用方式,称为右值引用,用 "&&" 表示。

需要注意的,和声明左值引用一样,右值引用也必须立即进行初始化操作,且只能使用右值进行初始化,比如:

int num = 10;
//int && a = num;  //右值引用不能初始化为左值
int && a = 10;

和常量左值引用不同的是,右值引用还可以对右值进行修改。例如:

int && a = 10;
a = 100;
cout << a << endl;

程序输出结果为 100。

另外值得一提的是,C++ 语法上是支持定义常量右值引用的,例如:

const int&& a = 10;//编译器不会报错

但这种定义出来的右值引用并无实际用处。一方面,右值引用主要用于移动语义和完美转发,其中前者需要有修改右值的权限;其次,常量右值引用的作用就是引用一个不可修改的右值,这项工作完全可以交给常量左值引用完成。

移动构造函数

拷贝是最消耗资源和降低效率的;尤其是临时对象、容器类

对象移动

假设你的路由器后面用网线连着一台台式电脑,现在你要把桌子上的路由器放到地上:

复制:新买一个路由器放到地上,再新买一台电脑,最后把这台新的电脑用网线连到新的路由上。

移动(理想):把路由器放到地上。

移动(现实):新买一个路由器放到地上,把原来那个路由上的网线拔下来,插到新的路由上

「大幅提升性能」就是:你不用为了挪路由器而买电脑了。

移动原理:

  • A移动到B,A就不能再使用;
  • 移动并不是内存的数据移动到其他内存,而是变更所有权;
  • 变更所有权并不是变更所有属性的所有权

移动拷贝构造函数

使用右值引用:提高运行效率,减少不必要的拷贝(对象移动)

移动构造函数的参数和拷贝构造函数不同,拷贝构造函数的参数是一个左值引用,但是移动构造函数的初值是一个右值引用。这意味着,移动构造函数的参数是一个右值或者将亡值的引用。也就是说,只有当用一个右值,或者将亡值初始化另一个对象的时候,才会调用移动构造函数。移动构造函数的例子如下:

#include 
#include 

using namespace std;

class Integer {
private:
    int* m_ptr;
public:
    Integer(int value)
        : m_ptr(new int(value)) {
        cout << "Call Integer(int value)有参" << endl;
    }
    
    Integer(const Integer& source)
        : m_ptr(new int(*source.m_ptr)) {
        cout << "Call Integer(const Integer& source)拷贝" << endl;
    }

    Integer(Integer&& source)
      : m_ptr(source.m_ptr) {
        source.m_ptr= nullptr;
        cout << "Call Integer(Integer&& source)移动" << endl;
    }
    
    ~Integer() {
        cout << "Call ~Integer()析构" << endl;
        delete m_ptr;
    }

    int GetValue(void) { return *m_ptr; }
};
Integer getNum()
{
    Integer a(100);
    return a;
}
int main(int argc, char const* argv[]) {
    Integer a(getNum()); 
    cout << "a=" << a.GetValue() << endl;
    cout << "-----------------" << endl;
    Integer temp(10000);
    Integer b(temp);
    cout << "b=" << b.GetValue() << endl;
    cout << "-----------------" << endl;

    return 0;
}

结果:

C++封装思想之一:封装(1.8W字详解)_第41张图片

解释:

  上面的程序中,getNum()函数中需要返回的是一个局部变量,因此它此时就是一个临时变量,因为在函数结束后它就消亡了,对应的其动态内存也会被析构掉,所以系统在执行return函数之前,需要再生成一个临时对象将a中的数据内容返回到被调的主函数中,此处自然就有两种解决方法:1、调用复制构造函数进行备份;2、使用移动构造函数把即将消亡的且仍需要用到的这部分内存的所有权进行转移,手动延长它的生命周期。

显然,前者需要深拷贝操作依次复制全部数据,而后者只需要“变更所有权”即可。

上面的运行结果中第一次析构就是return a; 这个临时对象在转移完内存所用权之后就析构了。

此处更需要说明的是:遇到这种情况时,编译器会很智能帮你选择类内合适的构造函数去执行,如果没有移动构造函数,它只能默认的选择复制构造函数,而同时存在移动构造函数和复制构造函数则自然会优先选择移动构造函数。

比如上述程序如果只注释掉移动构造函数而其他不变,运行后结果如下:

原来调用了移动构造函数的地方变成了拷贝构造。

C++封装思想之一:封装(1.8W字详解)_第42张图片

注:移动构造的&&是右值引用,而getNum()函数返回的临时变量是右值

【思考】

1、移动构造函数的第一个参数必须是自身类型的右值引用(不需要const,为啥?右值使用const没有意义),若存在额外的参数,任何额外的参数都必须有默认实参

2、看移动构造函数体里面,我们发现参数指针所指向的对象转给了当前正在被构造的指针后,接着就把参数里面的指针置为空指针(source.m_ptr= nullptr;),对象里面的指针置为空指针后,将来析构函数析构该指针(delete m_ptr;)时,是delete一个空指针,不发生任何事情,这就是一个移动构造函数。

移动构造优点

移动拷贝构造函数(使用右值引用传参):直接接管对象空间,从而省去临时变量中间拷贝过程,大大提高运行效率

移动构造函数是c++11的新特性,移动构造函数传入的参数是一个右值 用&&标出。

首先讲讲拷贝构造函数:拷贝构造函数是先将传入的参数对象进行一次深拷贝,再传给新对象。这就会有一次拷贝对象的开销,拷贝的内存越大越耗费时间,并且进行了深拷贝,就需要给对象分配地址空间。而移动构造函数就是为了解决这个拷贝开销而产生的。

移动构造函数首先将传递参数的内存地址空间接管,然后将内部所有指针设置为nullptr,并且在原地址上进行新对象的构造,最后调用原对象的的析构函数,这样做既不会产生额外的拷贝开销,也不会给新对象分配内存空间。即提高程序的执行效率,节省内存消耗。

左值转右值

static_cast(对象)
std::move

std::move() 能把左值强制转换为右值,一般用于将匿名对象转成右值。

注:

  • 本身并不移动对象,只是将对象的类型转换为右值
  • 使用注意事项:调用此函数即保证后面不会再使用传入的对象

我们把语句 Integer b(temp); 改为 Integer b(std::move(temp)); 后,运行结果如下。

int main(int argc, char const* argv[]) {
    Integer a(getNum()); 
    cout << "a=" << a.GetValue() << endl;
    cout << "-----------------" << endl;
    Integer temp(10000);
    Integer b(std::move(temp));
    cout << "b=" << b.GetValue() << endl;
    cout << "-----------------" << endl;

    return 0;
}

C++封装思想之一:封装(1.8W字详解)_第43张图片

 对比移动构造实现一节的例程运行结果发现,非匿名对象 temp (左值)在加了std::move之后强制转为右值也能做 只接收右值的移动拷贝函数 的参数了,因此编译器在这里调用了移动拷贝函数。

从“b=10000”的上一行可以看出,std::move() 确实把左值 temp 转换为右值。

使用匿名对象要使用move函数将匿名对象强制转换成右值

C++封装思想之一:封装(1.8W字详解)_第44张图片

注意事项

1、移动后源对象必须是有效的,可析构的

  • 移动操作必须确保移动后源对象可以被销毁且销毁后不会影响新创建的对象,例如如果源对象中有数据成员是指针,则必须置为空,否则在源对象执行析构函数时,会将新创建对象中的指针指向的资源释放掉。
  • 移动操作还必须保证对象仍然可以安全地为其赋予新值或者可以安全地使用而不依赖其当前值。另一方面,移动操作对移动后源对象中留下的值没有任何要求,因此我们的程序不应该依赖于移动后源对象中的数据。

2、合成的移动操作问题

  • 一个类定义了自己的拷贝构造函数,拷贝赋值运算符或析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符。
  • 定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝构造函数和拷贝赋值运算符,否则拷贝构造函数和拷贝赋值运算符会被定义为删除的
  • 使用=default显式要求编译器生成合成的移动操作,且编译器不能移动所有成员

3、移动构造函数和移动赋值运算符必须标记为noexcept

        由于移动操作窃取资源,它通常不分配任何资源,因此,移动操作通常不会抛出任何异常。不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept,因为某些标准库容器除非知道移动操作是无异常的,否则就会进行拷贝

赋值运算符重载

当以拷贝的方式初始化一个对象时,会调用拷贝构造函数;当给一个对象赋值时,会调用重载过的赋值运算符。

即使我们没有显式的重载赋值运算符,编译器也会以默认地方式重载它。默认重载的赋值运算符功能很简单,就是将原有对象的所有成员变量一一赋值给新对象,这和默认拷贝构造函数的功能类似。

对于简单的类,默认的赋值运算符一般就够用了,我们也没有必要再显式地重载它。但是当类持有其它资源时,例如动态分配的内存、打开的文件、指向其他数据的指针、网络连接等,默认的赋值运算符就不能处理了,我们必须显式地重载它,这样才能将原有对象的所有数据都赋值给新对象。

#include 
#include 
using namespace std;

//变长数组类
class Array{
public:
    Array(int len);
    Array(const Array &arr);  //拷贝构造函数
    ~Array();
public:
    int operator[](int i) const { return m_p[i]; }  //获取元素(读取)
    int &operator[](int i){ return m_p[i]; }  //获取元素(写入)
    Array & operator=(const Array &arr);  //重载赋值运算符
    int length() const { return m_len; }
private:
    int m_len;
    int *m_p;
};

Array::Array(int len): m_len(len){
    m_p = (int*)calloc( len, sizeof(int) );
}

Array::Array(const Array &arr){  //拷贝构造函数
    this->m_len = arr.m_len;
    this->m_p = (int*)calloc( this->m_len, sizeof(int) );
    memcpy( this->m_p, arr.m_p, m_len * sizeof(int) );
}

Array::~Array(){ free(m_p); }

Array &Array::operator=(const Array &arr){  //重载赋值运算符
    if( this != &arr){  //判断是否是给自己赋值
        this->m_len = arr.m_len;
        free(this->m_p);  //释放原来的内存
        this->m_p = (int*)calloc( this->m_len, sizeof(int) );
        memcpy( this->m_p, arr.m_p, m_len * sizeof(int) );
    }
    return *this;
}

//打印数组元素
void printArray(const Array &arr){
    int len = arr.length();
    for(int i=0; i

 运行结果:

0, 1, 2, 3, 4, 5, 6, 7, 8, 9

0, 1, 2, 3, 4

0, 1, 2, 3, 4, 5, 6, 7, 8, 9

0, 1, 2, 3, 4, 5, 6, 7, 8, 9

将 arr1 赋值给 arr2 后,修改 arr2 的数据不会影响 arr1。如果把 operator=() 注释掉,那么运行结果将变为:

0, 1, 2, 3, 4, 5, 6, 7, 8, 9

0, 1, 2, 3, 4

0, 1, 2, 3, 4, 5, 6, 7, 8, 9

0, 1, 2, 234, 4, 5, 6, 920, 8, 9

去掉operator=()后,由于 m_p 指向的堆内存会被 free() 两次,所以还会导致内存错误。

为什么返回值是对象的引用

返回对象的引用可以实现链式操作,如(a=b)=c这样的操作

移动赋值运算符

Array &Array::operator=(Array &&arr)
{
    this->m_p=arr.m_p;
    arr.mp=NULL;
    this->m_len = arr.m_len;
    return *this;
}

委托构造函数

作用

为了合理复用构造函数来减少代码冗余,C++11标准支持了委托构造函数

概念

  • 某个类型的一个构造函数可以委托同类型的另一个构造函数对对象进行初始化
  • 为了描述方便我们称前者为委托构造函数,后者为代理构造函数

执行顺序

委托构造函数会将控制权交给代理构造函数,在代理构造函数执行完之后,再执行委托构造函数的主体

语法

在委托构造函数的初始化列表中调用代理构造函数即可

class X {
public:
    X() : X(0, 0.) {}
    X(int a) : X(a, 0.) {}
    X(double b) : X(0, b) {}
    X(int a, double b) : a_(a), b_(b)
    {
        CommonInit();
    }
private:
    void CommonInit() {}
    int a_;
    double b_;
};

C++封装思想之一:封装(1.8W字详解)_第45张图片

通过全参的构造函数来定义其它构造函数,帮助对象初始化

C++封装思想之一:封装(1.8W字详解)_第46张图片

C++封装思想之一:封装(1.8W字详解)_第47张图片

注意事项

  • 每个构造函数都可以委托另一个构造函数为代理
  • 不要递归循环委托!因为循环委托不会被编译器报错,随之而来的是程序运行时发生未定义行为
  • 如果一个构造函数为委托构造函数,那么其初始化列表里就不能对数据成员和基类进行初始化 

default、delete

C++封装思想之一:封装(1.8W字详解)_第48张图片

trivial VS non-trivial:

如果这个类都是trivial ctor/dtor/copy/assignment函数,我们对这个类进行构造、析构、拷贝和赋值时可以采用最有效率的方法,不调用无所事事正真的那些ctor/dtor等,而直接采用内存操作如malloc()、memcpy()等提高性能,这也是SGI STL内部干的事情

C++封装思想之一:封装(1.8W字详解)_第49张图片

拷贝构造函数VS赋值运算符

在默认情况下(用户没有定义,但是也没有显式的删除),编译器会自动的隐式生成一个拷贝构造函数和赋值运算符。但用户可以使用delete来指定不生成拷贝构造函数和赋值运算符,这样的对象就不能通过值传递,也不能进行赋值运算。

class Person
{
public:

    Person(const Person& p) = delete;

    Person& operator=(const Person& p) = delete;

private:
    int age;
    string name;
};

上面的定义的类Person显式的删除了拷贝构造函数和赋值运算符,在需要调用拷贝构造函数或者赋值运算符的地方,会提示_无法调用该函数,它是已删除的函数_。

还有一点需要注意的是,拷贝构造函数必须以引用的方式传递参数。这是因为,在值传递的方式传递给一个函数的时候,会调用拷贝构造函数生成函数的实参。如果拷贝构造函数的参数仍然是以值的方式,就会无限循环的调用下去,直到函数的栈溢出。

何时调用

拷贝构造函数和赋值运算符的行为比较相似,都是将一个对象的值复制给另一个对象;但是其结果却有些不同,拷贝构造函数使用传入对象的值生成一个新的对象的实例,而赋值运算符是将对象的值复制给一个已经存在的实例。这种区别从两者的名字也可以很轻易的分辨出来,拷贝构造函数也是一种构造函数,那么它的功能就是创建一个新的对象实例;赋值运算符是执行某种运算,将一个对象的值复制给另一个对象(已经存在的)。调用的是拷贝构造函数还是赋值运算符,主要是看是否有新的对象实例产生。如果产生了新的对象实例,那调用的就是拷贝构造函数;如果没有,那就是对已有的对象赋值,调用的是赋值运算符。

调用拷贝构造函数主要有以下场景:

  • 对象作为函数的参数,以值传递的方式传给函数。 
  • 对象作为函数的返回值,以值的方式从函数返回
  • 使用一个对象给另一个对象初始化

代码如下:

class Person
{
public:
    Person(){}
    Person(const Person& p)
    {
        cout << "Copy Constructor" << endl;
    }

    Person& operator=(const Person& p)
    {
        cout << "Assign" << endl;
        return *this;
    }

private:
    int age;
    string name;
};

void f(Person p)
{
    return;
}

Person f1()
{
    Person p;
    return p;
}

int main()
{
    Person p;
    Person p1 = p;    // 1
    Person p2;
    p2 = p;           // 2
    f(p2);            // 3

    p2 = f1();        // 4

    Person p3 = f1(); // 5

    getchar();
    return 0;

上面代码中定义了一个类Person,显式的定义了拷贝构造函数和赋值运算符。然后定义了两个函数:f,以值的方式参传入Person对象;f1,以值的方式返回Person对象。在main中模拟了5中场景,测试调用的是拷贝构造函数还是赋值运算符。执行结果如下:

C++封装思想之一:封装(1.8W字详解)_第50张图片

分析如下:

 1、这是虽然使用了"=",但是实际上使用对象p来创建一个新的对象p1。也就是产生了新的对象,所以调用的是拷贝构造函数。

2、首先声明一个对象p2,然后使用赋值运算符"=",将p的值复制给p2,显然是调用赋值运算符,为一个已经存在的对象赋值 。

3、以值传递的方式将对象p2传入函数f内,调用拷贝构造函数构建一个函数f可用的实参。

4、这条语句拷贝构造函数和赋值运算符都调用了。函数f1以值的方式返回一个Person对象,在返回时会调用拷贝构造函数创建一个临时对象tmp作为返回值;返回后调用赋值运算符将临时对象tmp赋值给p2.

5、按照4的解释,应该是首先调用拷贝构造函数创建临时对象;然后再调用拷贝构造函数使用刚才创建的临时对象创建新的对象p3,也就是会调用两次拷贝构造函数。不过,编译器也没有那么傻,应该是直接调用拷贝构造函数使用返回值创建了对象p3。

成员函数、属性

static关键字

类的静态成员:必须在类外初始化,不属于某个对象,属于类(被所有对象共享),可以直接通过类名的方法访问

C++封装思想之一:封装(1.8W字详解)_第51张图片

static成员需要在类内定义,在类外进行初始化与使用

C++封装思想之一:封装(1.8W字详解)_第52张图片

整型static const成员可以在类定义体中初始化,该成员可以不在类体外进行定义

C++封装思想之一:封装(1.8W字详解)_第53张图片

C++封装思想之一:封装(1.8W字详解)_第54张图片

static关键字的作用:实现对象之间的通信

普通成员函数的名字是依赖于成员对象的,其函数指针存储在对象头中

静态成员函数名代表着的是普通函数名的函数指针

普通成员函数的函数名不是普通函数名的函数指针,而是指向类的成员函数的指针

C++封装思想之一:封装(1.8W字详解)_第55张图片

C++封装思想之一:封装(1.8W字详解)_第56张图片

const 修饰成员函数为只读(该成员函数不允许对 成员数据 赋值) mutable修饰的成员除 外

C++封装思想之一:封装(1.8W字详解)_第57张图片

const关键字

const对象

常对象:只能调用const修饰的方法

C++封装思想之一:封装(1.8W字详解)_第58张图片

C++封装思想之一:封装(1.8W字详解)_第59张图片

const成员函数

C++封装思想之一:封装(1.8W字详解)_第60张图片

C++封装思想之一:封装(1.8W字详解)_第61张图片

空类默认生成的成员

C++封装思想之一:封装(1.8W字详解)_第62张图片

指向类成员的指针

指向类数据成员(属性)的指针

类成员权限改为public

C++封装思想之一:封装(1.8W字详解)_第63张图片

在未定义对象的时候,访问类中成员的地址,访问的是相对于类起始地址的偏移量

C++封装思想之一:封装(1.8W字详解)_第64张图片

C++封装思想之一:封装(1.8W字详解)_第65张图片

然后创建一个实际对象,通过取对象地址,获取首地址,接着加上偏移量,可以访问到成员变量的值,即使为私有变量同样可以访问

C++封装思想之一:封装(1.8W字详解)_第66张图片

通过地址偏移量还可以突破类的封装性访问私有成员值

C++封装思想之一:封装(1.8W字详解)_第67张图片

指向类成员函数的指针

打印出来的是用于指向函数入口地址的函数指针变量的地址,因为还未实例化对象,因此现在存放在类对象头中的函数指针未指向任何地址

C++封装思想之一:封装(1.8W字详解)_第68张图片

C++封装思想之一:封装(1.8W字详解)_第69张图片

其中(t.*p_print)()即为t.print

C++封装思想之一:封装(1.8W字详解)_第70张图片

总结:

  • 指向类的成员属性的指针与普通意义上的指针不一样。存放的是偏移量。
  • 指向类的成员函数的指针与普通函数的区别,普通函数是函数的入口地址,类的成员函数指针代表的函数存放的地址;
  • 用指向类员函数的指针,实现更加隐蔽的接口。
classWidget
{
public:
  Widget()
  {
//对函数指针数组进行初始化 一定要加Widget::
     fptr[0] = &Widget::f;
     fptr[1] = &Widget::g;
     fptr[2] = &Widget::h;
     fptr[3] = &Widget::i;
  }

void select(int i, int val)
  {
     (this->*fptr[i])(val);
//this-> 一定不能省略 因为指向类成员的指针在被调用的时候 
//必须用一个对象去调用
  }

int count()
  {
returncnt;
  }

private:
void f(intval) { cout "void f(int val)"val endl; }
void g(intval) { cout "void g(int val)"val endl; }
void h(intval) { cout "void h(int val)"val endl; }
void i(intval) { cout "void i(int val)"val endl; }

enum { cnt = 4 }; //运用到常量时用枚举类型 可以不用定义名字
void(Widget::*fptr[cnt])(int);  //定义指向类成员函数的指针数组
//因为四个成员函数有很多的共同点 
//而指向类成员的指针类型可以包含这些共同点
//所以用这种类型的指针数组来存放函数 实现更加隐蔽的调用
};



int main(void)
{
Widget w;

  for (int i = 0; i < 4; i++)
  {
     w.select(i, 23);
  }

return 0;
}

隐藏调用接口

通过函数指针隐藏函数调用接口,提高代码的扩展性、灵活性、可维护性,用户可以灵活地配置功能(C/C++实际库开发的通常写法:可以参考STM32库的封装)

C++封装思想之一:封装(1.8W字详解)_第71张图片

C++封装思想之一:封装(1.8W字详解)_第72张图片

C++封装思想之一:封装(1.8W字详解)_第73张图片

你可能感兴趣的:(C++,c++,嵌入式)