C++ 面向对象
1. C++语言基础
1.1 函数
C++新增:多态
函数重载( overload )
函数重写(覆写,overrride)
编译器会根据实参的类型来⾃动确定调⽤哪个重载函数
C++新增:内联函数
修饰关键字:inline
作用:编译时直接将函数替换为一堆代码,减少函数调用带来的开销。
比 #define 安全
成员函数默认内联,即使不写 inline;外部函数必须加 inline 才能内联。
内存分配管理
每一个函数在栈空间上都有一段栈帧,保存这当前函数所需的变量等。当函数出栈时,这些成员也随之销毁。
1.2 const 修饰符
const 修饰的对象、变量,在运行过程中不能修改其值。
注意:常量必须在声明时被初始化 const int a; // 错误!
关于 const 可修改的练习:
例 1
编译器在做隐式转换的时候不会添加或删除 const 修饰,如果类型不匹配会报语法错误。
正确做法:使用强制类型转换,消除语法问题
编译器不会自作主张修改 const 修饰,因为可能有潜在风险,但是如果人为地强制转化,说明是程序员告诉编译器:“就这么干,我说的!“。编译器就会很放心地让程序员来承担责任,不会报错 /doge
注意:这里 a 为局部变量,即使被 const 修饰,也保存在栈上(假设没有常量折叠)。如果换做全局变量,被 const 修饰后存储在常量区中,为只读属性,没有办法修改的。
例 2:指针变量也是变量,所以可用 const 修饰
常量指针
指向常量的指针
这里可以这样子去记忆:const 负责在他左边的东西,如果左边是 char*,说明地址是常量,如果左边是 char,说明字符是常量。
1.3 动态内存分配
常规的静态分配内存:int a[10] = {0};
数组 a 在编译的时候就被分配了固定大小的内存。
而使用动态定义变量更灵活
注意:最后记得 delete
1.4 作用域运算符::
使用类的静态成员
解除被隐藏全局函数或变量
2. 类和对象
注意点:
(C++11 之前)在类体中不允许对数据成员进行初始化
类中的成员不能是自身类的对象
否则会导致无限递归,最终栈溢出,因此会报编译错误。
但是可以放自身类的指针或引用。
若类 A 中含有成员类 B,则类 B 需要提前声明
数据成员不能用 auto、register、extern 修饰;成员函数不能用 extern 修饰
2.1 拷贝构造函数
注意点:
形参必须是对象的引用,&不能省略,否则会向上文所述的无限递归。
要特别注意浅拷贝的问题
举个例子
当调用拷贝构造函数的时候,编译器会无脑复制一份数据到新对象,此时两个对象中 name 指向同一个地址,当两个类调用析构函数时,name 会被 delete 两次,第二次会出错。
这种情况必须自己写拷贝构造函数。
对象作为参数传递时,用引用或者指针好一点,可以避免一次拷贝构造,减少开销
2.2 静态成员
作用:为了解决同类对象间的数据共享问题,实际就是共享变量
静态成员在整个内存中只有一份,位于静态区。
在编译器遇到初始化语句时,就为其分配空间。
两种初始化方法:
在类内初始化:必须加 static 修饰符
在类内声明,要加 static;在类外初始化,不加 static
静态成员函数只能访问静态成员,因为非静态成员有对象才存在;相反,非静态成员函数可任意访问静态、非静态成员。在⼀般的成员函数中都隐含有⼀个 this 指针,⽤来指向对象自身,而在静态成员函数中没有 this 指针,这是无法访问非静态成员的技术原因。但是若向静态函数中传入对象,则可以访问对象中的非静态成员。
2.3 友元
允许⼀个类授权另⼀个类的对象(友元类)、某个成员函数(友元成员)或外部函数(友元函数)访问其对象的非公有成员,而不允许整个程序访问。是类的封装性与执行效率的折中。
Java 保留了静态成员,舍弃了友元机制。
2.4 对象嵌套
比如类 A 中有成员变量类 B,则在实例化 A 的时候,先调用 B 的构造函数,再调用 A 的构造函数(先里后外)
另外,在 A 的构造函数中,推荐使用构造函数初始化列表,来初始化 B。
3. 派生类
重定义(隐藏)redefining:派生类中重新定义基类成员,包括数据成员、成员函数,可参考重写(覆盖)overload:是重定义,但使用虚函数,实现了多态在 java 中两者无区别
派生类重新定义了 print,则派生类中有两个 print,但隐藏了继承来的 print——用派生类对象 a 调用 print,即 a.print(),调用的是重新定义的,除非 a.person::print();或者用基类指针指向 a,用指针调用 print。如果是重写,则根据虚表直接找到重写的 print。
3.1 派生方式
这里比较绕,分三步说明
1)权限变化
公有派生中,权限不变。公有 -> 公有,私有 -> 私有,保护 -> 保护
私有派生中,all -> 私有
2)类内访问权限
基类的公有和保护成员都能够被派生类的成员访问
基类的私有成员不能被派生类成员直接访问,但可以通过基类的公有成员函数间接访问,比如基类的 get、set
3)类外访问权限
指的是实例化派生类后,能否通过 a.print()的方式访问类内成员
从类外看,派生类和基类是融为一体的,就当作一个普通的类操作,所以当然是私有不能访问,公有可以访问,而具体是私有还是公有见 1)中分析
综上所述,派生方式只改变类外访问权限
3.2 派生类构造、析构函数
派生类不继承构造函数,因为不符合构造方法的命名规则。但调用派生类构造函数时,编译器也要调用基类构造函数,这不是真正意义上的继承。析构函数也是类似。但基类析构函数可以在子类中用基类名显式调用
注意:C++基类的析构函数一般定义为虚函数,防止内存泄漏
执行顺序
创建派生类对象:(1)先执行基类的构造函数(2)执行派生类的构造函数
销毁派生类对象时,相反
如何传参
如果基类的构造函数没有参数,或者没有显式地定义构造函数的时候,派生类构造函数不必考虑向基类构造函数传递参数,甚至可以不定义构造函数
当基类中带有参数的构造函数的时候,派生类必须定义构造函数,且使用初始化列表传递参数
3.3 重新定义
3.4 多重继承
派生类有多个基类,派生方式可以各不相同
构造顺序
但是多继承可能有模糊性(菱形继承的二义性) 。此外,从不同类继承同名成员,也会出现歧义性
3.5 虚拟继承
编译器保证从虚基类中继承的成员只有一个拷贝,避免模糊性
一些语法规定
也就是说,虚继承可以作用于一分为二的继承,避免基类成员也一分为二。而在合二为一的情况下,虚继承没有用
在 D 的构造函数中,需要用参数化列表调用 ABC 的构造函数,即在直接继承 BC 基础上,加一个虚基类(祖先)的构造函数。因此,BC 在继承 A 的时候,必须是公有继承,不然 D 无法调用 A 的构造函数,因此虚继承必须是公有继承。
构造顺序
上图调用顺序为 ABCD,虚基类(祖先)第一个调用,并且只会调用一次,从而保证其成员只会被初始化一次。当然,如果虚基类还有父类,那应该按正常流程先调用父类,再子类。然后,虚继承的类先构造,非虚继承的类后构造
即:
虚基类或非虚基类的祖先的构造函数
任何虚继承基类的构造函数按照它们被继承的顺序构造
任何非虚继承基类的构造函数按照它们被继承的顺序构造
对象成员的构造函数
派生类自己的构造函数
析构顺序相反
构造顺序 例 1
则构造顺序为:CEABDMF
构造顺序 例 2
结果输出:13
补充
大部分编译器都提供了查看 C++代码中类内存分布的工具,比如 vs。参考
虚基类的直接子类(Veichle_Road、Veichle_Water)存放指向虚基类表的指针,以及自己新定义成员。
后续子类(Amphicar)继承了两个父类的成员,包括虚基类表指针,并存放了虚基类成员(weight)的一个拷贝。
4. 多态性(polymorphism)
4.1 编译多态和运行多态
重载(Overload):不同函数使用同一个函数名,即同样的接口实现不同的操作
重写(Override):对虚函数的重新定义,即有继承关系的 不同类对象 对同一消息作出不同响应
重新定义(Redefining):在派生类中定义一个新的
运算符重载
模板
联编
静态联编:在编译阶段进行联编,速度快、效率高,但不灵活
动态联编:在程序运行阶段进行联编,即直到程序运行时在确定调用哪个函数
在 C++中:函数重载(含运算符重载、重新定义、模版)是通过静态联编实现的虚函数多态性(重写)是通过动态联编来实现的,使用虚函数表在运行时实现
举个例子
用来体会一下静态联编
注意 fun 函数的形参为 Point&,即基类引用,因此会窄化,fun 始终调用基类的 Area()。若参数改为:Rectangle &s,也同样只能调用 Rectangle 中的 Area()
4.2 函数重载
不同函数使用同一个函数名,由编译器来选择具体由哪个函数来执行
4.3 运算符重载
一般用于操作类,比如两个自定义类的 “+” 运算
显然,重载的运算符函数必须要能访问类中元素,因此运算符重载 要么是成员函数,要么是外部友元函数。如果用成员函数,那形参只有一个,如果是友元函数,形参有两个。特别的,( )
、[ ]
、->
、=
,必须用成员函数实现运算符重载,<<(输出)
必须用友元函数实现运算符重载。
有一些运算符是不能重载的,共 5 个:.
.*
::
?:(三目运算符)
sizeof
对于[]、<<,都是两目,但区别是第一个参数是本类对象或其它类对象,所以采用不同实现方法,原因:
如果用成员函数实现,类的 this 指针会被绑定到运算符的左侧对象,防止出现异常的写法。这是与 this 的背后实现有关:比如
a.f()
,编译器先用 this 指向对象 a,然后调用函数 f,函数中访问数据成员时,编译器通过 this 到对象中找。对于 a[i]的重载,[]是函数,所以仍遵循上述原理。如果是友元,则没有 this 指针,双目运算符左右对象无限制,比如第一形参是整型(下标),第二形参是对象。可能导致:3[a] 。可参考
每个非静态成员函数都有一个隐式参数(编译器编译器编译时自动添加的),这个参数就是成员函数中 this 指针的来源,并且是第一个参数。比如:
void A::f() {}
,实际上是:void A::f(A *const this) {}
编译器在编译时会自动传递参数,使得成员函数可以访问对象的数据成员。因为数据成员分配在对象中,而成员函数本类对象共用,分配在别的地方。<<
运算符重载函数要求第一个参数必须是std::ostream &
,所以必须是非成员函数。但不一定非得是友元,除非访问私有成员
关于编译优化
在值返回的时候会有所区别
在上述 12 两种返回局部对象的情况时,按理来说,应该是在 return 时创建一个匿名对象,再调用拷贝构造函数将其赋值给调用运算符的左值。而编译器会对此优化,不需要拷贝构造。
因此,上述四种情况分别会
一次普通构造
一次普通构造
一次拷贝构造
两次拷贝构造 (形参到实参也要一次)
增量运算符的重载
前缀与后缀在调用时其实形式一样,为了在调用时能够区分前、后缀,c++后来(开始时前后缀无区分)规定:后缀函数加一个没有实际意义的 int 参数,这样编译器在判断出前后缀时,就知道调用哪一个了。在调用后缀函数时,自动传给参数 int 的值是 0,所以该参数的变量名都可以省略
前缀按语义可连接,后缀不允许。此外后缀要先返回后加加,故返回对象。非要返回引用当然也可以,但会造成连续加加,不符合语义。
类型转换运算符重载
通过构造函数进行(简单类型 -> 类对象)
通过类型转换运算符重载进行(类对象-> 简单类型)
如operator int();
注意:没有返回类型、参数
没有返回类型,因为强制转换为 int 说明 int 就是返回类型,再写一遍多此一举
没有参数,因为强制转换为单目运算符,加之用成员函数实现
赋值运算符重载
如果不重载,编译器会默认一个,其默认行为是复制对象的数据成员,就和默认拷贝构造函数一样,但这会导致浅拷贝
赋值运算符重载不能继承。实际是可继承,但因为派生类没定义赋值重载,则编译器默认一个,隐藏了继承来的
4.4 虚函数
动态联编实现的运行时多态
通常用基类指针(或引用)指向公有派生类对象。必须是公有派生,如果私有派生的话,从基类继承的成员都会私有,再拿基类指针指,首先会窄化,结果什么都访问不到。因此如果用基类指针指向私有、保护派生类对象,会直接在编译的时候报错
虚函数定义
在基类中被关键字 virtua 修饰,派生类中 virtual 可省略
这里的 virtual 和前文的虚继承一点关系都没有,同名纯属巧合
实现原理
一个类有虚函数,则会定义虚函数表,本类只有一个。本类对象中都有虚函数表指针。虚函数表是函数指针数组,指向每个虚函数。
派生类继承了虚函数,则也继承了虚函数表(复制了虚函数表)。如果重写虚函数,则修改虚表中函数指针,否则仍指向基类虚函数。
编译阶段创建对象及类的虚表。运行时用基类指针访问到派生类对象,找到其虚表指针,查表找到虚函数执行。所以编译时只绑定了指针(包括对象中的虚函数表指针、虚函数表中的函数指针),运行时根据指针才能绑定虚函数。 所以无论基类指针指向基类对象还是派生类对象,能做到不同对象对相同消息做出不同响应。 当基类指针指向派生类对象时,编译器以为是基类对象,但因为是虚函数,所以编译器建立虚函数表并在对象中存放虚函数指针,导致运行时调用重写的虚函数。
对于p->vfunc1()
,因为 p 是基类指针,则编译器已经将所指派生类对象隐式转为基类对象,所以无法判定调用哪个函数
注意事项
不能把静态成员函数声明为虚函数,因为静态成员函数不属于某个对象,而虚函数是通过对象指针或引用来调用的
构造函数不能是虚函数,因为执行构造函数时,对象还没有实例化。但析构函数可以是虚函数,且有时是必须的
针对第二点析构的,打个比方A *p = new B;
,new 了一个派生类对象,但是用的基类指针,也就是说最后调用了基类的析构,会导致派生类中新定义的成员无法销毁。因此析构函数也必须重写。
每个类的析构函数负责销毁自己新定义的成员,派生类只需要销毁掉新定义的即可,至于继承来的成员,会调用基类的析构销毁。整个流程就是:基类指针调用析构(虚函数),派生类析构,由于继承关系,基类析构(这里和虚函数没关系,纯属是“继承”的特性)
析构的虚函数有其特殊性,比如在基类和派生类中函数名不同,但就运行时多态而言,与其他虚函数性质一样。
详细研究虚析构函数在虚函数表情况,参见:c++虚析构函数在虚函数表中吗?
纯虚函数和抽象类
纯虚函数是一个在基类中说明的虚函数,它在该基类中仅定义了函数首部,必须在派生类中重写
定义:virtual 返回类型 函数名(参数表)=0;
有纯虚函数的类称为抽象类,其无法被实例化,不能做函数参数、返回类型或转换类型,但抽象类指针、引用可以
5. 模板
分为函数模板和类模板,是实现代码重用机制的一种工具
5.1 函数模板与模板函数
使用函数模板时需要实例化,可以显式、隐式实例化
函数模板中没有隐式的类型转换(形参到实参),但是重载后的模板函数(也就成了普通函数)是可以隐式转换的
解决方法:
采用强制类型转换,如将上述
max(i,c);
改写为max(i,int(c));
用非模板函数去重载函数模板
这样就会调用这个普通的重载函数
模板函数、函数重载的匹配次序
优先级为:
参数完全匹配的(重载或实例化)函数
(隐式)实例化后能匹配的模板函数
类型转换后能匹配上的重载函数
例如
6. 代码巩固
运算符重载
有一个 String 类,只有一个成员变量
1)重载[]
这里为了理解面向对象的一些点,采用了很奇怪的写法,仅仅保证语法无误
关注点:返回值应该是引用
2)重载+
如果要做String + String
的运算
这是一种返回临时对象的写法
如果要做String + char*
的运算
一种方法是重载
+
运算符,接收一个char*
类型的参数一种方法是使用类型转换
比如把
char*
转为String
,用构造函数就可以实现,运算时编译器会自动隐式转换,从而转为String + String
。需要注意的是,这种方法会把
char*
转为一个临时对象String
,这个临时对象作为+
函数参数传入时,函数形参必须声明为 const。也就是说,运算符重载函数接收一个临时对象时,必须是 const。若把
String
转为char*
,则需要重载char*()
但是这两种方法不能同时使用,即不能同时有
char*
->String
和String
->char*
,否则会有二义性:既可以转为char* + char*
,又可以转为String + String
提供隐式类型转换
整个思路是:A 重载了+
运算符,但是只能和 A 相加,因此这里给 B 提供了一个转化为 A 的类型转换重载,编译器在计算a+b
时,会自动把 b 隐式转换为 a
输出运算符重载
如正文所讲,<<
只能采用友元函数重载,并且返回值得是引用,第一个参数得是引用,第二个参数中如果要接收一个临时对象,必须用 const 修饰
编译器的移动构造优化
这段代码最终输出为:con con copy add con
分析:
A a, b;
两次默认构造A c = a;
标准的拷贝构造A d = a + b;
显然先有一次+
重载,之后编译器会优化,效果等价于A d(a + b);
也就是一次普通构造
怎么区分 =
是拷贝构造还是赋值运算呢?只有在初始化对象的时候,才会使用拷贝构造,如果对象已经被创建了,之后的=
都是赋值
小综合
题目:
有四种几何图形:三角形、矩形、正方形和圆。求四种几何图形的面积之和。
定义一个包含两个虚函数的类
公有派生各类,重写两个函数,这里只展示一个类,其余同理。
这里使用了一个指针数组,来实现多态;如果采用对象数组,则无法实现多态
文章转载自:laobei-uu
评论