参考资料
🍕 C++语言程序设计-郑莉-清华大学本科教材(第3版)
🍵 C++大学教程(第七版)
🚲 [c++中sizeof()的用法介绍](
🚨 本文仅为准备 考研初复试 使用,只包含 C++ 的基础语法,比如继承、多态、重载、模板等,并不包含 STL、异常等内容,但具备完整的知识体系,并不是由网上的面试题随意拼凑而来。
一、基本概念
简述你对“面向过程”和“面向对象”编程思想的认识与思考
面向过程:面向过程的设计思路是自顶向下的,按功能划分称若干个基本模块,形成一个树状结构,各个模块的功能上相对独立。
面向过程的优点主要是:
有效的将一个复杂的程序分解成多个小的、容易处理的子任务,便于维护和开发。
缺点主要是
可重用性差、数据安全性差;
当数据结构改变的时候,所有相关的处理过程都要进行相应的修改;
难以开发图形化界面;把数据和操作数据的过程分离开来。
面向对象:将数据和对数据的操作方法封装在一起,作为一个不可分割的整体——对象。对同类型的对象抽象出它们的共性,形成类。类通过外部接口public,与外部进行交互。
面向的对象的四大特性:抽象、封装、继承、多态。
面向对象的优点主要是:
各个模块之间的关系更见简单,模块之间的独立性、安全性都大大提高;
通过继承和多态,大大提高了程序的可重用性,便于开发和维护
面向对象程序“接口和实现方法分离”有啥优点
接口定义了交互的标准。接口的实现细节对用户是隐藏的。
接口和是实现方法分离使得程序更容易修改,只要接口保持不变,接口实现的改变不会影响用户。提高了代码的可维护性,使得代码变得清晰。
简述你对C++中数据类型和ADT的理解
数据类型是一组性质相同的具有一定范围的值和集合以及定义在这个集合上的一组操作。C++中既有内部类型:int、char、bool、float、double,又有外部数据类型:枚举类型enum、结构类型struct、联合类型union、数组类型int[]、类类型class。
ADT 即抽象数据类型,是基于已有类型而组成的复合数据类型,类就是抽象数据类型的描述形式。
什么是逻辑错误,什么是语法错误
语法错误:是对语言规则的违背,当编译器不能正确识别语句的时候,就会导致语法错误(比如句子末尾少个分号),他们都是在编译期间就被检查出来的错误,所以也叫编译错误。
逻辑错误:指代码逻辑上的错误,编译能通过,程序可以完成运行,但是不会产生正确的结果(比如死循环)
控制语句有哪几种,请画出他们的流程图
三种控制语句:
顺序
循环
选择
什么是else摇摆问题
if(a>0){
if(b>0)
//do something
else
//do something
}
这里的else匹配第二个if 而非第一个
即else匹配最近一个未和else匹配的if
enum 枚举类型
Enum Week{Mon,Tue,Wed,Thu,Fri,Sat,Sun);
对应0,1,2,3,4,5,6;
枚举是对整数区间的自定义类型,一旦定义则不能改变,常用于代替整数常量,使得代码更加清晰。
assert 断言
assert 断言,是宏,而非函数。
assert 宏的原型定义在 <assert.h>
(C)、<assert>
(C++)中,其作用是如果它的条件返回错误,则终止程序执行。可以通过定义 NDEBUG
来关闭 assert,但是需要在源代码的开头,include
之前。
#define NDEBUG // 加上这行,则 assert 不可用
#include <assert.h>
assert( p != NULL );
sizeof
基本数据类型
sizeof(int) = 4
结构体 struct
字节对齐
:让宽度为2的基本数据类型(short等)都位于能被2整除的地址上,让宽度为4的基本数据类型(int等)都位于能被4整除的地址上,依次类推。这样,两个数中间就可能需要加入填充字节,所以整个结构体的sizeof值就增长了。struct S1 { char a; //sizeof(char) = 1 int b; //sizeof(int) = 4 }; sizeof(S1); //值为8,4字节对齐,在char之后会填充3个字节。 struct S3 { }; sizeof(S3); //值为1,空结构体也占内存
联合体 union
结构体在内存组织上是顺序式的,联合体则是重叠式,各成员共享一段内存;所以整个联合体的sizeof也就是每个成员sizeof的最大值。
union u { int a; // sizeof(int) = 4 float b; // sizeof(float) = 4 double c; // sizeof(double) = 8 char d; // sizeof(char) = 1 }; sizeof(u); //值为8
指针
一个指针的 sizeof 必定是 4
char *b = "helloword"; int c = sizeof(b); // c = 4
数组
数组的sizeof值等于数组所占用的内存字节数。
注意:
当字符数组表示字符串时,其sizeof值将
’/0’
计算进去。当数组为形参时,其 sizeof 值相当于指针的sizeof值(4)。
char a[10]; char n[] = "abc"; cout<<sizeof(a)<<endl;//数组的大小为10,值为10 cout<<sizeof(n)<<endl;//字符串数组,将'/0'计算进去,值为4 void func(char a[3]) { int c = sizeof(a); //c = 4,因为这里a不在是数组类型,而是指针,相当于char *a。 } void funcN(char b[]) { int cN = sizeof(b); //cN = 4,理由同上。 }
函数
sizeof也可对一个函数调用求值,其结果是函数返回值类型的大小,函数并不会被调用。
对函数求值的形式:
sizeof(函数名(实参表))
注意:
不可以对返回值类型为空的函数求值
不可以对函数名求值
对有参数的函数,在用sizeof时,须写上实参表
float FuncP(int a, float b){ return a + b; } int FuncNP(){ return 3; } void Func(){ } int main(){ cout<<sizeof(FuncP(3, 0.4))<<endl; //OK,值为4,相当于sizeof(float) cout<<sizeof(FuncNP())<<endl; //OK,值为4,相当于sizeof(int) /*cout<<sizeof(Func())<<endl; //error,sizeof不能对返回值为空类型的函数求值*/ /*cout<<sizeof(FuncNP)<<endl; //error,sizeof不能对函数名求值*/ }
类型转换
隐式类型转换:赋值运算要求左值与右值的类型相同,若类型不同,编译系统会自动将右值转换为左值的类型。
显示/强制类型转换:比如
int a = 1; float x = 10.0; x = (float) a + 1;
二、函数
using
① using 声明
一条 using 声明
语句一次只引入命名空间的一个成员。它使得我们可以清楚知道程序中所引用的到底是哪个名字。如:
using namespace_name::name;
② 构造函数的 using 声明
在 C++11 中,派生类能够重用其直接基类定义的构造函数。
class Derived : Base {
public:
using Base::Base;
/* ... */
};
如上 using 声明,对于基类的每个构造函数,编译器都生成一个与之对应(形参列表完全相同)的派生类构造函数。生成如下类型构造函数:
Derived(parms) : Base(args) { }
③ using 指示
using 指示
使得某个特定命名空间中所有名字都可见,这样我们就无需再为它们添加任何前缀限定符了。如:
using namespace_name name;
④ 尽量少使用 using 指示 污染命名空间
🚥 一般说来,使用 using 命令比使用 using 编译命令更安全,这是由于它只导入了指定的名称。如果该名称与局部名称发生冲突,编译器将发出指示。using编译命令导入所有的名称,包括可能并不需要的名称。如果与局部名称发生冲突,则局部名称将覆盖名称空间版本,而编译器并不会发出警告。另外,名称空间的开放性意味着名称空间的名称可能分散在多个地方,这使得难以准确知道添加了哪些名称。
尽量少使用 using 指示
using namespace std;
应该多使用 using 声明
int x;
std::cin >> x ;
std::cout << x << std::endl;
或者
using std::cin;
using std::cout;
using std::endl;
int x;
cin >> x;
cout << x << endl;
inline 内联函数
① 特征
相当于把内联函数里面的内容写在调用内联函数处;
编译时在调用处用函数进行替换,节省了参数传递、控制转移等开销。相当于不用执行进入函数的步骤,直接执行函数体;
相当于宏,却比宏多了类型检查,真正具有函数特性;
编译器一般不内联包含循环、递归、switch 等复杂操作的内联函数;
在类声明中定义的函数,除了虚函数的其他函数都会自动隐式地当成内联函数。
② 使用
// 声明1(加 inline,建议使用)
inline int functionName(int first, int second,...);
// 声明2(不加 inline)
int functionName(int first, int second,...);
// 定义
inline int functionName(int first, int second,...) {
/****/
};
// 类内定义,隐式内联
class A {
int doA() { return 0; } // 隐式内联
}
// 类外定义,需要显式内联
class A {
int doA();
}
inline int A::doA() { return 0; } // 需要显式内联
③ 编译器对内联函数的处理步骤
将 inline 函数体复制到 inline 函数调用点处;即直接把函数体嵌入到代码中,而没有函数调用的出入栈的过程
为所用 inline 函数中的局部变量分配内存空间;
将 inline 函数的的输入参数和返回值映射到调用方法的局部变量空间中;
如果 inline 函数有多个返回点,将其转变为 inline 函数代码块末尾的分支(使用 GOTO)。
比如:
inline int Max(int a, int b){
if(a>b)
return a;
return b;
}
K = Max(n1,n2);
等价于
if(n1 > n2)
tmp = n1;
else
tmp = n2;
K = tmp;
④ 内联函数的优缺点
优点
内联函数同宏函数一样将在被调用处进行代码展开,省去了参数压栈、栈帧开辟与回收、结果返回等,从而提高程序运行速度。
内联函数相比宏函数来说,在代码展开时,会做安全检查或自动类型转换(同普通函数),而宏定义则不会。
在类中声明同时定义的成员函数,自动转化为内联函数,因此内联函数可以访问类的成员变量,宏定义则不能。
内联函数在运行时可调试,而宏定义不可以。
缺点
代码膨胀。内联是以代码膨胀(复制)为代价,消除函数调用带来的开销。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。
nline 函数无法随着函数库升级而升级。inline函数的改变需要重新编译,不像 non-inline 可以直接链接。
是否内联,程序员不可控。内联函数只是对编译器的建议,是否对函数内联,决定权在于编译器。
⑤ 虚函数可以是内联函数吗
内联是可以修饰虚函数的,但是当虚函数表现多态性的时候不能内联。
内联是在编译期由编译器决定是否内联,而虚函数的多态性在运行期,编译器无法知道运行期调用哪个代码,因此虚函数表现为多态性时(运行期)不可以内联。
inline virtual
唯一可以内联的时候是:编译器知道所调用的对象是哪个类(如Base::who()
),这只有在编译器具有实际对象而不是对象的指针或引用时才会发生。
内联虚函数 示例代码:
#include <iostream>
using namespace std;
class Base
{
public:
inline virtual void who()
{
cout << "I am Base\n";
}
virtual ~Base() {}
};
class Derived : public Base
{
public:
inline void who() // 不写inline时隐式内联
{
cout << "I am Derived\n";
}
};
int main()
{
// 此处的虚函数 who(),是通过类(Base)的具体对象(b)来调用的,编译期间就能确定了,所以它可以是内联的,但最终是否内联取决于编译器。
Base b;
b.who();
// 此处的虚函数是通过指针调用的,呈现多态性,需要在运行时期间才能确定,所以不能为内联。
Base *ptr = new Derived();
ptr->who();
// 因为Base有虚析构函数(virtual ~Base() {}),所以 delete 时,会先调用派生类(Derived)析构函数,再调用基类(Base)析构函数,防止内存泄漏。
delete ptr;
ptr = nullptr;
system("pause");
return 0;
}
结构体 struct 和 typedef struct
C 中
// c
typedef struct Student {
int age;
} S;
等价于
// c
struct Student {
int age;
};
typedef struct Student S;
此时 S
等价于 struct Student
,但两个标识符名称空间不相同。
另外还可以定义与 struct Student
不冲突的 void Student() {}
。
C++ 中
由于编译器定位符号的规则(搜索规则)改变,导致不同于C语言。
如果在类标识符空间定义了
struct Student {...};
,使用Student me;
时,编译器将搜索全局标识符表,Student
未找到,则在类标识符内搜索。即表现为可以使用
Student
也可以使用struct Student
,如下:// cpp struct Student { int age; }; void f( Student me ); // 正确,"struct" 关键字可省略
若定义了与
Student
同名函数之后,则Student
只代表函数,不代表结构体,如下:typedef struct Student { int age; } S; void Student() {} // 正确,定义后 "Student" 只代表此函数 //void S() {} // 错误,符号 "S" 已经被定义为一个 "struct Student" 的别名 int main() { Student(); struct Student me; // 或者 "S me"; return 0; }
C++ 中 struct 和 class
总的来说,struct 更适合看成是一个数据结构的实现体,class 更适合看成是一个对象的实现体。
最本质的一个区别就是默认的访问控制
默认的继承访问权限。struct 是 public 的,class 是 private 的。
struct 作为数据结构的实现体,它默认的数据访问控制是 public 的,而 class 作为对象的实现体,它默认的成员变量访问控制是 private 的。
union 联合
联合(union)是一种节省空间的特殊的类,一个 union 可以有多个数据成员,但是在任意时刻只有一个数据成员可以有值。当某个成员被赋值后其他成员变为未定义状态。
union Mark{
char grade;
bool pass;
int percent;
};
sizof(Mark) = 4;
联合有如下特点:
默认访问控制符为 public
可以含有构造函数、析构函数
不能含有引用类型的成员
不能继承自其他类,不能作为基类
不能含有虚函数
匿名 union 在定义所在作用域可直接访问 union 成员
匿名 union 不能包含 protected 成员或 private 成员
全局匿名联合必须是静态(static)的
#include<iostream>
union UnionTest {
UnionTest() : i(10) {};
int i;
double d;
};
static union {
int i;
double d;
};
int main() {
UnionTest u;
union {
int i;
double d;
};
std::cout << u.i << std::endl; // 输出 UnionTest 联合的 10
::i = 20;
std::cout << ::i << std::endl; // 输出全局静态匿名联合的 20
i = 30;
std::cout << i << std::endl; // 输出局部匿名联合的 30
return 0;
}
三、数据的共享和保护
C++ 中的作用域
**函数原型作用域:在函数原型声明时形参的作用范围就是函数原型作用域。**即只在形参的小括号内有用,出了小括号就死亡
**局部作用域/块作用域:函数形参列表中形参的作用域。**从形参的声明处开始,到整个函数体结束为止;函数体内声明的变量,其作用域从声明处开始,到整个函数体结束为止
类作用域
**全局作用域/文件作用域:**在任何函数外部声明的标识符的作用范围都是文件作用域,从声明标识符开始,到文件末尾结束,这之间的任何函数都可以访问该标识符
C++中的存储类型指示符
auto
auto标名一个变量具有自动存储周期,该说明符只能用在具有局部作用域的变量声明中。比如一般的局部变量或是在函数原型中的参数
static
使用static声明的局部变量仅被其所声明所在的函数所知,外部无法共享该变量,static局部变量在函数返回后仍保存着变量的值
extern
表明在其他地方定义了该变量(该关键字用于全局变量)
register
只能用在具有局部作用域的变量,请求一个变量存储在寄存器中快速使用,但是不能获得该变量的地址。register通常是不必要的
类型 |
特性 |
---|---|
auto |
代码块作用域/局部作用域,自动存储期 |
register |
代码块作用域/局部作用域,自动存储期 |
static |
代码块作用域 或者 文件 / 全局作用域,静态存储期 |
extern |
文件/全局作用域,静态存储期 |
常量 const
常量:const 用于定义常量,const修饰的数据无法被改变,可以用于保护被修饰的变量,防止意外的修改,增强程序的健壮性。
指向常量的指针 / 指针常量:const 可用于修饰指针,分为指向常量的指针(
const int * p1
:指针指向的内容不能被修改)和指针常量(int * const p2
:指针本身不可被修改)方便记忆可以理解为
*
在 const 前面 则为指针常量常引用:const 可用于修饰引用,指向常量的引用,用于形参类型,既避免了拷贝、又避免了函数对值的修改
常成员函数:const 可用于修饰成员函数,说明该成员函数内不能修改成员变量
静态 static
静态成员变量:static 声明静态数据成员,具有全局生命周期,不随函数的消亡而消亡,static 修改的数据不属于任何一个对象,该类的所有对象共享。
静态成员函数:static 修饰成员函数,使得不需要对象就能访问该静态成员函数,但是在静态函数内不能访问或修改非静态成员变量或非静态成员函数。
静态普通变量:static 修饰普通变量,修改变量的存储区域和生命周期,使变量存储在静态区,具有静态生命周期,在 main 函数运行前就分配了空间,如果有初始值就用初始值初始化它,如果没有初始值系统用默认值初始化它。
静态普通函数:static 修饰普通函数,表明函数的作用范围,仅在定义该函数的文件内才能使用。在多人开发项目时,为了防止与他人命名空间里的函数重名,可以将函数定位为 static。
友元 friend
友元函数和友元类:
若 B 类是 A 类 的友元,则 B 类可访问 A 类的私有成员
友元机制破坏了数据封装与数据隐藏,建议尽量不使用或少使用友元
友元关系不可传递
友元关系的单向性
友元声明的形式及数量不受限制
四、数组与指针
指针运算符 *
和 &
*
称为指针运算符,也称解析;&
称为取地址运算符;
int a = 3; //假设a的地址为1000
int *p = &a; //定义一个指针,此处的 * 不作解析
cout<<*p<<endl; //输出3
cout<<&p<<endl; //输出1000
this 指针
this
指针是一个隐含于每一个非静态成员函数中的特殊指针。它指向调用该成员函数的那个对象。当对一个对象调用成员函数时,编译程序先将对象的地址赋给
this
指针,然后调用成员函数,每次成员函数存取数据成员时,都隐式使用this
指针。当一个成员函数被调用时,自动向它传递一个隐含的参数,该参数是一个指向这个成员函数所在的对象的指针。
this
指针被隐含地声明为:ClassName *const this
,这意味着不能给this
指针赋值;在ClassName
类的const
成员函数中,this
指针的类型为:const ClassName* const
,这说明this
指针所指向的这种对象是不可修改的(即不能对这种对象的数据成员进行赋值操作);this
并不是一个常规变量,而是个右值,所以不能取得this
的地址(不能&this
)。在以下场景中,经常需要显式引用
this
指针:为实现对象的链式引用;
为避免对同一对象进行赋值操作;
在实现一些数据结构时,如
list
。
动态内存分配 new
同一个程序有可能会处理很大的数据,有时处理很小,若总是以最大的空间内存分配,难免会造成浪费。
在C语言中,用
malloc
进行动态内存分配;在C++中,用
new
语句
① new 动态分配
动态分配一个变量
p = new T; //T代表int,double,float等等
//p类型为T*的指针
//该语句动态分配了一片大小为 sizeof(T) 字节的内存空间,并将起始地址赋给了p
int *pn;
pn = new int;
*pn = 5; //往刚开辟的空间中写入了数据5
动态分配一个数组
p = new T[N];
//动态分配了一片大小为 sizeof(T)*N 字节的内存空间,并将起始地址赋给了p
int *pn;
int i = 5;
pn = new int[i*20]; //100个元素,最大合法下标为99
pn[0] = 20;
pn[100] = 30; // 编译没问题,运行时数组越界
注:new运算符返回值类型都是 T*
② delete 释放动态分配的内存
delete总是和new成对出现 :即动态分配的内存一定要释放掉,否则占用的内存就会越来越多。
delete指针
该指针必须指向new出来的空间
int *p = new int;
*p = 5;
delete p;
delete p; //wrong; 同一片空间不能被delete两次
delete [ ]指针
int *p = new int[20];
p[0] = 1;
delete []p; //若delete p 则只删除了一部分,没有删干净
深复制和浅复制
以下面这个类为例:
class Point{
...
}
class ArrayOfPoints{
private:
Point *points;
int size;
public:
ArrayOfPoints(int size):size(size){
points = new Points[size];
}
~ArrayOfPoints(){
delete []points;
}
};
浅复制:实现对象间数据元素的一一对应复制,但是指向的仍是同一个地址空间,并没有形成真正的副本,其中一个改变,另一个也会改变。(默认的复制构造函数就是浅复制)
深复制:当被复制的对象数据成员是指针类型时,不是复制该指针成员本身,而是将指针所指的对象进行复制
一般深复制需要重写复制构造函数,new一个新的存储空间进行值的复制操作
ArrayOfPoints::ArrayOfPoints(const ArrayOfPoints& rhs){ size = rhs.size; points = new Point[size]; for(int i = 0; i < size; i++) points[i] = rhs.points[i]; }
C++ 中的内存区域
C++中的内存区域分为5个区:堆区、栈区、全局/静态存储区、常量存储区、代码程序区
栈(stack):存放函数的参数值、局部变量等,由编译器自动分配和释放,通常在函数执行完后就释放了,其操作方式类似于数据结构中的栈。栈内存分配运算内置于CPU的指令集,效率很高,但是分配的内存量有限。
堆(heap):由程序员控制内存的分配和释放的存储区,是不连续的存储空间,堆的分配(new)和释放(delete)有程序员控制,容易造成二次删除和内存泄漏,堆的分配方式类似于链表
void fun(){ int *p = new int[5]; }
在上述代码中就包含了堆和栈,看到new,我们就知道分配了一块堆内存,那么指针p呢,它分配的是一块栈内存。即在栈内存中存放了一个指向一块堆内存的指针p
静态存储区(static):存放全局变量和静态变量的存储区,初始化的变量放在初始化区,未初始化的变量放在未初始化区。在程序结束后释放这块空间
常量存储区(const):存放常量字符串的存储区,只能读不能写
程序代码区:存放源程序二进制代码
五、类的继承和派生
类成员的访问控制
访问控制 |
功能 |
---|---|
|
类外(main()函数)不能访问 private 和 protected 类型,只能访问声明 public 的对外接口 |
|
类开头若不加访问权限,则默认为private类型 ① 可被基类的成员函数访问 ② 可被基类的友元函数访问 |
|
① 可被基类的成员函数访问 ② 可被基类的友元函数访问 ③ 派生类的成员函数可以访问 当前对象 基类的protected成员 |
三种继承方式
继承方式 |
功能 |
---|---|
|
基类的 public 和 protected 成员访问属性在派生类中保持不变 基类的 private 成员,派生类不可直接访问 |
|
基类的 public 和 protected 成员都以 private 身份出现在派生类中 基类的 private 成员,派生类不可直接访问 |
|
基类的 public 和 protected 成员都以 protect 身份出现在派生类中 基类的 private 成员,派生类不可直接访问 |
为什么说继承是面向对象的主要特征之一
继承是软件重用的一种形式。我们可以通过继承这一方式,从现有的类中吸收数据和方法,并添加现有的类中所没有的新的数据和方法。通过继承这一方式,提高了程序的抽象程度,更加接近人的思维方式,使程序结构清晰并且有利于开发和维护。
成员初始化列表
class Student{
private:
string name;
int age;
public:
Student(string n, int a): name(n),age(a){}
}
使用成员初始化列表的好处:
更高效:初始化列表可以不必调用默认构造函数来初始化,少了一次调用默认构造函数的过程。
有些场合必须要用初始化列表:
常量成员 const ,因为常量只能初始化不能赋值,所以必须放在初始化列表里面
class A{ private: const int i; public: A(int a): i(a){} // 常数据成员只能通过构造函数的初始化列表来获得初值 }
引用类型,引用必须在定义的时候初始化,并且不能重新赋值,所以也要写在初始化列表里面
没有默认构造函数的类类型,因为使用初始化列表可以不必调用默认构造函数来初始化
::
范围解析运算符
全局作用域符(
::name
):用于类型名称(类、类成员、成员函数、变量等)前,表示作用域为全局命名空间类作用域符(
class::name
):用于表示指定类型的作用域范围是具体某个类的命名空间作用域符(
namespace::name
): 用于表示指定类型的作用域范围是具体某个命名空间的
示例:
int count = 11; // 全局(::)的 count
class A {
public:
static int count; // 类 A 的 count(A::count)
};
int A::count = 21;
void fun()
{
int count = 31; // 初始化局部的 count 为 31
count = 32; // 设置局部的 count 的值为 32
}
int main() {
::count = 12; // 测试 1:设置全局的 count 的值为 12
A::count = 22; // 测试 2:设置类 A 的 count 为 22
fun(); // 测试 3
return 0;
}
多继承时的二义性问题
如果某个派生类的部分或全部基类都是从另一个共同的基类派生而来,
在这些直接基类中,从上一级继承而来的成员就拥有相同的名称,因此派生类中就会出现同名现象。
class Base{
int var0;
void fun();
};
class Derived1:public Base{
int var1;
};
class Derived2: public Base{
int var2;
};
class A: public Derived1,public Derived2{
int var3;
void fun();
};
int main(){
Derived d;
d.var0 = 3; //二义性
d.Base::var0; //二义性
return 0;
}
① 作用域限定
class Base{
int var0;
void fun();
};
class Derived1:public Base{
int var1;
};
class Derived2: public Base{
int var2;
};
class A: public Derived1,public Derived2{
int var3;
void fun();
};
int main(){
Derived d;
d.Derived1::var0 = 2;
d.Derived1::fun0();
d.Derived2::var0 = 3;
d.Derived2::fun0();
return 0;
}
使用作用域限定符,派生类的对象在内存中就同时拥有成员var0的多份同名副本,可赋不同的值,若只需要一个这样的数据副本,使用虚基类
② 虚继承 / 虚基类
虚基类(说虚继承可能更好理解 😂)用于解决多继承(派生类继承多个小基类,这些小基类又都继承同一个大基类)条件下的菱形继承问题(浪费存储空间、存在二义性)。
为最远的派生类提供唯一的基类成员,而不重复产生多次复制
class Base{
int var;
};
class Derived1: virtual public Base{
};
class Derived2: virtual public Base{
};
class A: public Derived1,public Derived2{
};
int main{
A a;
a.var = 2; //直接访问基类的数据成员
}
📣 虚基类的底层实现原理:
底层实现原理与编译器相关,一般通过虚指针和虚表实现(详见下文),每个虚继承的子类都有一个虚基类指针(占用一个指针的存储空间,4字节)和虚基类表(不占用类对象的存储空间)(需要强调的是,虚基类依旧会在子类里面存在拷贝,只是仅仅最多存在一份而已,并不是不在子类里面了);当虚继承的子类被当做父类继承时,虚基类指针也会被继承。
③ 虚基类 和 作用域限定 比较
前者只维护一份成员副本,后者在派生类中拥有同名成员的多个副本,可以存放不同的数据。
相比之下,前者使用更为简洁,空间更为节省,后者可以容纳更多的数据
六、多态
静态绑定 和 动态绑定
绑定机制:
绑定是将一个标识符名和一个存储地址联系在一起的过程
编译时的多态通过静态绑定实现
绑定工作在程序编译连接阶段运行(比如重载)
运行时的多态通过动态绑定实现
绑定工作在程序运行阶段运行(比如虚函数)
C++ 多态的实现
多态是指同样的消息被不同类型的对象接收时产生不同的行为,是对类的特定成员函数的再抽象。
C++ 多态的实现:
重载多态:编译期的多态,静态绑定 —— 函数重载、运算符重载;
子类型多态:运行期的多态,动态绑定 —— 虚函数;
参数多态:编译器的多态,静态绑定 —— 函数模板、类模板;
强制多态:编译期或者运行期的多态 —— 强制类型转换、自定义类型转换。
重载(函数重载、运算符重载)和虚函数是多态实现的主要方式。
虚函数 virtual
① 如何区别虚函数和纯虚函数?两者都有啥作用
virtual void fun(); //虚函数
virtual void fun() = 0; //纯虚函数
虚函数使得基类和派生类的同名函数具有不同的操作,实现多态。
纯虚函数是一种特殊的虚函数,在基类中不能对虚函数给出有意义的实现,纯虚函数只是一个接口,是个函数的声明而已,它要留到子类里去实现。
有纯虚函数的类称为抽象类。这种类不能直接生成对象,只有被继承,并且纯虚函数在子类中必须给出实现,否则该子类仍是纯虚函数
虚函数在子类里面可以不重写;但纯虚函数必须在子类给出实现才可以实例化子类
② 虚析构函数的作用
析构函数:类的析构函数是为了释放内存资源,析构函数不被调用的话就会造成内存泄漏。
虚析构函数:定义为虚析构函数是为了当用一个基类的指针删除一个派生类的对象时,派生类的析构函数会被调用。
但并不是要把所有类的析构函数都写成虚函数。只有当一个类被用来作为基类的时候,才把析构函数写成虚函数。
class Shape
{
public:
Shape(); // 构造函数不能是虚函数
virtual double calcArea();
virtual ~Shape(); // 虚析构函数
};
class Circle : public Shape // 圆形类
{
public:
virtual double calcArea();
...
};
int main()
{
Shape * shape1 = new Circle(4.0);
shape1->calcArea();
delete shape1; // 因为Shape有虚析构函数,所以delete释放内存时,先调用子类析构函数,再调用基类析构函数,防止内存泄漏。
shape1 = NULL;
return 0;
}
③ 虚指针、虚表
虚指针:每个对象有一个指向当前类的虚表的指针。
虚表:每个多态类都有一个虚表,虚表中存放当前类的各个虚函数的入口地址,如果派生类实现了基类的某个虚函数,则在虚表中覆盖原本基类的那个虚函数指针,在编译时根据类的声明创建。
动态绑定的实现:
构造函数中为对象的虚指针赋值;
通过多态类型的指针或引用调用成员函数时,通过虚指针找到虚表,进而找到所调用的虚函数的入口地址
通过该地址调用虚函数
重载
① 函数重载
C++允许功能相近的函数在相同的作用域内,以相同函数名声明,从而形成重载。
函数重载提高了程序的可扩充性。
🚨 注意:
重载函数的 形参个数 或者 形参类型 必须不同;
编译器不以 函数的返回值 或者 形参名 来去区分重载函数。
若只有返回值类型不同,则不叫重载,叫重复定义
② 运算符重载 operator
Ⅰ 什么是运算符重载
对已有的运算符赋予多重含义,使用一个运算符作用于不同类型的数据时产生不同的行为。
Ⅱ 运算符重载规则
重载为类成员函数
双目运算符重载规则(1个参数 对象)
Complex operator + (const Complex &c2) const;
前置单目运算符重载规则(无参)
Point &Point::operator++(); //前置++
后置单目运算符重载规则(1个参数 int)
Point operator++(int); //后置++
重载为非成员函数
双目运算符重载规则(2个参数 对象)
friend Complex operator + (const Complex &c1, const Complex &c2);
前置单目运算符重载规则 (1个参数 对象)
friend Complex operator ++(Complex &c1);
后置单目运算符重载规则(2个参数 对象,0)
friend Complex operator ++(Complex &c1, 0);
Ⅲ 哪几个运算符必须重载为成员函数
()
[
->
=
模板 template
函数模板
template<class T> T sum(T a, T b){ return a + b; }
类模板
template<class T> class Store{ private: T item; //item用于存放任意类型的数据 public: T &getElem(); //提取数据 }; template<class T> T& Store<T>::getElem(){ return item; }
① 函数模板 和 函数重载 的区别和联系
函数模板:若一个函数的功能是对任意类型的数据做同样的处理,比如一个加法函数可以处理float、int、char等多种类型,则将所处理的数据类型说明为参数,就可以把这个函数声明为模板
<template>
。函数模板代表的是具有相同功能的一类函数,函数模板的参数都是抽象的。函数重载:同一函数名定义多个函数,这些函数的参数个数,参数类型不同,这就是函数重载(不以返回值判定是否是重载)。函数重载的参数都是具体的。
② 函数模板 和 模板函数 的区别与联系
函数模板:函数模板就是数据类型参数化的函数定义,是一类函数。当编译系统发现用指定数据调用函数模板的时候,就创建了一个模板函数,模板函数就是函数模板实例化的结果。