关于cpp类的部分点
类
1 | class A{ |
成员
- 成员变量
- 成员函数
- 成员函数在类内声明,可以直接在类内定义,
- 也可以声明完了在类外通过类似于下面的这样来定义
1 | int A::get(){ |
public
,private
,protected
🧠C++的默认成员类型为private类型
- public成员 ,基类、派生类、友元、外部都可以访问
- protected成员,基类、派生类、友元可以访问,
- private成员,基类、友元可以访问,
继承
有public
,private
,protected
这三种方式的继承
🧠C++的默认继承方式为private继承
1 | class B:public A{ |
该图来自:https://www.cnblogs.com/mu-ge/p/14523757.html
从物理结构上来说,子类确实包含了父类的私有成员,但是我们不能通过正常的渠道访问到他们。
我们可以通过内联汇编获取私有成员函数的入口地址,然后就能顺利访问了。
构造函数与析构函数
构造函数
- 在每次创建类的新对象的时候执行
- 构造函数名称和类名完全一样,没有返回类型,也不是void。
- 我们可以用来为一些成员变量设置初值
1 | class A{ |
按理来说构造函数也可以是私有的或是保护的,但是在创建一个对象的时候一定要是调用的公有构造函数,故下面这样会出错
1 | class Box |
- 对于构造函数中的初始化(赋值)操作,我们可以简化为使用初始化列表来进行该字段的初始化操作
1 | class Line{ |
- 有多个字段,仍然可以这么做,不同字段用逗号隔开即可
1 | C::C( double a, double b, double c): X(a), Y(b), Z(c) |
关于创建对象,如果你的构造函数里面没有参数(包括使用默认的构造函数),那就别像A a()
这样写了,因为这会让编译器认为你是在创建一个返回值为A类型的的无参函数a,我不知道是不是所有的都这样,但最好别这样写。
析构函数
- 它会在每次删除所创建的对象时执行
- 析构函数名称与类名相同,只是前面加了个~号作为前缀,没有返回值,也不能带任何参数。
- 析构函数有助于在跳出程序(比如关闭文件、释放内存等)之前释放资源
1 | class Line |
输出:
1 | Object is being created |
🧠:创建出来的对象(也是一个局部变量)是要保存在栈中的,因此由栈的先进后出,便可以知道,先创建的对象后析构;后创建的对象先析构
拷贝构造函数
类会有默认的拷贝构造函数
就类对象而言,相同类型的类对象是通过拷贝构造函数来完成整个复制过程的。
1 |
|
拷贝构造函数是一种特殊的构造函数
函数的名称必须和类名称一致,
它必须的一个参数是本类型的一个引用变量
如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27class CExample {
private:
int a;
public:
//构造函数
CExample(int b)
{ a = b;}
//拷贝构造函数
CExample(const CExample& C)
{
a = C.a;
}
//一般函数
void Show ()
{
cout<<a<<endl;
}
};
int main()
{
CExample A(100);
CExample B = A; // CExample B(A); 也是一样的
B.Show ();
return 0;
}啥时候会用到拷贝构造函数呢?
1、对象以值传递的方式传入某个函数参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14//如定义这个全局函数(即类外函数),传入的是对象
void g_Fun(CExample C)
{
cout<<"test"<<endl;
}
那么
int main()
{
CExample test(1);
//传入对象
g_Fun(test); //这里将一个对象test传入时,就会调用拷贝构造函数
return 0;
}具体来说:
调用g_Fun()时,会产生以下几个重要步骤:
- test对象传入形参时,会先会产生一个临时变量,就叫 C 吧。
- 然后调用拷贝构造函数把test的值给C。 整个这两个步骤有点像:CExample C(test);
- 等g_Fun()执行完后(但是在函数退出之前), 析构掉对象C。
2、对象以值传递的方式从某个函数返回
1 | CExample g_Fun() |
(🍎):
当func()函数执行到return时,会产生以下几个重要步骤:
- 先会产生一个临时变量,就叫temp吧。
- 然后调用拷贝构造函数把a的值给temp。整个这两个步骤有点像:CExample temp(a);
- 在函数执行到最后先析构a局部变量。
- 等g_Fun()执行完退出后再析构掉temp对象。
1 | Line func(Line b){//调用拷贝构造函数1 |
关于参数对象(b),局部对象(a),临时对象(temp)的析构顺序:
参数对象(b),局部对象(a)在函数退出之前析构;
且局部对象先析构,参数对象后析构
而return处产生的临时对象则是在函数调用完成退出之后才调用析构函数的。
3、对象用另一个对象来初始化
1 | CExample A(100); |
友元函数和友元类
友元可以是一个函数,该函数被称为友元函数;友元也可以是一个类,该类被称为友元类,在这种情况下,整个类及其所有成员都是友元。
类的友元函数是定义在类外部,但有权访问类的所有私有(private)成员和保护(protected)成员。尽管友元函数的原型有在类的定义中出现过,但是友元函数并不是成员函数。
1 | class Box |
因为友元函数没有this指针,则参数要有三种情况:
要访问非static成员时,需要对象做参数;
要访问static成员或全局变量时,则不需要对象做参数;
如果做参数的对象是全局对象,则不需要对象做参数.
可以直接调用友元函数,不需要通过对象或指针
内联函数
引入内联函数的目的是为了解决程序中函数调用的效率问题,这么说吧,程序在编译器编译的时候,编译器将程序中出现的内联函数的调用表达式用内联函数的函数体进行替换,而对于其他的函数,都是在运行时候才被替代。这其实就是个空间代价换时间的节省。所以内联函数一般都是1-5行的小函数。在使用内联函数时要留神:
- 1.在内联函数内不允许使用循环语句,switch语句,异常接口声明;
- 2.内联函数的定义必须出现在内联函数第一次调用之前;
- 3.对于类的成员函数,在类内定义则默认为内联函数,但是类成员函数也可以再类外定义,故==成员函数不一定是内联函数==。
- 递归函数(自己调用自己的函数)是不能被用来做内联函数的
1 | inline int Max(int x, int y)//内联函数用inline关键字 |
静态成员变量
我们可以使用 static 关键字来把类成员定义为静态的。当我们声明类的成员为静态时,这意味着无论创建多少个类的对象,静态成员都只有一个副本(或者说在内存中只有一份拷贝),为这个类的所有对象所共享,即一处变处处变。
静态成员在类的所有对象中是共享的。
静态变量必须要经过初始化才能使用,且只能在类外初始化。写为:
1 | class A{ |
我们不能把静态成员的初始化放置在类的定义中,但是可以在类的外部通过使用范围解析运算符 :: 来重新声明静态变量从而对它进行初始化,如下面的实例所示。
静态成员函数
如果把函数成员声明为静态的,就可以把函数与类的任何特定对象独立开来。静态成员函数即使在类对象不存在的情况下也能被调用,静态函数只要使用类名加范围解析运算符 :: 就可以访问。
静态成员函数只能访问静态成员数据、其他静态成员函数和类外部的其他函数。
因为静态成员函数属于整个类,在类实例化对象之前就已经分配空间了,而类的非静态成员必须在类实例化对象后才有内存空间,所以在静态成员函数内使用非静态资源就出错了,就好比没有声明一个变量却提前使用它一样。
静态成员函数有一个类范围,他们不能访问类的 this 指针。您可以使用静态成员函数来判断类的某些对象是否已被创建。
静态成员函数与普通成员函数的区别:
- 静态成员函数没有 this 指针,只能访问静态成员(包括静态成员变量和静态成员函数)。
- 普通成员函数有 this 指针,可以访问类中的任意成员;
1 |
|
继承
一个派生类继承了所有的基类方法,但下列情况除外:
- 基类的构造函数、析构函数和拷贝构造函数。
- 基类的重载运算符。
- 基类的友元函数。
当一个类派生自基类,该基类可以被继承为 public、protected 或 private 几种类型。继承类型是通过上面讲解的访问修饰符 access-specifier 来指定的。
我们几乎不使用 protected 或 private 继承,通常使用 public 继承。当使用不同类型的继承时,遵循以下几个规则:
- 公有继承(public):当一个类派生自公有基类时,基类的公有成员也是派生类的公有成员,基类的保护成员也是派生类的保护成员,基类的私有成员不能直接被派生类访问,但是可以通过调用基类的公有和保护成员来访问。
- 保护继承(protected): 当一个类派生自保护基类时,基类的公有和保护成员将成为派生类的保护成员。
- 私有继承(private):当一个类派生自私有基类时,基类的公有和保护成员将成为派生类的私有成员。
多继承
1 | // 基类 Shape |
虚拟继承
菱形继承:菱形继承是多继承的一种特殊情况。有两个子类继承同一个父类,而又有子类同时继承这两个子类。
通过上面的图可以看出菱形继承有数据冗余和二义性的问题,在Assistant的对象中Person成员会有两份。
以学生老师和课程三个关系为例
1 | class Person |
为了解决菱形的二义性和数据冗余问题就引出了虚拟继承,在上述代码Student和Teacher继承Person时使用虚拟继承,只需要在public前加上virtual即可。
1 | class Person |
多态
多态就是说不同继承关系的类对象去调用同一个函数,可以产生不同的行为(通俗来说父亲和孩子都会说话,父亲.say()得到的是成熟的声音;而孩子.say()得到的奶气的声音。)
多态一般通过子类重写父类的虚函数virtual int F(int a,char b){…..}实现,
重写即就是子类的里这个函数的名称,参数,返回类型都必须和父类里的完全一样。
由此实现多态。
在子类里的F前面也可以把virtual写上,但是不写也行,因为父类中一个函数被声明为虚函数,那么在所有的派生类中它都是虚函数了。
构成多态有两点必要的:
- 调用该函数的必须是指针或者引用;
- 被调用的函数必须是虚函数,且完成了虚函数的重写,即上面说的。
这两个条件必要同时满足
下面分别解释:(父类Parent,子类Child,方法F)
那么为啥调用该函数的必须是指针或者引用呢。
开门见山:为了动态绑定,动态绑定只有当我们通过指针或引用调用虚函数的时候才会发生。
先来看一个例子:
1 |
|
可以看到虽然用了虚函数,但是如果我们直接拿对象去调用这个虚函数BuyTicket,那么调用是有问题的。
解释一下什么是静态和动态类型,如对于F(Parent &obj)(或是*obj)
静态类型你可以理解为obj本身的类型,即父亲类对象的引用,Parent&,它是父亲类型的
而动态类型,则依赖于obj最后传入的到底是什么类型
如传入p,即传入的是父亲类型的对象,父亲类型便是obj此时的动态类型
那调用BuyTicket的就是父类对象,自然用父类的成员函数
而传入c,即传入的是孩子类型的对象,孩子类型便是obj此时的动态类型
那调用BuyTicket的就是子类对象,自然用孩子类的成员函数
动态类型通过指针或引用实现,即这个指针(或引用)最终真正指向的区域是什么类型的对象。而这个指向在我们真正将具体对象传入之前,是无法得知的,即这个参数对象最终和哪个类绑定得真正执行的时候才知道。故这样的操作也称动态绑定。
因为(无论是对非虚函数的调用还是,还是)直接通过对象进行的函数(虚函数或非虚函数)的调用都是在编译时绑定的。而对象的类型没法改变的,我们无法让一个对象的动态类型和静态类型不一致。因此,通过对象进行的函数调用将在编译时就绑定到该对象所属类的那个版本的函数上。用这个例子来看就是:
参数为:Parent obj,即一个父类的对象,(后面解释为什么用父类做参数)
那
在编译时读取到:obj.BuyTicket()这句话便认为我们将要调用的是一个父类成员方法了。
因此最后就产生了上面🍎处的结果。
那么为啥用父类对象做参数呢。
因为只能是派生类给基类赋值,会发生切片操作。基类不能给派生类赋值。
简单理解就是派生类可以强转为基类,但是基类默认不可以转成派生类。
故若用子类做参数,那我们就无法正确将父类对象传进去了。
1 | //例如 |
上面说了用指针或是引用是为了动态绑定。
C++primer ch15.3,也有描述
当且仅当通过指针或者引用调用虚函数时,才会在运行时解析该调用,也只有这种情况下,对象的动态类型才有可能与静态类型不同。
那么为啥要用虚函数呢。
虚函数
即为在函数最前面加上了关键字virtual
的函数,虚函数只在类中使用。
编译器为每个类添加了一个隐藏成员,隐藏成员中保存了一个指向虚函数地址数组的指针,称为虚表指针(vptr),这种数组称为虚函数表(virtual function table,vtbl),故
有虚函数的类就会有一张虚函数表,而每个类的对象则会用一个虚表指针指向该虚函数表。
如果派生类重写了基类的虚函数,那么派生类中将保存的应该是重写的虚函数的地址,而不是基类的虚函数地址。
而如果派生类没有重写而是直接继承了基类的虚函数,那么派生类中虚函数表将保存基类中未被重写的虚函数。
当然如果子类中还定义了新的虚函数,这个虚函数的地址也会被添加到派生类的虚函数表中。
由此我们知道经过重写时候,有两张不一样的表,
于是当我们调用函数的时候会发生语法检查,当我们在运行时将子类对象指针/引用传入之后(即满足多态条件)便会去查找子类虚表中虚函数的对应地址,于是即去调用了子类中重写过的那个虚函数;而如果传入的是父类对象指针/引用,那自然是去父类虚函数表找对应的虚函数地址,即去调用父类中那个虚方法了。
再提一遍,如果我们不使用指针或引用,而是直接用对象parent obj做参数,那在编译阶段,便会根据参数绑定到父类中的那个虚函数上去,即最后调用的必然是父类虚函数了。
总的来说指针/引用和虚函数都是必须的,不用虚函数的话,也无法实现动态绑定,而编译器则采用静态绑定,即根据这个指针本身的类别来判断,那在这里自然最后调用的是父类虚函数了。
那么为啥构造函数不能用虚函数呢。
我们知道虚函数的使用主要起到了动态绑定的效果,即我们只需知道这个接口(即这个虚函数)而无需知道具体的类型,等到这个虚函数运行时再根据具体传入类型去调用具体的虚函数。即虚函数行为是在运行期间确定实际类型的。
而对于构造函数来说,当我们创建一个对象我们必须知道一个对象具体的类型才能去把这个类型的对象给创建出来,那如果构造函数是一个虚函数,我们则需要在调用他的时候根据传入对象类型去传入一个“实际的类型”去调用正确的构造函数,然而此时我们的对象还未构建成功,编译器无法得知对象的实际类型,那自然是无法去调用所谓正确的构造函数(虚函数)的,这就形成了一个悖论。
那么为啥析构函数要用虚函数呢。
当对象的生命周期结束时,我们需要用到析构函数。
虚析构函数是为了解决这样的一个问题:基类的指针指向派生类对象,并用基类的指针删除派生类对象。
对于不用被继承的类来说,析构函数是不是虚函数自然无所谓。因此C++默认的析构函数并不是虚函数,虚函数表以及虚表指针都会耗费更多的空间。
而在类的继承中,
其实如果是派生类的指针指向派生类的对象,那析构自然没什么问题,是不是虚函数都可以去完成正确的析构操作。
然而假如有基类的指针去指向了派生类的对象,那么当我们去执行析构操作的时候,就涉及到了一个“该调用基类析构函数还是派生类析构函数的问题”了.这就回到了我们上面讨论的虚函数在类继承和多态中的作用了。
因此可以知道假如我们的析构函数不用虚函数,那么编译器便实施静态绑定,在删除基类指针的时候只会去调用基类的析构函数,而不调用派生类的析构函数。
而当我们使用了虚析构函数,我们便可以调用到派生类的析构函数,而且,另外还有派生类的析构函数会自动调用基类的析构函数(在下面说为啥)这样一条机制,我们便能够做到把和基类与派生类相关的占用的内存都给清理干净了。
1 | class A |
而如果我们不用虚函数,则会得到
1 | /* |
这里其实还有额外一个问题:为啥默认派生类的析构函数要自动调用基类的析构函数呢?
基类和派生类在析构函数上的关系
不要认为上面是因为先创建了基类指针才导致的最后调用了基类析构,事实上,即使我们只是这样:
1 | int main(){ |
最后的输出结果仍然是:
1 | Delete class BPn |
解答:这是因为事实上,派生类包含了两个部分,基类部分和派生类部分,基类部分和派生类部分,因此当我们需要做清理工作的时候,不仅要清理派生类部分相关的内存,还要做基类部分的清理工作。
且内存的清理工作必须严格要求,谁开辟的谁最后释放,因此我们两部分的析构函数都要调用。
既然看到了析构函数,那么不难联想到:
基类和派生类在构造函数上的关系
事实上,在创建派生类构造函数的时候会先调用基类的构造函数以确保完成所有与基类相关的初始化工作。
如我们在基类A中定义了变量A,在B中定义了b,那我们在创建对象的时候当然要把这个a也给初始化了,并且原则上是优先专门的事专门的人做,因此会调用基类构造方法去初始化a。
此时你可能会想,既然B把变量a给继承了过来,那不是可以在B中构造函数里面对a做初始化吗?确实可以,但是在你这么做之前,编译器还是会先执行A中的构造函数,然后再去执行B中的构造函数,也就是说你以为B是第一个对a做初始化的,但事实上B之后后来者,他只是把A做好的初始化给改了而已。
1 | class A |
好啦,下面看一个关于基类,派生类的构造函数及析构函数执行顺序的完整例子吧。
1 |
|
纯虚函数
所谓纯虚函数就是像这样virtual ReturnType Function()= 0;
声明的函数,即没有具体实现的虚函数(记得写‘=0’)
为什么要纯虚函数呢?
主要是为了更合理、更方便实现多态。试想一个动物基类可以派生出狮子,老虎等派生类,当时生成一个动物类的对象,这显然是不合理的,这时候就用到了纯虚函数:
- 纯虚函数一般在基类中定义,写作
virtual ReturnType Function()= 0;
- 编译器要求,我们必须要在派生类中去重写出这个方法,以实现对态。
- 拥有纯虚函数的类称为抽象类,他不能生成对象。正如我们上面举的动物的例子一样。、
关于抽象类
抽象类
与带有纯虚函数的类
这两个说法是等价的。- 抽象类只能作为基类来使用,试想如果在派生类中也声明了纯虚函数或是继承了基类的纯虚函数而不去重写,那么这个派生类也还是一个抽象类,也无法创造对象实例,那就没意义了。
虚函数和纯虚函数的关系
纯虚函数当然也是虚函数了,只是不去实现而已。
而当子类重写父类的纯虚函数F之后,那子类中的这个F对于孙子类来说自然就变成了普通的虚函数。
然后孙子类也可以重写子类中的虚函数以实现需要的多态。
extern
若一个变量需要在同一个工程中不同文件直接使用或修改,则需要将自变量做extern声明。只需将该变量在其中一个文件中定义,然后在另外一个文件中使用extern声明。(不要在文本类的编辑器如vs code中直接建两个文件夹去试,因为肯定不能直接去访问另一个文件夹中的文件的,去建个工程文件再试。当然把这四个文件写在同一个文件夹下是肯定没什么问题的。)
1 |
|
我们在main.hpp中定义了一个int b,则在main.cpp中引入这个头文件即可直接访问和修改b了
1 |
|
1 |
|
我们在a.cpp中定义了apple
如果我们想让他在main.cpp中被使用
于是用extern将其声明在a.hpp(即a.h)中:
1 |
|
注意⚠️⚠️⚠️,我们并没有再定义一次apple,extern int apple
只是一份声明,就像你对你定义的函数int f(){.....}
的声明int f();
一样。
这份声明的作用是说变量已经在其他源文件中定义,通知编译程序不必再为它开辟存储单元了。就是说这份声明不会再额外占用一份空间了。
那么我们只需在main.cpp中引入a.hpp即可
1 |
|
其实extern int banana
的本意是声明外部变量,啥是外部变量呢?
在函数体外部定义的变量若没有使用static(静态局部变量)类型符都可以被叫做外部变量,这样定义了之后他的作用范围是当前程序的整个范围,这意味着所有函数都可以直接用它。(当然在单个文件中变量加不加static都可以作为当前程序的全局变量,而若是多个文件,则一个文件中的static是不允许在其他文件中申明的;如果非想把一个static变量定义在本cpp之外,那可以定义在一个头文件中,然后引入这个头文件就好啦。)
当某一个函数f去用banana的时候extern int banana
即是说“f,我叫banana,在外部已经定义过了,所以你不用再定义一遍了,可以直接用我。”
所以声明格式 extern 数据类型 外部变量banana
正确的理解应该是:
对于当前函数(或是文件)F来说,banana是一个外部变量,而我用extern去声明,只是要告诉F你想访问的这个变量是个外部变量早就有人定义过了你直接用吧。
而不要理解成是extern关键字“创造”了一个“外部变量”,这样就本末倒置了。
最常见的就是我们在单个cpp文件中所说的定义在主函数外的全局变量,它对于主函数(或是其他任何函数)来说即为外部变量:
1 | //正常我们会这么写 |
其实我们完全可以把这个变量定义在最后边,然后在用到他的函数之前声明即可。声明位置选1234都可以,当然我们不能在5处声明,因为那在用到他的函数之后了:
(这看起来和函数在主函数之后定义,但在主函数之前声明差不多。)
1 | //1 |
外部变量和局部变量是可以同名的,当定义了局部变量之后,下面再用这个变量用的就是局部变量了:
1 |
|
总结:外部变量banana
并不是像int,double那样是一种特定类型的变量,而是因为定义在当前范围之外而被称为外部变量,我们想在当前范围去访问/改变banana,就要在当前范围内用extern int banana
来告诉当前范围banana已经定义好了直接用吧。为了程序的简洁有逻辑,我们往往把extern int banana
这句话写在某个头文件a.h
里,然后我们在当前范围引入这个头文件#include "a.h"
之后就可以放心用啦。
对于多文件,当banana被引入当前文件a.cpp后,他就相当于a.cpp中的全局变量,因此不能在主函数外再定义一个全局变量(外部)banana,不过我们可以在main函数里面再定义一个banana,因为这是再定义局部变量,之前说过,外部变量和局部变量是可以同名的,只是会有覆盖效果罢了。
static
static 主要是为了解决如下问题:
我在函数中定义了一个局部变量count=0用count++来记录当前函数被运行了几次,
当我运行函数,编译器会为它在栈上分配空间,count=1
而当函数运行结束,这部分空间就会被释放掉。
那即使我在整个程序的生命周期内再次使用到这个函数,count又会被初始化为0,运行完了还是count=1,这显然没有达到我想要的效果。
我们的理想变量是一个能够持续程序的整个生命周期,但作用范围又只限制于当前函数的变量。
我们当然可以用全局变量来统计,但是我希望统计变量count只与当前函数有关,不会被其他东西给改掉,全局变量显然超出了这个范围。
static关键字便很好的解决了这个问题。它就是那个“能够持续程序的整个生命周期,但作用范围又只限制于当前函数的变量”。
全局变量
在一个cpp文件中,直接说全局变量一般默认指不加static修饰的全局变量。
所谓全局变量就是指可以作用在当前文件全部范围内的变量。并且全局变量还可以通过extern,作用在整个工程,被所有文件访问/操作。
而加了static的全局变量呢,再单个文件内来看和上面那种全局变量没什么区别,都是可以作用在当前文件所有范围内。
然而若从整个工程(这里指多文件)来看,static全局变量是不能被其他的文件访问的,即使用了extern也不好使,不允许这么干。
const
const即constant的缩写,字面即不变的,也就是我们能用它来定义常量。
字面来理解,我们定义了常量,那么它就无法被改变了,如下所示:
1 |
|
其实这事实上是不准确的,可以说不被改变只是因为C++的处理机制与编译器的优化而已,可以看一下上面的代码,p指针得到了a的地址,然后对a地址内的值进行了重新赋值,那从物理上来说a的值怎么可能不被改变呢?
我们来做一下尝试,将const int a = 3;
改写为volatile const int a = 3;
1 |
|
前面输出结果中a的值没有变,是因为编译器根据const的机制做出了优化,让const向用户反馈的值没有改变,即它真的是“字面上的不变”。
volatile 关键字跟 const 对应相反,是易变的,容易改变的意思。所以不会被编译器优化,编译器也就不会改变对 a 变量的操作(这个操作即通过p指针改掉了a的值)。
所以可以看到我们看到的值与实际地址内的值是不一样的,这难免让人不爽。
所以说const变量不被改变
可以说是一场君子协定,它防不住恶意篡改。
因此平时写代码的时候尽量不要去对const变量去做什么更改赋值操作,这会造成它的表里不一。
事实上如果我们直接尝试做如下操作
1 | int *p = &a; |
编译器会提醒我们:
1 | error: cannot initialize a variable of type 'int *' with an rvalue of type |
而上面我们则是做了强制转换(int *)(&a)
。
将const int *
转换成了int *
解释完了这些,下面我们再说某个const变量不可改的时候,就按字面理解就行了,别再想它内部是不是被改了,反正我们看到的是不变的。
除了上面那样直接修饰一个变量,
下面来具体看const修饰指针有哪些用法:
先说一句,不要去记什么常量指针,指针常量啥的,还容易记错,只要看我下面写的红字就行了,知道const修饰的是啥就没问题了。
形如:
1 | int a=8; |
这是指,指针p指向的地址内的值(数值8)不能被修改,即我们不能去改变*p
的值;
其实简化来看这样写就是说const
是在修饰*p
例子:
1 | const int *p = &a; |
但是我们却可以改变指针的指向,如再写道
1 | const int *p = &a; |
来一个完整的例子:
1 | int main(){ |
由于指针指向可变,因此以下初始化方式都是对的:
1 | 1.错的 |
形如:
1 | int a=8; |
其实简化来看这样写就是说const
是在修饰p
那么就是说我们不能再将指针p指向其他地方。
但是对于p现在指着的地址里面的值我们是可以改的。
由于指针指向不能变且需要指向明确的地址,我们就必须在定义的时候就制定指针指向,
1 | 1.必须这样写 |
记得,以后别轻易把const type *类型的变量赋给别的指针
只有const type *型的变量,才能接收const type *型的变量
下面看具体的例子:
1 | int main(){ |
形如:
1 | int a=5; |
右边的const在修饰p这显而易见,所以p指针不能再指向其他地址;而左边的const则是在修饰一个*const p
,但他本质上还是一个*p
不是吗,所以说*p
不能改,即p指针指向的那块地址里面的数不能改;所以说这玩意儿一通操作完之后,啥都不能改,起到的就是一个const int a=5
的作用。。。
const 作参数
跟上面类似理解即防止被篡改。
1 | void Helloa(const int a) |
1 | void Helloa(const int * a) |
如果我们传递参数是自定义对象,那么如果直接传递的话上面说过还要调用到拷贝构造函数,如果传递的东西比较大比较浪费时间,所以还可以再配合上引用:
1 | class Animal{ |
const 修饰函数的返回值
- 修饰内置类型(int ,double那些)。
那修饰不修饰没啥区别,反正最后得到的都是一个值常量
1 | const int Helloa() |
- 修饰自定义类型
平时我们有时候会对一个类的对象作a=b的操作,但是如果a是返回值,那a便不能做左值了,如:
1 | class A{ |
你可能有疑问,返回值应该是表达式(expression)不是本来就不能作为左值吗?其实不然,我们返回引用/指针类型(在类中)就行。如:
1 | class A{ |
或者
1 | class A{ |
修饰的返回值为指针或者引用
- 指针:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15const int *F(int *p){//返回一个值不可变的指针
*p=10;
return p;
}
int main(){
int a=99;
int *p=&a;
cout<<*F(p)<<endl;//10
int * const q = F(p);//错
int * k = F(p);//错
/*
*/
}
这俩错的原因是:
1.将 const int* 赋给了 int*;
这与
const int a=99;
int p = &a;
是一样的错误
因为此时的&a 也是 const int
原因在于:首先&a是地址,而这块地址中的内容99不可以改变,所以就等效于const int*
2.将 const int* 赋给了 int* const;
所以记得,以后别轻易把const type *类型的变量赋给别的指针
只有const type *型的变量,才能接收const type *型的变量
上面我们说过借助类和对象,我们可以把指针或引用型的返回值用作左值。那么如果是普通的函数呢?
🍎🍎🍎
函数的返回类型决定了函数调用是否是左值。
调用一个返回引用的函数得到左值,调用其他返回类型得到右值。
于是我们可以像使用其他左值那样来使用“返回引用的函数”的调用
当然从常量的知识可以知道,这个返回值肯定不能是const型的,要不肯定不能为他赋值了。
即我们能为返回值为非常量引用的函数的结果赋值。 ——参考自《C++ primer》p202
1 | int &F(int &a){ |
你可以看到我们用了两个引用符号,为啥参数也要用引用呢?
这是因为int &F(int a)
中a作为函数参数保存在栈中,而函数运行一结束,那栈就清空了,这个a也就不在了,
另外局部对象在函数结束后也销毁了,局部指针在函数结束后也释放了,
所以说像函数参数以及在函数中定义的局部对象、局部指针千万别拿来返回,你都不知道会返回出个啥,而编译器也不会报错,最多警告你一下。
所以说不要以为是&a
让你做到左值功能的,这只是为了更靠谱,更安全。
不信你试试把F前面的引用符号&给去掉,编译器就会甩个error给你:error: expression is not assignable
这红色看着可真难受~
const 修饰函数
形如:
1 | int Helloa const{ |
注意哦,这里const释放函数名后面的,而不是放在前面的,放在前面是修饰返回值。
放在后面则表示这个函数是个只读函数,表示在这个函数的函数体内我们不能修改对象的数据成员,也不能调用非const函数。
为啥不能调用非const函数呢?
因为非const函数是可以修改数据成员的,而const规定不能修改数据成员,这不就矛盾了吗?所以在const成员函数内部只能调用cosnt函数。
1 | class A{ |
写了const之后所有的数据成员在这个函数里就都改不了了,那我要是非想改怎么办呢,有啥办法可以让成员变量不受这个只读函数的限制呢?mutable
我们可以使用 mutable 关键字修饰这个成员,mutable 的意思也是易变的,容易改变的意思,被 mutable 关键字修饰的成员可以被修改。
1 | class A{ |
写个小总结:(下面用int指代各种type)
以后别*轻易把const int * 类型的变量赋给别的指针
只有const int *型的变量,才能接收const int *型的变量
const int * 可以接收int *
const int *可以接收int *const
const int *可以接收const int *由于int * const指针的指向不能被改变,故只看在初始化的时候:
只可以接收int *和int * constint *只能接收int *和int * const
面向对象的一些特点
封装
先说一下抽象,所谓抽象即我们提炼出某些事物的共同点,将其概括为一个类别,如有眼睛鼻子嘴巴,会跑动的是动物。
于是我们得到了一个类别Animal,它的属性有眼睛、鼻子、嘴巴,行为/功能有:奔跑。
老虎狮子都是这个动物类别的实例/对象。
当我们将这些事物抽象为了动物类,下面要做的便是封装。
所谓封装即我们将属性和行为等数据作为一个独立的整体,并且尽可能对外部隐藏内部实现细节,而只保留必要的接口对外开放,就是告诉类的使用者/用户你只需要知道可以用这个功能就行了,不需要知道他是怎么实现的,如
1 | class robot{ |
用户买来机器人之后,只需要知道执行Do_Housework()
就能让它做家务就行了,而无需知道这个功能是怎么实现的,因为这是设计者的事儿。
封装的好处就是让系统更安全,不至于随便一个孩子就来把这个机器人给改崩溃了。
继承
我们用基类-派生类或是父类-子类来指代这种关系。
派生类可以继承一些这一类事物所共有的特性,如狮子继承自动物,他当然应该把眼睛鼻子嘴巴移动等东西给继承过来。
多态
多态上面说过了,简单来说就是不同派生执行同一功能可以有不同效果。
如同样是继承自动物,老虎🐯的move()就是四蹄狂奔,而袋鼠🦘的move()则是向前跳跃。
封装和继承都为多态做了一定的准备工作。