查漏补缺——C/C++(类0)


前言

本篇文章着重讨论《C++ Primer Plus 6th》的第10,11章内容,结合之前学的再添加一些作者自己的理解。
内容大致包括:

  1. 类的定义,如何去理解(略)
  2. 类的基本元素(略)
  3. 构造函数,析构函数(详细)

其实自己在之前的学习过程中,书和老师讲得都听得明白,让自己去写类,去优化实际的类,写构造函数总是卡壳,到底是默认构造,有参数构造,转换构造,拷贝构造,移动构造亦或者是赋值运算符重载,当然还有深拷贝浅拷贝等问题。(简单点说,就是无法让自己写的类用起来像标准数据类型一样丝滑)往往会把自己绕晕进去,本文着重总结这方面的知识。

通过阅读书籍,发现很多书将这些内容的时候往往会通过设计类来把知识点一步一步地讲解清楚。这样做确实挺好,但是回过头才发现自己忘得差不多了。我这篇文章的行文逻辑是先讲清楚概念然后再来设计类,边设计类边引用先前讲过的知识。

关于类与对象的基础知识可以参考【C++】——类和对象:类的引入、类的定义、类的访问限定符及封装、类的作用域、类的实例化、类对象大小的计算、this指针

一、简述要设计一个什么类

设计一个表示股票的类,对象就是人,他可以进行股票的相关操作。
我们想要有以下的可执行的操作行为:

  • 获得股票(acquire);
  • 增持(buy)(在已有某只股票的基础上进一步购买更多);
  • 卖出股票(sell);
  • 更新股票价格(update);
  • 显示关于所持股票的信息(show);

为此,我们将存储以下的信息:

  • 公司名称(company);
  • 所持股票数量(shares);
  • 每股价格(share_val);
  • 股票总值(total_val);

指定类设计的第一步是提供类声明。类声明类似结构声明,可以包 括数据成员和函数成员。声明有私有部分,在其中声明的成员只能通过 成员函数进行访问;声明还具有公有部分,在其中声明的成员可被使用 类对象的程序直接访问。

指定类设计的第二步是实现类成员函数。 可以在类声明中提供完整的函数定义, 而不是函数原型, 但是通常的做法是单独提供函数定义(除非函数很小,直接定义在类声明中,会被自动当作内联函数) 。 在这种情况下, 需要使用作用域解析运算符::来指出成员函数属于哪个类。

以下是函数声明:

// stock10.h Stock class declaration with constructors, destructor added
#ifndef STOCK1_H_
#define STOCK1_H_
#include <string>
class Stock
{
private:
    std::string company;
    long shares;
    double share_val;
    double total_val;
    void set_tot() { total_val = shares * share_val; }
public:
    Stock();        // default constructor
    Stock(const std::string & co, long n = 0, double pr = 0.0);  // reload constructor;n is shares; pr is share_val;
    ~Stock();       // noisy destructor
    void buy(long num, double price);
    void sell(long num, double price);
    void update(double price);
    void show();
};

#endif

二、访问控制与封装

C++中类的访问控制是通过三个访问修饰符来实现的:publicprotectedprivate。这些修饰符控制类成员(属性和方法)的访问范围。下面是一个表格,详细说明了它们之间的关系:

访问修饰符类内部类外部子类(派生类)
public
protected
private
  • public: 公有成员可以在类的内部、外部以及派生类中访问。
  • protected: 保护成员可以在类的内部和派生类中访问,但不能在类的外部直接访问。
  • private: 私有成员只能在类的内部访问,不能在类的外部或派生类中访问。

简单地说,public成员是对所有人开放的,protected成员对派生类开放,而private成员只对类自身开放。这三种访问控制级别使得类的封装性得以保证,允许开发者在保护数据和实现细节的同时,提供清晰的接口给类的用户。


三、类的作用域

类中定义的名称(如类数据成员名和类成员函数名)的作用域都 为整个类。出了这个类,就啥也不是

3.1 作用域为类的常量

有时候,使符号常量的作用域为类很有用。例如,类声明可能使用 字面值30来指定数组的长度,由于该常量对于所有对象来说都是相同 的,因此创建一个由所有对象共享的常量是个不错的主意。你以为可以这样:

class Bakery {
private:
    const int Months = 12;
    double costs[Months];
}

❌❌❌❌❌❌❌❌!!!达咩!!因为声明类只是描述了对象的形式,并没有创建对象。因此,在创建对象前,将没有用于存储值的空间。
这时我们一般用类中定义常量的方式——使用关键字 static(当然枚举也可以,但我不怎么用):

class Bakery {
private:
    static const int Months = 12;  //将Months该常量与其他静态变量存储在一起,而不是存储在对象中。
    double costs[Months];
}

10.6.2 作用域内枚举(C++11)

我不咋用,等用到了,感受到了它的好处再水一篇博客(咳咳咳… …)


四、构造函数和析构函数

C + + 的目标之一是让使用类对象就像使用标准类型一样。 \textcolor{red}{C++的目标之一是让使用类对象就像使用标准类型一样。} C++的目标之一是让使用类对象就像使用标准类型一样。

每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫构造函数(constructor)。构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。
构造函数可以有很多个,类似函数重载。

  • 构造函数可以初始化一个已声明为 const、volatile 或 const volatile 的对象。 该对象在构造函数完成之后成为 const。
  • 若要在实现文件中定义构造函数,请为它提供限定名称,如同任何其他成员函数一样:Stock::Stock(){…}。

4.1 成员初始化表达式列表

构造函数可以选择具有成员初始化表达式列表,该列表会在构造函数主体运行之前初始化类成员。

首选成员初始化表达式列表,而不是在构造函数主体中赋值。 成员初始化表达式列表直接初始化成员。 以下示例演示了成员初始化表达式列表,该列表由冒号后的所有 identifier(argument) 表达式组成:

Stock(const std::string &co, long n, double pr):company(co), shares(n), share_val(pr) {} 

这个构造函数接受三个参数:co、n 和 pr,并且使用这些参数初始化类的成员变量 company、shares 和 share_val。

4.1默认构造函数

Stock stock1;

默认构造函数通常没有参数,但它们可以具有带默认值的参数。

如果没有声明任何构造函数,则编译器会自动生成默认构造函数,叫做合成的默认构造函数(synthesized default constructor)

//函数声明
Stock();
Stock() = default;   //C++11新标准,和第一个方式类似
Stock(const std::string &co, long num = 0, double price = 0.0);   //具有带默认值的参数
Stock(const std::string &co = "ustc", long n = 0, double pr = 0.0):company(co), shares(n), share_val(pr) {} //具有带默认值的参数

也可以在函数定义的时候给定默认值

// 函数定义:
Stock::Stock(){
    company = "no name";
    shares = 0.0;
    share_val = 0.0;
    total_val = 0.0;
}

在设计类时,通常应提供对所有类成员做隐式初始化的默认构造函数。

4.2 有参构造函数

//函数声明
Stock(const std::string &co = "ustc", long num = 0, double price = 0.0);   
//肯定还要加函数定义

Stock(const std::string &co, long num = 0, double price = 0.0);  // 少一个带默认值的,也可以在函数定义给它补上,甚至可以在定义成员变量的时候就带有默认值
//成员初始化表达式:函数声明+函数定义,一行代码搞定
Stock(const std::string &co = "ustc", long n = 0, double pr = 0.0):company(co), shares(n), share_val(pr) {}

但这二者还是有区别:

  1. 第一个构造函数仅仅是声明了构造函数的存在和它的参数列表,并且为所有参数提供了默认值。这意味着它可以作为默认构造函数使用。然而,这个构造函数的具体实现(即如何初始化成员变量)需要在类的外部或内部另外定义。

  2. 第二个构造函数不仅声明了构造函数,还提供了具体的实现细节,即如何使用参数初始化类的成员变量。这个构造函数同样为所有参数提供了默认值,因此它也可以作为默认构造函数使用。

4.3 转换构造

思考一下标准类型中变量的类型转换行为:

int a = 3.14;

这里之所以会运行成功,是因为首先(编译器没有优化的情况下且情况很复杂):

  1. 3.14会有一个隐式类型转换(int)3.14,生成临时变量
  2. 这个临时变量会赋值给a:a = 临时变量

同理,我设计的类也想要有这样的一个功能其他类型转换成为我这个类的数据类型。当然,如何转换需要通过我自己设计转换构造函数的定义来告诉编译器如何实现别的类型转换成我这个类型。看看下面这个简单的例子:

#include <iostream>

using namespace std;
class A{
private:
    int x;
public:
    A(){
        cout << this << "constructor"<<endl;
    }
    
    A(int x){
        this->x=x;  //转换构造
        cout << this << ":transform constructor" << endl;
    }
    
    ~A(){
        cout << this << "destructor" <<endl;
    }
};

int main(){
	  A a;
    A b(100);   //直接调用转换构造
    a = 100;    //先调用转换构造,生成一个临时变量,后再赋值给A类型变量a
    
    return 0;
}

A b(100);这个转换构造自不必说。 a = 100;却要好好说说:

  1. 先到用转换构造,生成一个A类型的临时变量==(匿名对象)==
  2. 再将该临时变量赋值给同样是A类型的a

所以要实现上述的a = 100;还应该定义赋值运算符的重载!!! operator=

A &operator=(const A &a){
    cout << "operator = "<<endl;
    return *this;
}

当然有了转换构造,将别的类型转换成为自己类这个类型;当然就会有将自己类这个类型转换成其他数据类型,这个时候就要请出类型转换函数了

可以参考这篇文章C++转换构造函数和隐式转换函数
至于为什么不用我前面定义的那个股票类?因为股票类不好用这种转换构造啊!到是数学上定义的复数,向量能很好地使用转换构造和类型转换函数!(之后有时间填坑)

4.4 拷贝构造

我们已经有a对象了,我们想利用a对象去构造新的对象b。
但注意:别把拷贝构造和赋值操作给搞混了,像这种情况:

Stock a("Furry Mason", 50, 2.5);
Stock b;
b = a;  // 这不是拷贝构造!!!这是赋值运算,定义好赋值运算符重载就好!!

上述的情况是这样的:因为b已经存在且构造好了(调用的是无参默认构造),而拷贝构造是在还没有b的情况下要借助a来构造!

应该这样:

Stock a("Furry Mason", 50, 2.5);
Stock b(a);      //基于a把b的内容生成出来,这才是拷贝构造

拷贝构造函数用于创建一个对象的副本,通常在以下情况下被调用:

  1. 通过使用另一个同类型对象初始化新对象时。
Stock c(b);  // 使用拷贝构造函数
  1. 函数参数传递时,如果参数是按值传递,则会调用拷贝构造函数创建参数的副本。
void func(Stock s);  // 调用 func(b) 时,会使用拷贝构造函数。借用实参来进行形参初始化
  1. 函数返回值时,如果返回的是对象,则会调用拷贝构造函数创建返回值的副本。
Stock func() { return b; }  // 返回时,会使用拷贝构造函数

接下里写一下关于股票类的拷贝构造:

// 拷贝构造函数:函数声明+定义
    Stock(const Stock &s)
        : company(s.company), shares(s.shares), share_val(s.share_val), total_val(s.total_val) {
        std::cout << "Copy constructor called.\n";
    }

在C++中,拷贝构造函数通常使用引用来接收另一个对象作为参数,原因如下:

避免无限递归:如果拷贝构造函数的参数不是引用,那么在调用拷贝构造函数时,会尝试通过值传递来复制参数对象,这又会触发拷贝构造函数的调用,从而形成无限递归,最终导致程序崩溃。

因此,为了防止无限递归和提高效率,拷贝构造函数的参数通常是对另一个对象的引用,如 Stock(const Stock &s)。这样,拷贝构造函数可以直接访问原始对象 s 的成员变量,而不需要创建 s 的副本。

拷贝构造中,形参前面最好加一个const,意味着不会去改变给你的对象的内容。像这里的Stock(const Stock &s)

4.4.1 深拷贝&浅拷贝

为了简单好记起见,我下一个暴论(排除掉智能指针):只要类中成员变量涉及到动态内存空间申请,必然会引出深拷贝和浅拷贝问题

using namespace std;

class Array{
private:
    int n;
    int *data;
public:
    Array(int n){
        this->=n;
        //动态开辟空间
        this->data=new int[n];   //储存在堆里,函数的局部变量在栈里
    }
    
    //访问数组中的元素,运算符重载
    int &operator[](int idx){
    	if(idx<0 || idx>=n) return end;
    	return data[idx];
		}
    
    size_t size(){
        return n;
    }
    
    void output(){
        cout << "Array(" << data << "):";
        for(int i=0; i<n; ++i){
            i && cout << ",";
            cout << data[i];
        }
        cout << endl;
    }
};

浅拷贝:

Array arr(10);
for(int i =0; i<arr.size();++i){
    arr[i]=rand()%100;
}
arr.output();
	
Array brr(arr);    //调用拷贝构造
Array brr = arr;   //和上面的等价。这里的等号不是赋值运算而是初始化

注意:如果我不写拷贝构造,计算机会自动帮我生成拷贝构造。

以下是计算机会帮助我们自动生成的函数:

  1. 默认的无参构造
  2. 默认的拷贝构造
  3. 默认的析构函数

如果自己定义,那么计算机就不会帮我们自动生成构造。

如果我们调用brr.output(),会发现输出是一样的!!!!!

Array(0xec4010):34,35,65,15,23,26,24,56,94,27
Array(0xec4010):34,35,65,15,23,26,24,56,94,27

说明二者的数组是一样的(就是同一个东西,内存地址都是一样的)。你改brr元素,那么arr中的元素也会被改变。这就是深拷贝,因为我们在定义data的时候用的是指针,相当于是它将你的指针(也就是地址),都给拷贝下来了。

深拷贝:
为了避免像浅拷贝那样把内存地址也给拷贝过来,我们需要自己去定义拷贝构造函数:

Array (Array &a){
    this->n=a.n;
    //this->data=a.data; 这是浅拷贝
    this->data=new int[this->n];
    for(int i=0; i<this->n; ++i){
        this->data[i]=a.data[i];
    }
}

//构造的时候动态申请,那就必须在使用完之后要释放这块内存
~Array(){
    delete[] data;
}

注意:这里会出现一个非常经典的问题:

我们假设定义了上述的析构函数。

我们将拷贝构造这样定义:

Array (Array &a){
    this->n=a.n;
    this->data=a.data;    
}

运行程序就会发现:内存泄漏。原因是arr会释放掉data的内存,brr也会释放data的内存,二者会冲突!!!多次释放同一块内存。

最终程序应该是这样的:

#include <iostream>
#inlcude <cstdlib>
using namespace std;

class Array{
private:
    int n;
    int *data;
public:
    Array(int n){
        this->=n;
        //动态开辟空间
        this->data=new int[n];   //储存在堆里,函数的局部变量在栈里
    }
    
    //拷贝构造
    Array (Array &a){
    	this->n=a.n;
    	//this->data=a.data; 这是浅拷贝
    	this->data=new int[this->n];
    	for(int i=0; i<this->n; ++i){
        	this->data[i]=a.data[i];
    	}
	}
    
    //访问数组中的元素
    int &operator[](int idx){
    	if(idx<0 || idx>=n) return end;
    	return data[idx];
	}
    
    size_t size(){
        return n;
    }
    
    void output(){
        cout << "Array(" << data << "):";
        for(int i=0; i<n; ++i){
            i && cout << ",";
            cout << data[i];
        }
        cout << endl;
    }
    
    //构造的时候动态申请,那就必须在使用完之后要释放这块内存
	~Array(){
    	delete[] data;
	}
};

4.5 RVO (Return Value Optimization)

RVO (Return Value Optimization) 是一种编译器优化技术,用于优化函数返回值的处理,以避免不必要的对象拷贝或移动操作。它的目标是提高程序的性能和减少不必要的开销,特别是在处理大型对象时。RVO 主要针对函数返回值是对象时的情况

在没有RVO的情况下,当一个函数返回一个对象时,通常会发生以下步骤:

  1. 在函数内部创建一个临时对象作为返回值。
  2. 将这个临时对象拷贝到函数调用者的内存空间中,通常是一个栈帧中的位置。
  3. 销毁函数内部的临时对象。

这个过程中的第2步涉及到拷贝操作,这会导致额外的性能开销,特别是对于大型对象或资源密集型对象。

MyClass func() {
    MyClass obj;
    // 在这里对obj进行操作
    return obj;
}

MyClass result = func();

如果编译器支持RVO,它可能会优化掉对obj的复制操作,直接将obj的构造在result中,从而减少了拷贝构造的开销。

RVO是C++编译器常见的优化之一,但它并不是强制要求的。编译器可以根据自己的策略和情况来决定是否执行RVO优化。在大多数情况下,现代C++编译器会执行RVO来提高代码性能,但不能依赖特定的RVO行为,因为编译器可能会因不同的编译器和编译选项而有所不同。

为什么会这样?得通过汇编语言来看,下面就回个简单例子:

#include <iostream>
using namespace std;

class A{
    int x;
puiblic:
    A(){
        cout << this << "default cosntructor" << endl;
    }
    A(int x):x(x){
        cout << this << "param constructor" << endl;
    }
    
    A(const A &a):x(a.x){   
        cout << this << "copy constructor" << endl;
    }  //拷贝构造中,形参前面最好加一个const,意味着不会去改变给你的对象的内容
    
    ~A(){
        cout << this << " deconstructor" << endl;
    }
};

A func(){
    A tmp(100);
    cout << "&tmp:" << &tmp << endl;   //输出tmp地址
    return tmp;
}

int main(){
    A a=func();
    cout << "&a:" << &a << endl;   //输出a的地址
    return 0;
}

输出结果:

0x7ffc056a87e4 param constructor
&tmp:0x7ffc056a87e4
&a:0x7ffc056a87e4
0x7ffc056a87e4 destructor

这里发现tmp的地址和a的地址一样,说白了,tmp为a的引用。就是把a的地址放在了tmp这里。

底层逻辑是这样的:
a将地址传到func()中去,存到tmp中,接着在func()中调用构造函数,来看看汇编层面下是怎么样的:

;将a地址传递给func()
&a -> %rdi

;func()中
movq %rdi, -24(%rbp)
movq -24(%rbp), %rax  ;rax可以理解为临时变量
movl $100, %esi  ;esi代表传给函数的参数,f(di, si, ......)
movq %rax, %rdi  ;这里已经准备好将di和si的值准备传递给构造函数

于是从func()函数跳转到类中定义的有参构造函数,因为传进去的参数经过函数重载中的函数匹配,匹配到了有参构造(地址和值对应的就是param constructor函数)

;这里进入传参构造函数
...
movq %rdi, -8(%rbp) ;这里存的是a的地址(&a)
movl %esi, -12(%rbp) ;这里存的是100
movq -8(%rbp), %rax
movl -12(%rbp), %edx
movl %edx, (%rax)   ;这句话就对应了 A(int x):x(x)这条语句,加了()代表访问的是这块内存指向的地址

总结一下就是:

  1. A a=func();这条语句将a的引用(地址)传给了func函数
  2. A tmp(100);这里tmp匹配上了类中构造函数的有参构造,又因为tmp是a的引用,所以这里直接对a进行了构造。

我们如果不用编译器的优化呢?这样编译g++ rvo.cpp -fno-elide-constructors,其结果:
在这里插入图片描述

  1. 先带参数构造,得到tmp(构造了tmp)
  2. tmp return 了A类型的对象(非引用),会有一个匿名对象(A类)。相当于就是那tmp去拷贝构造了一个匿名对象(匿名对象初始化)。
  3. 用匿名对象对A进行拷贝构造

注意,这里的返回值优化并不是一成不变的,而是随着时代的发展,逐渐完善,所以万一过几年这种优化策略就被替换了呢?谁也说不好。

4.6 移动构造

下一篇文章将会做详细说明。但是为了构造函数这个知识点的完整性,还是在这里先简单地讲一下。

移动构造函数是C++11引入的一个新特性,它是一种特殊的构造函数,用于在创建对象时将资源从一个临时对象(右值)转移到新对象中。这种转移操作通常比传统的复制操作更高效,因为它避免了不必要的资源拷贝。

移动构造函数的一般形式如下:

Copy code
ClassName(ClassName&& other);

其中&& 表示该参数是一个右值引用。

在移动构造函数的实现中,你通常会将 other 对象的资源转移到新创建的对象中,并将 other 对象的状态设置为一种合适的"空"状态。这样做的目的是确保在 other 对象被销毁时,不会释放已经转移给新对象的资源。

举个例子:

class MyVector {
private:
    int* data;
    size_t size;

public:
    // 移动构造函数
    MyVector(MyVector&& other) noexcept
        : data(other.data), size(other.size) {
        // 将 other 的资源转移给新对象
        other.data = nullptr;
        other.size = 0;
    }

    ~MyVector() {
        delete[] data;
    }
};

在这个示例中,MyVector 类的移动构造函数接受一个右值引用参数 other,并将 other 的资源(data 指针和 size)转移到新创建的对象中。然后,它将 other 的 data 指针设置为 nullptr,size 设置为 0,以防止 other 对象被销毁时释放已经转移的资源。

使用移动构造函数可以显著提高程序的性能,特别是在涉及到临时对象或返回大型对象的场景中。

4.7 析构函数

就是销毁声明的对象,有几点需要注意:

  1. 先声明,后销毁。(因为万一后声明的对象需要前面声明的对象。类似栈)
  2. 遇到成员变量有指针的情况,析构函数应该有delete [] 指针成员变量
  3. 如果不定义析构函数,编译器将会给出默认的析构函数。遇到指针成员变量一定要自己析构!!!

4.8 delete & default

很多时候经常搞不明白调用的编译器默认的构造函数,还是我们自己设定的构造函数。所以可以设置关键字来给后面的人做一个提醒.

#include <iostream>
using namespace std;

class A{
public:
    A()=default;
    //A(const A &a)=default;
    A(const A &a)=delete;
    //只是做了一个说明
}

int main(){
    A a;
    A b;
    a=b;  //这里是赋值,和拷贝不一样
    
    return 0;
}

总结

这篇博客尽管在介绍简单的类的相关知识点,但重点还是放在了如何定义构造函数上面:

  1. 默认构造函数:一般无参,但最好设置默认值
  2. 有参构造:很常用,一般用初始化列表的方式,既做了函数声明又做了函数定义。效果杠杠的!!
  3. 转换构造:参数往往是一个,就是用在其他类型转换成该类类型。往往伴有类型转换函数。
  4. 拷贝构造:记住和赋值运算做区别,赋值运算是俩对象都已声明了,而拷贝构造是用一个已有的对象去创造新对象。拷贝构造的参数是用一个常引用。拷贝构造常用在:
    1. 通过使用另一个同类型对象初始化新对象时。
    2. 函数参数传递时,如果参数是按值传递,则会调用拷贝构造函数创建参数的副本。(用实参拷贝构造形参)
    3. 函数返回值时,如果返回的是对象,则会调用拷贝构造函数创建返回值的副本。(这里往往有RVO优化)
  5. 移动构造。将临时变量通过高效的方式构造一个新的对象。

当然,还有一个情况,那就是成员变量有指针的时候。拷贝构造往往会有深拷贝和浅拷贝的情况,进而引发内存泄漏的问题。析构函数也要有相应的方式去销毁相应的指针。

  1. 深拷贝和浅拷贝是指在赋值一个对象时,拷贝的深度不同。 区别是浅拷贝是拷贝了对象的引用,当原对象发生变化的时候,拷贝对象也跟着变化;深拷贝是另外申请了一块内存,内容和原对象一样,更改原对象,拷贝对象不会发生变化。

参考资料:

  1. 《C++ Primer 5th》
  2. 《C++ Primer Plus 6th》
  3. https://learn.microsoft.com/zh-cn/cpp/cpp/constructors-cpp?view=msvc-170#default_constructors
  4. C++转换构造函数和隐式转换函数
  5. 海贼宝藏——星星老师
  6. 【C++】——类和对象:类的引入、类的定义、类的访问限定符及封装、类的作用域、类的实例化、类对象大小的计算、this指针
  • 26
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 9
    评论
评论 9
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值