C++学习笔记-第5单元
文章目录
注:本部分内容主要来自中国大学MOOC北京邮电大学崔毅东的《C++程序设计》课程。
第5单元 对象和类(高级)
单元导读
本单元介绍对象和类的一些高级特性,比如析构函数、委托构造、拷贝构造、深拷贝浅拷贝等。此外还介绍了断言、C++11的常量表达式及静态断言、vector类等内容。
本单元的难点 有两个:
一是静态成员。静态数据成员是由类的所有对象共享的,只有一份。静态数据成员的声明/定义、访问方式都有特殊性。
二是深拷贝和浅拷贝。要理解深浅拷贝这种现象发生的前提是与类的指针成员有关。
本单元的目标 是学完本单元,编程时要会:
- 用静态数据成员统计对象数量;
- 用代理构造函数简化代码;
- 用拷贝构造函数、析构函数;
- 解决深拷贝的问题;
- 用assert()调试程序;
- 知道用vector代替数组。
5.1 [C++11]断言与常量表达式
5.1.1 常量表达式与constexpr关键字
常量表达式 (Constant expressions)是编译期可以计算值的一个表达式,也就是说编译器来执行这段代码。有时我们需要一个编译期常量(如C++数组的大小),但是 const
修饰的对象既可能是编译期常量也可能是运行期常量,这时就需要用到constexpr
(只定义编译器常量)。下面的代码展示了const
在声明数组时存在的问题:
/* 使用变量声明数组大小:错误 */
int n = 1;
n ++;
std::array<int, n> a1; // error: n is not a constant expression
/* 使用const常量声明数组大小:正确 */
const int cn = 2;
std::array<int, cn> a2; // OK: cn is a constant expression
/* 使用变量赋值的const常量来声明数组大小:错误 */
const int rcn = n; // rcn is runtime constant, compiler does
// NOT know its value at compile-time
rcn = ++cn; // error: rcn is read-only
std::array<int , rcn> a3; // error: rcn is NOT known at compile-time
constexpr
是C++11中的关键字,称为 编译期常量表达式说明符,constexpr说明符声明可在编译时计算函数或变量的值。下面给出constexpr
的使用示例代码:
/* 将max函数变成编译期常量表达式 */
/* 注:这要求所传递的实参都需要时编译期常量 */
constexpr int max(int a , int b) { // c++11 引入 constexpr
if (a > b) return a; // c++14才允许constexpr函数中有分支循环等
else return b;
}
int main() {
int m = 1;
const int rcm = m++; // 运行期常量
const int cm = 4; // 编译期常量,等价于: constexpr int cm = 4;
int a1[ max(m , rcm)]; // 错误:m & rcm 不是编译期常量
std::array<char , max(cm , 5)> a2; // OK: cm 和 5 是编译期常量
}
const
和constexpr
两者主要的区别是:
const:告知程序员,const 修饰的内容是不会被修改的。主要目的是帮程序员避免bug 。
constexpr:用在所有被要求使用“constant expression”的地方(就是constexpr 修饰的东西可以在编译期计算得到值),主要目的是让编译器能够优化代码提升性能 。
/* 第3、4行体现出const的作用 */
char* s1 = "Hello"; // C语言允许,但C++编译出错
*s1 = 'h'; // C语言中,语法正确,但运行时会出错
const char* s2 = "World"; // C++ 要求加const
*s2 = 'w'; // C++编译器报错
另外,constexpr
不只能修饰编译器常量表达式,还可以修饰函数、类的构造函数、模板函数等。可以查看C语言中文网 C++11 constexpr:验证是否为常量表达式(长篇神文) 进一步学习。 constexpr
用法有非常多的细节(cppreference.com 列出了30 多个条目),且C++14、C++17、C++20都对其有细节上的修改,初学只需简单了解即可。
5.1.2 断言与C++11的静态断言
断言是一条检测假设成立与否的语句。若假设成立,断言就会悄无声息;若假设不成立,断言就会中断程序的执行。C语言中的断言为assert()
,在C++11则定义了静态断言static_assert()
。下面分别对其进行介绍。
(1)assert()
assert
是C语言的宏(Macro),运行时检测假设是否成立,注意这个假设不一定是编译期常量表达式。使用时需要调用包含头文件,并以 调试模式 编译程序。调试模式(debug)和发行模型(release)的主要区别在于给编译器传递的编译参数不同(而不是编译的程序不同),比如debug模式会添加额外的附加的用于调试的信息(断点等);而release模型则不会放调试信息,而是会对代码进行编译优化,使代码又小、运行速率又快。下面是用法示例:
#include<cassert> //调试模式编译程序
assert( bool_expr ); // bool_expr 为假则中断程序
std::array a{ 1, 2, 3 }; //C++17 类型参数推导
for (size_t i = 0; i <= a.size(); i++) {
assert(i < 3); //断言:i必须小于3,否则失败
std::cout << a[i];
std::cout << (i == a.size() ? "" : " ");
}
注意到assert()
依赖于NDEBUG宏(non-debug)。NDEBUG这个宏是C/C++标准规定的,所有编译器都有对它的支持。
(1)调试(Debug)模式编译时,编译器不会定义NDEBUG,所以assert()宏起作用。
(2)发行(Release)模式编译时,编译器自动定义宏NDEBUG,使assert()宏不起作用。
(3)手动输入#undef NDEBUG
,强制使得assert()生效。
(4)手动输入#define NDEBUG
,强制使得assert()不生效。
assert
主要用来帮助调试解决逻辑bug(由于使用“断点/单步调试”可能会很复杂,所以可以部分替代)。当假设不成立时,控制台会显示assrt()
括号内的整个语句。下面是一个程序示例:
#undef NDEBUG // 强制以debug模式使用<cassert>
int main() {
int i;
std::cout << "Enter an int: ";
std::cin >> i;
assert((i > 0) && "i must be positive"); //注意这里用了与运算符
return 0;
}
上面示例的第6行代码中,若assert中断了程序则表明程序出bug了!程序员要重编代码解决这个bug,而不是把assert()放在那里当成正常程序的一部分。
关于何时使用断言assert()
这个问题,《代码大全2》:若某些状况是你预期中的,那么用错误处理;若某些状况永不该发生,用断言(Use error-handling for conditions you expect to occur; use assertions for conditions that should never occur.)。
/***可以预料到的问题不使用断言***/
int n{ 1 } , m{ 0 };
std::cin >> n;
assert((n != 0) && "Divisor cannot be zero!"); // 不合适
int q = m / n;
/***预料不到的问题才使用断言***/
int n{ 1 } , m{ 0 };
do { // 这是修补bug的代码
std::cin >> n; // 断言失败后,要解决这个bug
} while (n == 0); // 在这里编写修复bug的代码
assert((n != 0) && "Divisor cannot be zero!");
int q = m / n;
(2)C++11:static_assert
static_assert()
是一个关键字。C++11中,静态断言的调用语法如下:
static_assert ( bool_constexpr, message)
//1. bool_constexpr: 编译期常量表达式(注意不能有变量),可转换为bool类型
//2. message: 字符串字面量,是断言失败时显示的警告信息。自C++17起,message是可选的。
static_assert
的作用是编译时断言检查,常用在模版编程中,对写库的作者用处大,对于一般的C++程序员几乎用不到。示例如下:
// 下面的语句能够确保该程序在32位的平台上编译进行。
// 如果该程序在64位平台上编译,就会报错 (例子来自MSDN)
static_assert(sizeof(void *) == 4, "64-bit code generation is not supported.");
(3)assert()使用示例
#include<iostream>
#include<array>
#include<cassert>//for using assert
using std::cin;
using std::cout;
using std::endl;
//任务一:用递归计算阶乘factorial,并用assert检查3的阶乘
//任务二:将factorial变成常量表达式,用static_assert检查3 的阶乘
//任务三:创建factorial(4)大小的数组
constexpr int factorial(int n) {
if (n == 0) {
return 1;
}
else {
return n * factorial(n - 1);
}
}
int main() {
//任务一
auto f3 = factorial(3);
assert(f3 == 6 && "3的阶乘应该是6");
cout << "3! = " << f3 << endl;
//任务二
static_assert(factorial(4) == 24, "factorial should be 24");
//有错误的话,不需要运行就可以直接看到static_assert的波浪线提示
cout << "4! = " << factorial(4) << endl;
//任务三
std::array<int, factorial(4)> a;//定义一个数组(要求括号里是编译器常量)
cout << a.size() << endl;
cin.get();
return 0;
}
输出结果:
3! = 6
4! = 24
24
5.1.3 声明与定义
声明(declare/declaration)是引入一个标识符并描述其类型,无论这个标识符是类型、对象还是函数。编译器需要该“声明”,以便识别在别处使用该标识符。例如:
extern int bar; //extern声明bar已经在其他的源文件中声明好了
extern int g(int, int);//声明函数
double f(int, double); //声明函数时可以省略extern
class foo; //声明类时不允许使用extern
定义(define/definition)用来实例化/实现这个标识符。链接器需要“定义”,以便将对标识符的引用能链接到标识符所表示的实体。例如(注意没有花括号的话都算声明,而不是定义):
int bar; //定义一个实数
int g(int lhs, int rhs) {return lhs*rhs;} //定义函数体
double f(int i, double d) {return i+d;} //定义函数体
class foo {}; //定义一个类
定义与声明的区别(stack overflow文章)主要有三点:
- 定义有时可取代声明,反之则不行。
- 标识符可被声明多次,但只能定义一次(如下面的例子)。
- 定义通常伴随着编译器为标识符分配内存
//多次声明,不会报错
double f(int a, double b);
double f(int a, double b);
//多次定义,会报错
double f(int a, double b){};
double f(int a, double b){};
最后总结一下,声明 好像在说:某个地方有个foo;定义 好像在说:它在这儿,长成这样。
5.2 代理构造、不可变对象、静态成员
5.2.1 C++11的代理构造
代理构造/委托构造(Delegation Constructor)就是一个构造函数可以调用另外的构造函数,好处就是可以少些代码、使得逻辑更加清晰。需要注意的时,一定要避免递归调用目标(环形构造)。下面给出正常的代理构造和递归构造的例子:
/************一、正常的代理构造**********/
class A{
public:
A(): A(0){}//被调用构造函数放到主调构造函数的初始化列表位置
A(int i): A(i, 0){}
A(int i, int j) {
num1=i;
num2=j;
average=(num1+num2)/2;
}
//构造函数的调用次序:A() → A(int) → A(int, int)。
private:
int num1;
int num2;
int average;
};
/***********二、错误的递归构造**********/
class A{
public:
A(): A(0){}
A(int i): A(i, 0){}
A(int i, int j): A(){}
//调用次序:A() → A(int) → A(int, int) → A()
private:
int num1;
int num2;
int average;
};
5.2.2 不可变对象
不可变对象(immutable object)创建后,其内容不可改变,除非通过成员拷贝。不可变对象对于编写多线程程序很有帮助。
若想使类成为“不可变类”,必须以下满足三个条件:
(1)所有数据域均设置为“私有”属性。
(2)没有更改器函数(如seter函数等)。
(3)也没有能够返回可变数据域对象的引用或指针的访问器(下面的代码就是反例)。
int main() {
//假设已经定义好上面图所示Employee类
Employee empJack("Jack", Date(1970, 5, 3), Gender::male);
Date *birthday = empJack.getBirthday();//外部指针指向Employee类里面私有的数据成员
birthday -> setYear(2010);//改变了Employee类里面私有的数据成员
cout << "birth year after the change is " <<
empJack.getBirthDate() -> getYear() << endl;
return 0;
}
下面给出代码示例:
头文件Date.h
#pragma once//只包含一次头文件
//任务一:创建Date类
#include<iostream>
#include<string>
class Date {
private:
int year = 2019, month = 1, day = 1;//就地初始化(需要有默认值)
public:
int getyear() { return year; }
int getmonth() { return month; }
int getday() { return day; }
void setyear(int y) { year = y; }
void setmonth(int m) { month = m; }
void setday(int d) { day = d; }
Date() = default;//没有任何赋值
Date(int y, int m, int d) :year{ y }, month{ m }, day{ d }{ }
std::string tostring() {
return(std::to_string(year) + "-" + std::to_string(month) + "-" + std::to_string(day));
}//2019-1-1
};
头文件Employee.h
#include<iostream>
#include<string>
#include"Date.h"
//定义gender枚举类型(带有作用域范围的枚举类型)
enum class Gender {
male,
famale,
};
//定义employee类
class Employee {
private:
std::string name;
Gender gender;
Date birthday;
public:
void setname(std::string name) { this->name = name; }
void setgender(Gender gender) { this->gender = gender; }
void setbirthday(Date birthday) { this->birthday = birthday; }
std::string getname() { return name; }
Gender getgender() { return gender; }
Date* getbirthday() { return &birthday; }
std::string tostring() {
return (name + (gender == Gender::male ? std::string("male/") : std::string("female/"))
+ birthday.tostring());
}
//下面是构造函数
Employee(std::string name, Gender gender, Date birthday) :
name{ name }, gender{ gender }, birthday{ birthday }{ }//构造函数的初始化列表
//默认构造函数
Employee() : Employee("Li yu/", Gender::male, Date(2001, 5, 6)) { }//使用委托构造的形式
};
源文件main.cpp
#include<iostream>
#include"Employee.h"
#include"Date.h"
//创建employee对象,并修改其生日
int main() {
Employee e;
//1.setter
e.setbirthday(Date(1999, 9, 17));
std::cout << e.tostring() << std::endl;
//2.getter
e.getbirthday()->setyear(2000);//拿到的是地址就可以修改
std::cout << e.tostring() << std::endl;
std::cin.get();
return 0;
}
5.2.3 实例成员与静态成员
静态成员(Static Members)就是加了static
的在类中定义的成员。在类的定义中,关键字static
声明不绑定到类实例的成员(该成员无需创建对象即可直接使用类的名字进行访问)。静态数据成员定义的规则如下:
(1) 声明为“ constexpr ”类型的静态数据成员必须 在类中声明 并初始化。自C++17 起,不能在类外定义。
(2) 声明为“ inline ”(C++17 起)或者“ const int ”类型的静态数据成员 可以 在类中声明并初始化。
(3) 其它须在 类外定义 并初始化,且不带static关键字。
静态数据成员的定义规则复杂,在类外定义,大部分情况下不会出错,详细的使用方法参见静态成员的说明。
静态数据成员具有静态存储期(static storage duration)或者C++11线程存储期特性。静态存储期指的是对象的存储在程序开始时分配,而在程序结束时解回收。注意静态数据成员的两个特性:
(1) 只存在对象的一个实例
(2) 静态存储器对象未明确初始化时会被自动“零初始化(Zero-Initialization)”
在下面的例子中,一旦实例化了Square(创建了Square的对象),每个对象中都有各自的side成员。这个side成员就叫做实例成员(存在于栈区)。而numberOfObjects作为实例静态成员只存在一个(在全局/静态区),由所有的Square对象共享。
class Square {
private:
double side;//实例成员
static int numberOfObjects;//静态成员
// ...
public:
Square():Square(1.0){ }//无参构造函数
//有参构造函数
Square(double side){
this->side = side;
numberOfObjects++;
}
// ...
};
int Square::numberOfObjects;//静态成员在类外定义,未明确初始化会进行零初始化
int main() {
Square s1{}, s2{5.0};
}
下面给出测试的代码:
头文件Square.h
#pragma once
//常见square类
class square {
private:
double side{ 1.0 }; //实例成员
static int numberofobjects; //静态成员
public:
square(double side) {
this->side = side;
numberofobjects++;
}
square() :square(1.0) { }
double getside() { return side; }
double getarea() { return side * side; }
void setside(double side) { this->side = side; }
static int getnumberofobjects() { return numberofobjects; }
//使用非静态的函数成员来访问静态的数据成员
int getnumberofobjectsnonstatic() { return numberofobjects; }
};
源文件main.cpp
//通过类名/对象名调用调用静态成员函数
#include<iostream>
#include"Square.h"
int square::numberofobjects;//静态成员默认初始化为0
int main() {
square s1;
//通过对象的名字访问函数成员
std::cout << s1.getnumberofobjects() << std::endl;
square s2{ 20.0 };
//直接使用类的名字+函数成员的名字
std::cout << square::getnumberofobjects() << std::endl;
std::cout << s2.getnumberofobjectsnonstatic() << std::endl;
//std::cout << square::getnumberofobjectsnonstatic() << std::endl;
//上一行报错说明非静态成员函数不能用类调用
std::cin.get( );
return 0;
}
输出结果:
1
2
2
5.3 析构、友元、深浅拷贝
5.3.1 析构函数
析构函数(dtor)与构造函数(ctor)正好相反。构造函数会在创建对象时自动调用,而析构函数会在函数对象销毁的时候自动调用(使用~C()=delete;
可以强制编译器不默认生成析构函数)。注意析构函数没有参数,也没返回值。下面时析构函数与构造函数的对比:
Destructor | Constructor | |
---|---|---|
何时调用? | 对象销毁时 | 对象创建时 |
原型 | C::~C( ) | C::C(arguments) |
默认函数的原型 | C::~C( ) | C::C( ) 或参数带有默认值 |
没有显式声明怎么办 | 编译器会生成默认函数 | 编译器会生成默认函数 |
可否重载? | No, only 1 | Yes |
下面给出一个实例示例代码:
头文件Date.h
#pragma once
#include<iostream>
#include<string>
class date {
private:
int year = 2019, month = 1, day = 1;
public:
int getyear() { return year; }
int getmonth() { return month; }
int getday() { return day; }
void setyear(int y) { year = y; }
void setmonth(int m) { month = m; }
void setday(int d) { day = d; }
date() = default;
date(int y, int m, int d) :year{ y }, month{ m }, day{ d }{
//便于观察date数据的创建和销毁
std::cout << "date: " << tostring() << std::endl;
}
std::string tostring() {
return(std::to_string(year) + "-" + std::to_string(month) + "-" + std::to_string(day));
}
};
头文件Employee.h
#include<iostream>
#include<string>
#include"Date.h"
enum class Gender {
male,
female,
};
class employee {
private:
std::string name;
Gender gender;
date* birthday;//将雇员类的生日改为date*类型的指针
static int numberofobjects;//增加静态成员,计算雇员对象的数量
public:
void setname(std::string name) { this->name = name; }
void setgender(Gender gender) { this->gender = gender; }
void setbirthday(date birthday) { *(this->birthday) = birthday; }
std::string getname() { return name; }
Gender getgender() { return gender; }
date getbirthday() { return *birthday; }
std::string tostring() {
return (name + (gender == Gender::male ? std::string("male/") : std::string("female/"))
+ birthday->tostring());
}
/************************构造函数**************************/
employee(std::string name, Gender gender, date birthday) :
name{ name }, gender{ gender }
{
numberofobjects++;
this->birthday = new date{ birthday };//调用了date类的隐式声明的拷贝构造函数
//new会从堆上进行创建
std::cout << "Now there are: " << numberofobjects << "employees" << std::endl;
}
employee() : employee("Li yu/", Gender::male, date(2001, 5, 6)) { }
/************************析构函数**************************/
~employee() {
numberofobjects--;
//在构造函数里声明的对象,就应该在析构函数里归还
delete birthday;
birthday = nullptr;
}
};
源文件main.cpp
#include<iostream>
#include"Date.h"
#include"Employee.h"
int employee::numberofobjects = 0;//记得加作用域employee
//在堆和栈(函数作用域与内嵌作用域)上分别创建employee对象,观察析构函数的行为
int main() {
employee e1;
//将主函数的信息都编译打印出来
std::cout << e1.tostring() << std::endl;
employee* e2 = new employee{ "Li yu/",Gender::male,date(1999,9,17) };
std::cout << e2->tostring() << std::endl;
//大括号表示内嵌作用域,创建了一个栈上的对象(出栈会自动调用析构函数)
{
employee e3{ "Tong hua/",Gender::female,{2001,9,16 } };
std::cout << e3.tostring() << std::endl;
}
std::cin.get();
return 0;
}
输出结果:
date: 2001-5-6
Now there are: 1employees
Li yu/male/2001-5-6
date: 1999-9-17
Now there are: 2employees
Li yu/male/1999-9-17
date: 2001-9-16
Now there are: 3employees
Tong hua/female/2001-9-16
5.3.2 友元函数
私有成员无法从类外访问,但有时又需要授权某些可信的函数和类访问这些私有成员。这时就需要友元函数(Friend)。使用friend
关键字在类内声明友元函数或者友元类。友元函数会给库的编写者带来一些方便,尤其是后期进行运算符重载的时候,当想要重定义流输出运算符的时候,就需要使用友元的方式。友元的缺点是打破了封装性。
下面的例子中,Kid类和print函数都可以直接访问Date类中的私有成员:
class Date {
private:
int year{ 2019 }, month{ 1 }, day{ 1 };
public:
friend class Kid;//友元类
friend void print(const Date& d);//友元函数
};
class Kid {
private:
Date birthday;
public:
Kid() {
cout << "I was born in " << birthday.year << endl;
}
};
void print(const Date& d) {
cout << d.year << "/" << d.month << "/" << d.day << endl;
}
int main() {
print(Date());
Kid k;
cin.get();
}
5.3.3 拷贝构造函数
拷贝构造函数是一种特殊的构造函数,可以简写为 copy ctor
/ cp ctor
。在Unix/Linux中,拷贝文件的命令叫做 cp
。拷贝构造(Copy Constructions)的作用是用一个对象初始化另一个同类对象。
/********声明拷贝构造函数********/
Circle (Circle&);//方式一
Circle (const Circle&);//方式二
//例子:带有额外的默认参数的拷贝构造函数
class X { //来自C++11标准: 12.8节
// ...
public:
X(const X&, int = 1);
//只要第一个对象是同类对象的引用,后面的参数都有默认值,那就是拷贝构造函数
};
X b(a, 0); //假设a是一个已有的构造函数
X c = b; // calls X(const X&, int);
/********调用拷贝构造函数********/
Circle c1( 5.0 ); //先声明一个对象
Circle c2( c1 ); //c++03
Circle c3 = c1; //c++03,注意不是赋值运算符,而是拷贝构造函数
Circle c5{ c1 }; //c++11
Circle c4 = { c1 }; //c++11
注意上述调用cpoy ctor的例子中,若两个对象obj1和obj2已经定义,那么obj1 = obj2;
就不是调用拷贝构造函数,而是对象赋值。反之,若obj2是已经定义好的AClass对象,那么定义对象obj1:AClass obj1 = obj2;
的时候,等号(=)就会被解释为初始化,需要调用拷贝构造函数。
一般情况下,如果程序员不编写拷贝构造函数,那么编译器会自动生成一个。这个自动生成的拷贝构造函数就叫做 隐式声明的拷贝构造函数 (Implicitly-declared Copy Constructor,有时候也叫默认拷贝构造函数)。一般情况下,隐式声明的copy ctor简单地将作为参数的对象中的每个数据域复制到新对象中。
下面是拷贝构造函数的代码示例:
头文件Square.h
#pragma once
//添加拷贝构造函数、析构函数,添加输出信息
#include<iostream>
class square {
private:
double side{ 1.0 };
static int numberofobjects;
public:
double getside() { return side; }
void setside(double side) { this->side = side; }
//构造函数
square(double side) {
this->side = side;
numberofobjects++;
}
square() :square(1.0) { }
//拷贝构造函数
square(const square& s) :square(s.side) {//实现委托构造
//this->side = s.side;
//numberofobjects++;
std::cout << "square(const square&) is invoked" << std::endl;
}
//创建析构函数
~square() {
numberofobjects--;
}
double getarea() { return side * side; }
static int getnumberofobjects() { return numberofobjects; }
//使用非静态的函数成员来访问静态的数据成员
int getnumberofobjectsnonstatic() { return numberofobjects; }
};
主函数main.cpp
#include<iostream>
#include"Square.h"
//在堆和栈分别拷贝创建square对象
int square::numberofobjects = 0;//注意静态成员一定要初始化
int main() {
square s1(10.0);//正常创建一个square对象
std::cout << "square: " << square::getnumberofobjects() << std::endl;//调用静态成员
square s2{ s1 };//拷贝构造函数
std::cout << "square: " << square::getnumberofobjects() << std::endl;//调用静态成员
square* s3 = new square{ s1 };//在堆上创建一个square对象,注意是默认调用拷贝构造函数
std::cout << "square: " << square::getnumberofobjects() << std::endl;//调用静态成员
std::cout << "s3's area is: " << s3->getarea() << std::endl; //调用函数
delete s3;//会调用析构函数
std::cout << "square: " << square::getnumberofobjects() << std::endl;//调用静态成员
std::cin.get();
return 0;
}
运行结果
square: 1
square(const square&) is invoked
square: 2
square(const square&) is invoked
square: 3
s3's area is: 100
square: 2
5.3.4 深拷贝、浅拷贝
浅拷贝和深拷贝是一个与拷贝构造函数密切相关的问题,注意到只有当数据成员有指针类型才会存在这种问题,否则就没有深/浅拷贝的区别。如果一个类的某个数据域成员是一个指向其他对象的指针,那么 浅拷贝(Shallow Copy) 的意思就是,拷贝构造函数在拷贝的时候会只拷贝指针的地址,而非指针指向的对象/内容(这就会导致在修改指针成员所指向的内容的时候,会影响不止一个类对象)。而 深拷贝(Deep Copy) 的意思就是,拷贝构造函既拷贝非指针类型的成员,也拷贝指针指向的内容。
在两种情况下会出现浅拷贝:
(1) 创建新对象时,调用类的隐式/默认构造函数。
(2) 为已有对象赋值时,使用默认赋值运算符。
假设某个类如上图所示,那么以下的代码会执行浅拷贝:
Employee e1{"Jack", Date(1999, 5, 3), Gender::male};
Employee e2{"Anna", Date(2000, 11, 8), Gender::female};
Employee e3{ e1 }; //cp ctor,执行一对一成员拷贝
//上一行代码默认执行浅拷贝,会导致e3.birthday指针指向了e1.birthday所指向的那个Date对象
从上图中可以看出,浅拷贝得到的e3,会和e1共用一个Date指针,修改Date的参数会同时影响e1和e3。这时候就需要定制拷贝构造函数(Customizing Copy Construc),也就是深拷贝。
进行深拷贝的步骤如下:
(1) 自行编写拷贝构造函数,不使用编译器隐式生成的(默认)拷贝构造函数。
(2) 重载赋值运算符,不使用编译器隐式生成的(默认)赋值运算符函数。
class Employee {
public:
// Employee(const Employee &e) = default; //浅拷贝ctor
Employee(const Employee& e){ //深拷贝ctor
birthdate = new Date{ e.birthdate };//在堆区拷贝构造出新的外挂对象
} // ...
}
Employee e1{"Jack", Date(1999, 5, 3), Gender::male};
Employee e2{"Anna", Date(2000, 11, 8),, Gender:female};
Employee e3{ e1 }; //cp ctor 深拷贝
此时可以看到,进行深拷贝的对象,会独立的占有一个内存,不会影响所拷贝的对象。
下面是对于深浅拷贝的代码验证示例:
头文件Date.h
#pragma once
#include<iostream>
#include<string>
class date {
private:
int year = 2019, month = 1, day = 1;
public:
void setyear(int y) { year = y; }
void setmonth(int m) { month = m; }
void setday(int d) { day = d; }
int getyear() { return year; }
int getmonth() { return month; }
int getday() { return day; }
std::string tostring() {
return(std::to_string(year) + "-" + std::to_string(month) + "-" + std::to_string(day));
}
//构造函数
date() = default;
date(int y, int m, int d) :year{ y }, month{ m }, day{ d }{
//便于观察date数据的创建和销毁
std::cout << "date: " << tostring() << std::endl;
}
};
头文件Employee.h
//增加静态成员,计算雇员对象的数量
//将雇员类的生日改为date*类型的指针
//修改构造函数和析构函数
#include<iostream>
#include<string>
#include"date.h"
enum class Gender {
male,
female,
};
class Employee {
private:
std::string name;
Gender gender;
date* birthday;
static int numberofobjects;//rmission 1
public:
void setname(std::string name) { this->name = name; }
void setgender(Gender gender) { this->gender = gender; }
void setbirthday(date birthday) { *(this->birthday) = birthday; }
std::string getname() { return name; }
Gender getgender() { return gender; }
date getbirthday() { return *birthday; }
std::string tostring() {
return (name + (gender == Gender::male ? std::string("male") : std::string("female"))
+ birthday->tostring());
}
/************构造函数*************/
//调用了date类的拷贝构造函数
Employee(std::string name, Gender gender, date birthday) :
name{ name }, gender{ gender }
{
numberofobjects++;
this->birthday = new date{ birthday };
std::cout << "Now there are: " << numberofobjects << "employees" << std::endl;
}
//默认构造函数
Employee() : Employee("LiYu ", Gender::male, date(2001, 5, 6)) { }
//定义深拷贝的拷贝构造函数(没有这个函数就是默认浅拷贝)
Employee(const Employee& e) : name{ e.name }, gender{ e.gender }{
numberofobjects++;
this->birthday = new date{ *(e.birthday) };//调用了date类的拷贝构造函数
std::cout << "Now there are: " << numberofobjects << "employees" << std::endl;
}
/************析构函数*************/
~Employee() {
numberofobjects--;
//在构造函数里声明的对象,就应该在析构函数里归还
delete birthday;
birthday = nullptr;
}
};
源文件main.cpp
#include<iostream>
#include"Date.h"
#include"Employee.h"
//任务一:构造employee对象e1,拷贝构造e2
//任务二:调试模式观察e1和e2的birthday成员
//任务三:添加拷贝构造函数实现深拷贝
//任务四:调试模式观察e1和e2的birthday成员
int Employee::numberofobjects = 0;//静态成员的初始化
int main() {
Employee e1{ "LiYu",Gender::male,{1999,9,17} };//正常创建employee对象
std::cout << e1.tostring() << std::endl;
Employee e2{ e1 };//以拷贝构造的形式创建,深/浅拷贝(通过查看地址可以验证)
std::cout << e2.tostring() << std::endl;
std::cin.get();
return 0;
}
运行结果(不是很重要):
date: 1999-9-17
Now there are: 1employees
LiYumale1999-9-17
Now there are: 2employees
5.4 vector类和[C++14]字符串字面量
5.4.1 C++的vector类
C++的 vector类 和 数组 有些相似的地方,主要的不同是:数组的大小在编译的时候就已经确定好,容量不能够继续改变;而vector对象容量可以随着内容自动增大,比较方便编写一些复杂的程序。一个使用vector
的简单例子见下:
vector<int> iV {-2, -1, 0};//列表初始化
// Store numbers 1, ..., 10 to the vector
for (int i = 1; i < 10; i++)
iV.push_back(i + 1);//在尾部添加函数
vector类还有很多其他的函数,具体见下:
下面给出使用string
类和vector
类的示例代码:
头文件helper.h
#pragma once
#include<iostream>
#include<vector>
#include<string>
//下面的宏和运算符重载函数可以帮助更快的输出vector的内容
/*代码拷贝地址:https://en.cppreference.com/w/cpp/container/vector/vector*/
//宏定义
#define Print(x) std::cout<<#x<<":"<<x<<std::endl;
//运算符重载函数
template<typename T>
std::ostream& operator<<(std::ostream& s, const std::vector<T>& v) {
s.put('[');
char comma[3] = { '\0',' ','\0' };
for (const auto& e : v) {
s << comma << e;
comma[0] = ',';
}
return s << ']';
}
源文件main.cpp
#include<iostream>
#include<vector>
#include<string>
#include"helper.h"
int main() {
//用C++的列表初始化,创建vector对象words
std::vector<std::string> words1{ "hello","my","beloved","good","morning" };
Print(words1);
//删除words最后一个对象
words1.erase(words1.end() - 1);//.end()获取最后一个元素后面的位置
Print(words1);
//在words尾部追加元素
words1.push_back("afternoon");
Print(words1);
//用迭代器拷贝words1的内容以创建word2
std::vector<std::string> words2(words1.begin() + 3, words1.end());
Print(words2);
//在words2中插入元素
words2.insert(words2.begin(), "hello!");
Print(words2);
//用拷贝构造创建words3
std::vector<std::string> words3(words2);
Print(words3);
//用[]修改words3的元素
words3[2] = "evening";
Print(words3);
//创建words4,初始化为多个相同的字串
std::vector<std::string> words4(4, "Tong hua");
Print(words4);
//words3与words4交换
words3.swap(words4);
Print(words3);
Print(words4);
std::cin.get();
return 0;
}
运行结果:
words1:[hello, my, beloved, good, morning]
words1:[hello, my, beloved, good]
words1:[hello, my, beloved, good, afternoon]
words2:[good, afternoon]
words2:[hello!, good, afternoon]
words3:[hello!, good, afternoon]
words3:[hello!, good, evening]
words4:[Tong hua, Tong hua, Tong hua, Tong hua]
words3:[Tong hua, Tong hua, Tong hua, Tong hua]
words4:[hello!, good, evening]
5.4.2 C++14的字符串字面量
C++11 “原始/生”字符串字面量(Raw String literals),其中这个“原始”体现在字符串字面量在程序中写成什么样子,输出之后就是什么样子。我们不需要为“Raw String literals”中的换行、双引号等特殊字符进行转义。使用的语法为 R "delimiter( raw_characters )delimiter"
,注意两个分隔符(delimiter)要保持一致即可。下面是代码示例:
#include <iostream>
const char* s1 = R"(Hello
World)";
// s1效果与下面的s2和s3相同
const char* s2 = "Hello\nWorld";
const char* s3 = R"NoUse(Hello
World)NoUse";
int main(){
std::cout << s1 << std::endl;
std::cout << s2 << std::endl;
std::cout << s3 << std::endl;
}
C++14的字符串字面量 将运算符 ""s
进行了重载,赋予了它新的含义,使得用这种运算符括起来的字符串字面量,自动变成了一个 std::string
类型的对象(注意要包含std::string
头文件)。
auto hello = "Hello!"s; // hello is of std::string type
auto hello = std::string{"Hello!"}; // equals to the above
auto hello = "Hello!"; // hello is of const char* type
一个来自cppreference.com的例子:
#include <string>
#include <iostream>
int main() {
using namespace std::string_literals;//为了使用重载运算符""s
std::string s1 = "abc\0\0def";//注:\0一般作为字符串的结束符
std::string s2 = "abc\0\0def"s;
std::cout << "s1: " << s1.size() << " \"" << s1 << "\"\n";
std::cout << "s2: " << s2.size() << " \"" << s2 << "\"\n";
}
上面例子的一个可能输出结果如下:
s1: 3 "abc"
s2: 8 "abc^@^@def"
//注:下面是我的运行结果
s1: 3 "abc"
s2: 8 "abcdef"
5.5 栈(stack)
栈(Stack) 是一种后进先出的数据结构。栈的一个明显的应用就是在函数调用时,主函数传给子函数的参数先进栈,进入子函数后,子函数的局部变量也按序在栈中建立;子函数返回时,局部变量出栈、参数出栈。stack类(The Stack Class)封装了栈的存储空间并提供了操作栈的函数。下图展示了栈中各种成员的关系:
下面给出栈类的示例代码:
头文件StackOfIntegers.h
#pragma once
//编写stackofintegers类
class stackofintegers {
private:
int elements[100];
int size{ 0 };
public:
bool empty();
int peek();
int push(int value);
int pop();
int getsize();
stackofintegers();
};
源文件StackOfIntegers.cpp
#include"StackOfIntegers.h"
//默认构造函数—私有数据初始化
stackofintegers::stackofintegers() {
size = 0;
for (int& i : elements) {//这种方式使得i变成了数组中每一个元素的别名
i = 0;//将所有的成员都初始化为0
}
}
//判断栈类是否为空
bool stackofintegers::empty() {
return(size == 0 ? true : false);
}
//获取已存储元素的数量
int stackofintegers::getsize() {
return size;
}
//读取不移除栈点的值
int stackofintegers::peek() {
return elements[size - 1];
}
//将栈点移除并返回它的值
int stackofintegers::pop() {
int temp = elements[size - 1];
elements[size - 1] = 0;
size--;
return temp;
}
//将value的值放到栈里去
int stackofintegers::push(int value) {
elements[size] = value;
size++;
return value;
}
源文件main.cpp
#include<iostream>
#include"StackOfIntegers.h"
//创建stack对象,展示入栈出栈操作
int main() {
stackofintegers s1{ };//创建一个栈对象
/*************入栈操作*****************/
for (int i = 0; i < 5; i++) {//底 → 1 2 3 4 5 → 顶
s1.push(i + 1);//将五个元素压入到栈里面
}
std::cout << "stack size= " << s1.getsize() << std::endl;
std::cout << "top element is: " << s1.peek() << std::endl;
/*************出栈操作*****************/
const int size = s1.getsize();
for (int i = 0; i < size; i++) {
std::cout << s1.pop() << " ";//将五个元素弹出栈
}
std::cout << std::endl;
std::cout << "stack now is empty: " << s1.empty() << std::endl;
std::cin.get();
return 0;
}
运行结果:
stack size= 5
top element is: 5
5 4 3 2 1
stack now is empty: 1
5.6 [C++17]结构绑定化
5.6.1 用于数组的结构绑定化
在C++17中引入的 结构化绑定声明(Structured Binding Declaration) 是一个声明语句,意味着声明了好几个标识符,并同时对这些标识符做了初始化。即,将指定的好几个名字绑定到初始化器的子对象或者元素上。三种调用形态如下:
cv-auto &/&&(可选) [标识符列表] = 表达式;
cv-auto &/&&(可选) [标识符列表] { 表达式 };
cv-auto &/&&(可选) [标识符列表] ( 表达式 );
//cv-auto: 可能由const/volatile修饰的auto关键字。
//&代表左值引用;&&代表右值引用。
//标识符列表:逗号分隔的标识符。
若初始化表达式为C++原生数组类型,则标识符列表中的名字绑定到数组元素,称为 用于原生数组的结构化绑定声明。注意这种绑定的要求是:
- 标识符数量必须等于数组元素数量。
- 标识符类型与数组元素类型一致。
int main() {
int priArr [] {42, 21, 7};
//ai/bi/ci 的基本类型都是int,只是cv标识或引用标识不同
auto [a1, a2, a3] = priArr; // a1 是 priArr[0] 的拷贝,a2, a3类推
const auto [b1, b2, b3] (priArr); // b1 是 priArr[0] 的只读拷贝,b2, b3类推
auto &[c1, c2, c3] {priArr}; // c1 是 priArr[0] 的引用,c2, c3类推
c3 = 14; // priArr[2]的值变为14
return 0;
}
若初始化表达式为std::array
数组类型,则标识符列表中的名字绑定到数组元素,称为 用于std::array的结构化绑定声明。std::array
数组类型与原生数组类型的区别在于前者知道数组大小。注意这种绑定的要求是:
- 标识符数量必须等于std::array数组中的元素数量
- 标识符类型与std::array中的数组元素类型一致
int main() {
std::array stdArr = {'a','b','c'};//没有写<char, 3>表示使用了模板的自动推导
auto [d1, d2, d3] {stdArr};
return 0;
}
下面是结构化绑定数组元素的代码示例:
源文件main.cpp
#include<iostream>
#include<array>
int main() {
//原生数组的结构化绑定
int a[]{ 1, 2, 3 };//C++原生数组
auto [e1, e2, e3] = a;//若数量不一致则会报错
std::cout << e1 << " " << e2 << " " << e3 << std::endl;
const auto [f1, f2, f3] { a };//注意不能再修改
//f1 = 10;//不能给常量赋值
std::cout << f1 << " " << f2 << " " << f3 << std::endl;
auto &[g1, g2, g3] {a};
g1 = 5;
std::cout << a[0] << " " << g2 << " " << g3 << std::endl;
//std::array数组的结构化绑定
std::array b{ 11, 12, 13 };
//std::array<int, 2> b{ 4,5 };
auto [h1, h2, h3] = b;//若数量不一致则会报错
std::cout << h1 << " " << h2 << " " << h3 << std::endl;
const auto [i1, i2, i3] { b };//注意不能再修改
//f1 = 10;//不能给常量赋值
std::cout << i1 << " " << i2 << " " << i3 << std::endl;
auto& [k1, k2, k3] { b };
k1 = 15;
std::cout << b[0] << " " << k2 << " " << k3 << std::endl;
std::cin.get();
return 0;
}
运行结果
1 2 3
1 2 3
5 2 3
11 12 13
11 12 13
15 12 13
5.6.2 用于对象数据成员的结构化绑定声明
若初始化表达式为类/结构体类型,则标识符列表中的名字绑定到类/结构体的非静态数据成员上,此时称为 用于数据成员的结构化绑定声明。注意这种绑定的要求是:
- 数据成员必须为公有成员。注意
struct
的数据成员默认公有,而class
默认私有。- 标识符数量必须等于数据成员的数量。
- 标识符类型与数据成员类型一致。
- auto后跟&,则标识符是数据成员的引用;auto前可放置const,表明标识符是只读的。
#include<iostream>
class C { // 可以改用 struct C,然后去掉下面的public属性说明
public:
int i{ 420 }; // 就地初始化
char ca[4]{ 'O', 'K', '!', '\0'};//没有\0就会输出烫烫烫
//注意类中的数组初始化,必须要指明大小
};
int main() {
C c;
auto [a1, a2] {c}; // a1是int类型,a2是char[]类型
std::cout << "a1:" << a1 << std::endl;
std::cout << "a2:" << a2 << std::endl;
auto& [b1, b2] { c };// b1是int&类型,是c.i的引用,
// b2是char(&)[4]类型(数组的引用), 是c.ca的引用
a1 = 100;
std::cout << "c.i:" << c.i << std::endl; // 输出420,改a1不影响c.i
b1 = 200;
std::cout << "c.i:" << c.i << std::endl; // 输出200,通过b1修改了c.i
}
结果输出:
a1:420
a2:OK!
c.i:420
c.i:200