1.oop
2.const原则
2.1 const概念:
有时我们需要定义一种变量,它的值不能被改变。例如,用一个变量来表示缓冲区的大小。使用变量的好处是当我们觉得缓冲区大小不再合适时,很容易对其进行调整。另一方面,也应随时警惕防止程序一不小心改变了这个值。为了满足这个要求,可以用关键字const 对变量的类型加以限制:
const int bufSize =300;
这样就把bufSzie定义成了一个常量,任何试图(定义之后)改变bufSize值的行为都将引发错误。
因为const对象一旦创建后其值就不能改变,所以const对象必须初始化。
const int i = getSize(); //正确 运行时初始化
const int j=42; //正确:编译时初始化
const int k; //错误 k是一个未经初始化的常量
2.2 初始化和const
与非const类型所能参与的操作相比,const类型的对象能完成其中大部分,主要的限制就是只能在const类型的对象上执行不改变其内容的操作。如普通的算术运算(加减乘除)。在不改变const对象的操作中还有一种是初始化,如果利用一个对象去初始化另一个对象,则它们是不是const都无关紧要:
int i=42;
const int ci = i; //正确 i的值拷贝给ci
int j=ci; //正确 ci的值拷贝给j
理解:拷贝一个对象的值并不会改变它,一旦拷贝完成,新的对象就和原来的对象没什么关系。
小结:可以用普通变量去初始化
默认状态下,const对象仅在文件内有效。类似于宏定义,当以编译时初始化的方式定义一个const对象时(如1小节中的变量j),编译器将在编译过程中把用到该变量的地方都替换成对应的值。为了执行这种替换,编译器必须知道变量的初始值。如果程序包含多个文件,则每个用了const对象的文件都必须能访问到它的初始值才行。则:必须在每个用到变量的文件中都有它的定义,所以为支持这一用法,同时避免对同一对象的重复定义,默认情况下,const对象被设定为仅在文件内有效。当多个文件中出现同名的const变量时,其实等同于在不同文件中分别定义了独立的变量。
当然,另一种将const对象在文件中共享的方法是在某个文件中加上extern关键词定义它,然后在其他文件中也用extern关键词声明此对象:
// file_1 文件一:定义并初始化
extern const int bufSize =100;
//file_2 文件二:声明
extern const int bufSize;
2.3 const的引用-核心可以sonst引用非const型,反之不行(可以通过非const改变const型)
2.3.1 概念
可以把引用绑定到const对象上,就像绑定到其他对象上一样,我们称之为对常量的引用: 对常量的引用不能被用作修改它所绑定的对象:
const int ci=100;
const int &r_ci=ci; //正确 引用r_ci及其绑定的对象都是常量
r_ci =10; //错误 r_ci是对常量的引用(别名) 不能修改常量的值
int &r_ci1 = ci; //错误:试图用一个非常量引用绑定一个常量对象
这里要注意常量引用的定义,假设上面第四条语句正确,则可以通过r_ci1来改变它所引用对象的值(ci的值),这明显不对。
2.3.2 初始化常量引用
在初始化常量引用时允许用任意表达式作为其初始值,只要该表达式的结果能转换成引用的类型即可。尤其,允许为一个常量引用绑定非常量的对象、字面值,甚至是一个表达式:
int i =4;
const int &r1 = i; //允许将const int &绑定到普通int对象上
const int &r2 = 42; //r2 是一个常量引用(绑定在字面值上)
const int &r3 = r1*2; //r3 是一个常量引用(绑定在表达式上)
int &r4 = r1*2; //错误:r4是一个普通的非常引用
且,i改变时,r1也会更改(2.3.1中同理也可以通过初始化的变量来更改const常量):
int i = 4;
const int& r1 = i; //允许将const int &绑定到普通int对象上
i++;
std::cout <<"常量引用:"<<r1 << std::endl;
输出:
常量引用:5
i 是普通变量,可以任意改变其值,r1是对i的常量引用,不可以通过r1改变i的值。相当于r1在定义时自己给自己加了约束,这不关它本身所绑定的普通对象i什么事。这点需要理解。
小结:可以使用非常量初始化一个底层const对象(指针所指对象是常量),但是反过来不行。
2.4 指针和const-和引用同理
与引用一样,也可以令指针指向常量或非常量。类似于常量引用,指向常量的指针不能用于改变其所指对象的值。要想存放常量对象的地址,只能使用指向常量的指针。
const double pi =3.14;
double *ptr = &pi ; //错误 ptr是一个普通指针
const double *cptr = π //正确 cptr是一个指向double型的常量指针
*cptr = 42; //错误 不能给*cptr赋值
与常量引用类似,允许指向常量的指针指向一个非常量。所谓指向常量的指针仅仅要求不能通过该指针改变对象的值,而没有规定该对象的值不能通过其他途径改变。
const int k = 3;
const int* cptr = &k ;//正确 cptr是一个指向double型的常量指针
int dval = 3;
cptr = &dval; //正确 但是不能通过cptr改变dval的值
注意const作用于指针时,有三种情况:
int ddd = 10;
const int m = 100;
int* const r1 = &ddd;//正确,指针包含的地址不能更改
const int* r2 = &m;//正确,指针指向的内存地址中的值不能通过r2更改
const int* r2 = &ddd;//正确,指针指向的内容可以随着ddd更改而更改
const int* const r3 = &m;//正确,两者都不能更改
const int* const r3 = &ddd;//正确,可以随着ddd更改而更改
ddd++;
std::cout << *r3 << std::endl;
2.5 const用于函数
可以使用非常量初始化一个底层const对象,但是反过来不行;同时一个普通的引用必须用同类型的对象初始化。
把函数不会改变的形参定义为普通的引用会引起一种常见的错误,这么做带给函数的调用者一种误导,即函数可以修改它的实参的值。此外,使用引用而非常量引用也会极大的限制函数所能接受的实参类型:不能把const对象、字面值或者需要普通类型转换的对象传递给普通的引用形参。
所以:对于不改变形参内容的函数,尽量使用常量引用类型。
2.5.1 const修饰形参
函数定义和声明时放在形参前,表示该参数传入后在函数内部不允许更改
void func(const int a){
a = 10; // 错误,传入参数a不允许修改
)
2.5.2 修饰函数返回值
若函数返回值为一个对象的引用,则在没有const修饰情况下,可以通过返回的引用修引用对象的值,造成风险。 尤其是在面向对象编程中,很容易误改实例的属性值。
#include "iostream"
int a = 10;
int& get_a(){
return a;
}
const get_a_2(){
return a;
}
int main(){
std::cout << "hello world" << std::endl;
get_a() = 20;
// 输出 20
std::cout << get_a() << std::endl;
get_a_2() = 30; // 报错, 表达式必须为一个可修改的左值,通过const修饰返回值,使其不可通过函数返回值修改
return 0;
}
2.5.3 cosnt修饰函数体
这种情况下const位置是函数体前,函数参数列表后,表示该成员
函数不修改成员变量。
class A{
private:
int a_;
public:
void func() const {
int s = a_; // 正常读取
s = a_ * a_; // 临时变量可以修改
a_ = s; // 报错,该函数体不允许修改成员变量
}
};
使用mutable关键字可以使得成员变量在const函数体中可以被修改:
class lstring {
private:
char* c_str;
public:
int change()const {
k++;//正确
}
补充知识点-const_cast:
参考博客:
const 总结(绝对全面)_const int &r4=r*2-CSDN博客
3.构造函数
3.1 类的6个默认成员函数
如果一个类中什么成员都没有,简称为空类。
空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成以下6个默认成员函数。默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数。
class date
{};
3.2 构造函数概念
用于类初始化的函数
构造函数,其实指的是对象创造构建的时候调用这个函数!所以才叫构造函数啊
构造函数特征:
函数名与类名相同。
无返回值。//甚至连void类型都不是!
对象实例化时编译器自动调用对应的构造函数。
构造函数可以重载。//函数名相同参数不同
可以提供多个构造函数多种初始化方式!
如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦 用户显式定义编译器将不再生成。(体现了默认特性!)
自定义构造:
class date
{
private:
int _year;
int _month;
int _day;
public:
date(int year, int month, int day)//构造函数没有返回值!类型名就是函数名!
{
cout << "Iint success!" << endl;
_year = year;
_month = month;
_day = day;
}
date()//构造函数支持函数重载!
{
cout << "Iint success!" << endl;
_year = 1;
_month = 1;
_day = 1;
}
};
成员初始化列表构造 :
#include<iostream>
using namespace std;
class Test
{
public:
//初始化列表的方式
Test():m_ia(0),m_ib(1)
{
}
void Print()
{
cout << m_ia << " " << m_ib << endl;
}
~Test()
{
}
private:
int m_ia;
int m_ib;
};
int main()
{
Test clsT2;
clsT2.Print();
return 0;
}
结果:
0 1
3.3 默认构造函数
3.3.1 默认构造规则
class date
{
public:
void print()
{
cout << _year << " " << _month << " " << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
date A;
A.print();
return 0;
}
输出:(随机值)
-858993460 -858993460 -858993460
为什么默认构造函数不起作用?成员变量仍然是随机值??
原因:c++把类型分为内置类型(基本类型)和自定义类型
内置类型:
int/char/double … 和任意类型的指针!(什么叫任意类型的指针?我们自定义类型的指针也算在内置类型里面例如Date*这种!
自定义类型 :
class/struct 定义的类型 例如 Stcak/Queun/Person/date
编译器会生成的默认构造函数会去调用自定义类型的默认成员函数用于初始化自定义类型!而c++这个默认成员函数对内置成员不会进行处理!但是会对自定义类型会调用它的默认构造!换句话说就是,只对类或结构体这种本身自带构造函数的成员调用他们的构造函数进行处理,其他成员没法管
class A
{
public:
A()
{
_a = 100;
std::cout << "A()构造函数成功!" << std::endl;
}
int _a;
};
class Date
{
public:
void print()
{
std::cout << _year << "-" << _month << "-" << _day << std::endl;
}
int _year;
int _month;
int _day;
//什么都没有做,只是增加了它的一个自定义类型!
A _aa;
};
int main(){
Date A;
A.print();
std::cout << A._aa._a<< std::endl;
return 0;
}
输出:
A()构造函数成功!
-858993460--858993460--858993460
100
可以使用default来定义默认构造函数
3.3.2 缺省构造
除了系统缺省的构造函数外,只要构造函数无参或者参数有缺省值, 编译器会认为它就是缺省构造函数。缺省的构造函数同时只能有1个。
无参的构造函数和全缺省的构造函数都称为默认构造函数,但是这两个有且只能有一个!因为同时存在有会二义性。无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数。
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
Date()
{
_year = 1;
_month = 1;
_day = 1;
}
private:
int _year = 0;
int _month = 0;
int _day = 0;
};
int main()
{
Date a;//错误,构造调用不明确
return 0;
}
带有默认参数的构造函数 Date(int year = 1, int month = 1, int day = 1)
允许以 0 个、1 个、2 个或 3 个参数的形式来调用。当没有提供任何参数时,它会使用默认值 1
。
代码报错如下:
下面这种构造就叫做缺省,缺了一部分变量需要创建的时候给出
class Date
{
public:
Date(int year, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year = 0;
int _month = 0;
int _day = 0;
};
int main()
{
Date a;
return 0;
}
3.4 拷贝构造-传入对象引用
3.4.1 拷贝构造概念
拷贝构造函数是一个已经存在的变量拷贝给一个即将创建的变量,这里调用的是拷贝构造函数而不是赋值运算符重载函数:
A a1;
A a2 = a1;
思考下面代码:
class t {
//正确
t(t& s) {
}
//错误
t(t s) {
}
};
先说错误的,第二个构造函数 t(t s)
试图接受一个同类型对象的值作为参数。这个定义在实际上会引发问题,因为每次你尝试使用这个构造函数时,它都会尝试使用自己来创建一个新的 t
类型的实例,从而导致无限递归调用。编译器不允许这样的构造函数,因为它无法正确工作。
第一个构造函数与之的区别就在于,不需要进行创建,这个构造函数接受的是一个对已存在的对象 s
的引用。
这引入了我们的拷贝构造的概念,其实第一个构造函数就是一个拷贝构造的非标准形式,标准形式如下:
class t {
public:
t(const t& s) {
// 拷贝构造函数的实现
}
};
演示代码:
class Date {
public:
Date(int year = 2022, int month = 8, int day = 20)
:_year(year)
, _month(month)
, _day(day)
{
cout << "Date(int year = 2022, int month = 8, int day = 20)" << endl;
}
Date(const Date& d)
:_year(d._year)
, _month(d._month)
, _day(d._day)
{
cout << "Date(const Date& d)" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main() {
Date d1;
Date d2(d1);
Date d3 = d1;
return 0;
}
结果:
Date(int year = 2022, int month = 8, int day = 20)
Date(const Date& d)
Date(const Date& d)
3.4.2 默认拷贝构造
并没有人为添加拷贝构造函数
class Date {
public:
Date(int year = 2022, int month = 8, int day = 20)
:_year(year)
, _month(month)
, _day(day)
{
cout << "Date(int year = 2022, int month = 8, int day = 20)" << endl;
}
void Print() {
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main() {
Date d1;
Date d2(d1);
d2.Print();
return 0;
}
结果:
Date(int year = 2022, int month = 8, int day = 20)
2022-8-20
3.4.2 默认拷贝的缺点-浅拷贝
分析下面这段代码:
class Stack {
public:
Stack(int k = 4)
:_arr(new int[k])
,_size(0)
,_capacity(k)
{
cout << "Stack(int k = 4)" << endl;
}
~Stack() {
cout << "~Stack()" << endl;
delete[] _arr;
_arr = nullptr;
_size = _capacity = 0;
}
private:
int* _arr;
int _size;
int _capacity;
};
int main() {
Stack st1;
Stack st2(st1);
return 0;
}
运行后报错:
分析:对于指针成员,默认拷贝直接复制了一份地址,这导致两个对象的指针成员指向了同一个地址,那么当调用析构函数清理申请的内存时,会对同一个地址进行两次清理,这导致了报错
这就叫做浅拷贝
我们想要的拷贝效果是拷贝构造的对象指向一块与源对象不同的空间,但空间大小和存放的内容都相同。
这其实就是所谓的深拷贝。
因此我们要自己定义拷贝构造函数进行深拷贝,如下:
class Stack {
public:
Stack(int k = 4)
:_arr(new int[k])
,_size(0)
,_capacity(k)
{}
Stack(const Stack& st)
:_arr(new int[st.capacity()])
,_capacity(st.capacity())
,_size(st.size())
{
memcpy(_arr, st._arr, st.size() * sizeof(int));
}
~Stack() {
delete[] _arr;
_arr = nullptr;
_size = _capacity = 0;
}
int size() const {
return this->_size;
}
int capacity() const {
return this->_capacity;
}
private:
int* _arr;
int _size;
int _capacity;
};
int main() {
Stack st1;
Stack st2(st1);
return 0;
}
报错解决,指针变量指向了不同地址
编译器自动生成的默认拷贝构造函数只能帮我们完成浅拷贝,所以当没有深拷贝需求时是可以不用我们自己写拷贝构造函数的。但当成员变量有指针且指向一块空间,需要拷贝构造的对象需要指向另外一块空间时,就需要我们自己写拷贝构造完成深拷贝了。
3.4.3 深拷贝的拓展-编译器可能的优化 (个人感觉很重要)
3.4.3.1 自定义类型做函数返回值时传值返回
下述代码涉及到使用类A作为函数返回值,这里我们要先记住一点
传值返回返回的并不是函数栈帧里的对象,而是临时拷贝出了一个对象,而且这个临时拷贝的对象是常量性质的
class A {
public:
A(int a = 0) {
cout << "A()" << endl;
}
A(const A& aa) {
cout << "A(A& aa)" << endl;
}
private:
int _a;
};
A f1() {
static A aa;
return aa;
}
int main() {
f1();
return 0;
}
也即是说,在执行:
return aa;
时,会将aa通过拷贝构造函数传给返回值A,这时候会调用拷贝构造函数,形成一个临时的常量对象
输出结果:
A()
A(A& aa)
修改代码如下:
int main() {
A a1 = f1();
return 0;
}
按理说这里会发生三次构造,两次拷贝构造,但是运行结果:
A()
A(A& aa)
只调用了一次拷贝构造
这里也就是所谓的编译器对于同一个表达式中连续拷贝构造的优化,把两个拷贝构造优化成了一个。
但这个优化并不是所有编译器都支持的,一般新版的编译器(比如VS2019…)会做这样的优化,因为这种优化并不是C++标准所规定的,所以做不做就取决于开发者了。
此外,我们可以验证产生的临时拷贝变量是const型,有如下代码:
class A {
public:
A(A& aa) {
cout << "A(A& aa)" << endl;
}
private:
int _a;
};
A f1() {
static A aa;
return aa;
}
int main() {
A a1 = f1();
return 0;
}
输出报错:
还有一个问题,对于函数返回时临时拷贝的那个常量性质的对象或变量是存在哪呢?
这里直接给结论,如果拷贝对象比较小,只有 4/8byte 时,可能依靠寄存器临时存放;如果比较大时,可能就会在上一个函数栈帧中预留出一块空间留着拷贝,比如上面可能就会在 main 函数中预留出一块存放临时拷贝对象的空间。
3.4.3.2 自定义类型做函数参数时传值调用
有如下代码:
class A {
public:
A(A& aa) {
cout << "A(A& aa)" << endl;
}
private:
int _a;
};
void f2(A aa)
{}
int main() {
A a1;
f2(a1);
return 0;
}
首先分析一下,先是调用构造函数创建了一个对象 a1 ,然后函数传参时由于是传值调用,所以传过去的是实参的一份拷贝,所以这里会临时拷贝出来一个对象,所以还会调用一次拷贝构造函数。
运行结果:
A()
A(A& aa)
匿名对象:
现在引入一种新的对象,因为后面需要用。
当我们创建一个对象变量时一般是这样 A aa;
但还有一种方式就是 A()
,
我们称之为匿名对象,它的生命周期只在这一行,可以看下面的代码验证一下:
class A {
public:
A(const A& aa) {
cout << "A(A& aa)" << endl;
}
A() {
cout << "A()" << endl;
}
~A() {
cout << "~A()"<<endl;
}
private:
int _a;
};
void f2(A aa)
{}
int main() {
A();
return 0;
}
输出结果:
A()
~A()
那么我们写出如下代码:
void f2(A aa)
{}
int main() {
f2(A());
return 0;
}
按理说,应该会将匿名对象拷贝给f2的形参,然后调用一次拷贝构造,但是输出结果为:
A()
~A()
并没有发生更改,没有调用拷贝构造 ,这也是编译器进行优化的一点。
关于编译器优化的总结:
1:在同一个表达式中,如果产生了临时对象,再用临时对象去拷贝构造一个对象,那么编译器可能会优化,两个对象合二为一,直接构造出一个对象。
2:优化一定发生在一个表达式中的连续步骤,连续步骤可以是连续拷贝构造,也可以是一次构造+一次拷贝构造。而且优化掉的都是临时对象,或者是匿名对象。
注意,上面说的是可能优化,只有部分比较新的编译器支持这种操作。
当然,无论再怎么优化,传值调用或传值返回总是避免不了临时拷贝,所以当能传引用的时候还是要传引用,尽量避免传值。
3.5 委托构造
#include <iostream>
class Data
{
public:
int num1;
int num2;
Data() //目标构造函数
{
num1 = 100;
}
Data(int num) : Data() //委托构造函数
{ // 委托 Data() 构造函数
num2 = num;
}
};
void function()
{
Data data(99); //首先调用Data() 先给num1复制,然后走到Data(int num) : Data() ,给num2赋值
std::cout <<data.num1 << std::endl;
std::cout <<data.num2 << std::endl;
}
运行结果:(注意运行顺序)
100
99
3.6 explicit禁止类型转换
当我们将isbig中传入整数而不是role对象,但也能得到结果,为什么?
先将520传入isbig中,但此时发生了强制类型转换,相当于传入role(520)一个初始化为520的对象
参考博客: