c++基础问题整理
背景
对常见的基础问题进行一个汇总整理
什么是多态?如何实现多态?
多态是面向对象编程中的一个重要概念,它指的是通过一个基类指针或引用调用一个虚函数时,会根据具体对象的类型来调用该虚函数的不同实现。
在多态中,相同的操作可以作用于不同的对象,而具体执行的操作则取决于对象的类型和特性。
在C++中,要实现多态,需要满足以下两个条件:
- 必须通过基类的指针或者引用调用虚函数
- 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
什么是虚函数?为什么要使用虚函数?
虚函数是C++中用于实现多态(polymorphism)的机制。核心理念就是通过基类访问派生类定义的函数。
在有动态分配堆上内存的时候,析构函数必须是虚函数,但没有必要是纯虚的。
想要构成多态,必须使用虚函数,借助于虚函数表来实现,
我们也知道,虚函数表是为了让父类引用子函数对象实现多态的。
虚函数的实现原理?
虚函数的实现原理主要涉及两部分:
虚函数表(Virtual Table)
编译器会为包含虚函数的类建立一个虚函数表(Virtual Table)。这个表中存放了该类所有的虚函数的入口地址。虚表指针(Virtual Pointer)
包含虚函数的类在每个对象中都有一个虚表指针(this指针就是),该指针指向该类的虚函数表。
当调用对象的虚函数时,通过这个虚表指针先找到对应的虚函数表,再在虚函数表中取得要调用的虚函数的实际入口地址,然后进行调用。
这样当对基类指针调用虚函数时,就可以根据这个指针动态得到派生类的覆盖实现版本的虚函数。
总结一下虚函数的调用过程:
- 通过对象的虚表指针找到虚函数表
- 在虚函数表中找到函数名对应的函数入口
- 跳转到该入口地址进行函数调用
这就是C++虚函数实现多态的基本原理和方法。
什么是虚函数表?
虚函数表是指在每个包含虚函数的类中都存在着一个函数地址的数组。
当我们用父类的指针来操作一个子类的时候,这张虚函数表指明了实际所应该调用的函数。
C++的编译器保证虚函数表的指针存在于对象实例中最前面的位置,这样通过对象实例的地址得到这张虚函数表,然后就可以遍历其中函数指针,并调用相应的函数。
什么是继承?继承有哪些特性?
继承是面向对象软件技术当中的一个概念,与多态、封装共为面向对象的三个基本特征。
继承可以使得子类具有父类的属性和方法或者重新定义,追加属性和方法。
在C++中,继承有以下几个特性:
- 派生类可以重载基类中的虚函数。
- 派生类可以访问基类的成员。
- 派生类可以新增成员函数和成员变量。
- 派生类可以作为基类的友元函数调用。
派生类可以访问基类的public, protected, private成员吗?
派生类不能直接访问基类的private成员,若要访问必须使用基类的接口,即通过其成员函数。
派生类对基类成员的访问权限,与是public 、 protected 、 private继承类型没关系,仅仅与基类中成员的权限属性有关系: 派生类能访问基类的public和protected成员,不能访问private成员 。
继承类型对派生类的影响是什么?这三种继承类型的区别是什么?
在C++中,继承有三种类型:公有继承(public)、私有继承(private)、保护继承(protected)。
派生从已有类产生新类的过程就是类的派生。
产生的新类就叫做派生类(derived class)。
被重用的原有类称为基类(base class)。
派生类不会影响到基类的结构 。
在C++中,公有继承、私有继承和保护继承的区别如下:
- 公有继承:基类中所有public成员在派生类中都为public属性;基类中所有protected成员在派生类中都为protected属性;基类中所有private成员在派生类中都为private属性。派生类对象可以访问基类中的公有成员和保护成员,但是不能访问基类的私有成员。
- 私有继承:基类中所有public和protected和private成员在派生类中都为private属性。派生类对象可以访问基类中的公有成员和保护成员,但是不能访问基类的私有成员。
- 保护继承:基类中所有public和protected成员在派生类中都为protected属性;基类中所有private成员在派生类中都为private属性。派生类对象可以访问基类中的公有成员和保护成员,但是不能访问基类的私有成员。
派生类可以作为基类的友元函数调用
派生类可以作为基类的友元函数调用。
在C++中,如果一个函数被声明为某个类或结构体的友元函数,那么这个函数就可以访问该类或结构体的所有成员(包括私有成员)和所有成员函数。
因此,派生类可以作为基类的友元函数调用,以便访问基类中的私有成员。
友元函数
友元函数是指某些虽然不是类成员却能够访问类的所有成员的函数。类授予它的友元特别的访问权。
通常同一个开发者会出于技术和非技术的原因,控制类的友元和成员函数(否则当你想更新你的类时,还要征得其它部分的拥有者的同意)。
在C++中,如果要声明函数为一个类的友元,需要在类定义中该函数原型前使用关键字 friend
示例代码如下:
1 | #include <iostream> |
在这个例子中,我们将派生类Derived声明为基类Base的友元类。
这样,Derived就可以直接访问Base的私有成员x。
在main函数中,我们通过派生类对象调用accessBaseX()函数修改了基类的x,然后再通过showX()函数输出x,说明派生类作为友元确实可以访问基类的私有成员。
如果在class定义中没有显式指定访问权限,则默认为private
也就是说,如果写成这样:
1 | class MyClass { |
那么x和func()默认都是private的,不能在class外部直接访问。
需要改成
1 | class MyClass { |
才能让x和func()成为public,可以在class外部访问。
所以C++类的默认访问权限是private。这与struct不同,struct默认是public。
这点需要注意,如果忘记写public,可能会导致意料之外的不可访问错误。
什么是抽象类和接口?它们之间有什么区别?
抽象类和接口都可以包含抽象方法,但是它们之间有以下几点主要区别:
在C++中,抽象类和接口的主要区别有:
(1)抽象类使用纯虚函数,接口使用纯虚函数加虚继承
(2)抽象类可以有构造函数,接口不能有
(3)抽象类可以包含普通函数的实现,接口所有函数都是纯虚函数
(4)一个类只能继承一个抽象类,但可以同时继承多个接口
(5)抽象类强调对类的抽象,接口强调对行为的抽象
总结一下:
- 抽象类体现的是”是什么”的关系,一个子类只能继承一个抽象类
- 接口体现的是”能做什么”的关系,一个类可以同时实现多个接口
抽象类与子类是 is-a 关系,接口与实现类是 has-a 关系。
抽象类示例
示例代码如下:
1 | #include <iostream> |
在C++中,抽象类包含至少一个纯虚函数,它只定义方法签名,不实现函数体。
子类在继承抽象类时,必须实现抽象类中的所有纯虚函数,否则子类也变为抽象类。
上面代码中,Shape类包含一个纯虚函数draw(),作为抽象方法。 Circle和Rectangle继承自Shape,并重写draw()方法,实现了抽象方法。
这样就可以通过子类对象调用抽象方法了。
子类在继承抽象类时,必须实现抽象类中的所有纯虚函数,否则子类也变为抽象类
在C++中,如果子类继承了抽象类,但是没有实现抽象类中的所有纯虚函数,那么这个子类也会变成抽象类。
但是子类可以选择只重写抽象类中的部分纯虚函数,而保留未实现的纯虚函数的纯虚性。
这时子类依然是一个抽象类,不能实例化对象,需要再被其他类继承实现剩余的纯虚函数。
例如:
1 | class Abstract { |
这里SubClass只重写了func1(),func2()仍为纯虚函数。
那么SubClass仍然是一个抽象类,需要后续完全实现两个纯虚函数才能成为具体类。
所以结论是:
子类必须实现抽象类的所有纯虚函数,才能成为具体类
子类可以只重写部分纯虚函数,保留其抽象类性质
接口示例
好的,在C++中可以通过纯虚函数和虚继承来实现接口,示例代码如下:
1 |
|
通过Interface类中的纯虚函数定义接口,ImplA和ImplB虚继承Interface并实现这些纯虚函数。
func()函数通过Interface指针调用方法实现解耦。
这就是C++中使用纯虚函数和虚继承实现接口的基本方式。
重载与重写
在C++中,重载(overloading)和重写(overriding)是两个不同的概念:
重载(overloading)指的是在同一个类中有相同名称但参数列表不同的多个函数。它必须发生在类内,是一种静态绑定。
例如:
1 | class Foo { |
重写(overriding)指派生类重新定义基类的虚函数,参数列表必须完全相同。它涉及基类和派生类,是一种动态绑定。
例如:
1 | class Base { |
重载是编译期决定的静态绑定,重写是运行期通过动态绑定来调用派生类的函数实现。
简记为:
- 重载 - 相同名,不同参;静态绑定
- 重写 - 相同签名;动态绑定
重写虚函数的时候该不该加override?
在C++中重写虚函数时加override关键字和不加的区别是:
加override可以防止意外重载
如果子类恰好定义了和父类同名的虚函数,但参数不同,就变成重载了,加override可以让编译器报错,防止这种情况。加override可以检查父类是否有该虚函数
如果父类没有要重写的虚函数,加override会报错,防止重写错误。加override可以提示维护者这个函数正在重写父类虚函数
增加代码可读性。
所以通常情况下,建议在重写虚函数时加上override关键字,既可以避免一些错误,也可以让代码更清晰。
但是有以下情况可以不加:
子类不会定义和父类同名的重载函数。
已确保父类有该虚函数需要重写。
老代码使用C++11之前的标准,不支持override。
所以是否加override可以根据具体情况来决定,建议在可能产生歧义或错误的情况下加上,提高代码质量。
C++中的静态绑定和动态绑定
C++中静态绑定和动态绑定:
静态绑定(Static Binding):
- 函数调用在编译时就已经确定了调用的函数地址
- 调用的是哪个函数是在编译期静态决定的
- C++中所有非虚函数的调用都是静态绑定
动态绑定(Dynamic Binding):
- 函数调用直到运行时才确定调用的函数地址
- 调用哪个虚函数版本是在运行时动态决定的
- C++中所有虚函数的调用都是动态绑定
例如:
1 | void foo(Base* b) { |
静态绑定优点是调用效率高,缺点是灵活性差。动态绑定正相反。
静态绑定主要用于非虚函数和模板函数,动态绑定用于虚函数以实现运行时多态。
什么是内联函数?内联函数和宏的区别是什么?
内联函数(inline function)就是在函数调用点进行函数体扩展 substituted 的函数。
将函数设置为内联函数的好处是:
- 内联函数在编译时进行展开,减少了函数调用的开销。
- 相比宏,内联函数在编译时会进行类型检查,增强了类型安全。
内联函数和宏主要有以下几点区别:
- 宏是文本替换,没有类型检查;内联函数在编译时会类型检查。
- 宏可能会造成重复定义问题;内联函数在链接时会去重复。
- 宏可以接受任意语句作为参数;内联函数只接受和函数形式相匹配的参数。
- 内联函数在Debug模式下不一定会内联,而宏总是展开的。
所以总结为,相比宏,内联函数有类型检查,避免重复定义错误,但是编译器可能不一定内联,需要根据优化级别。
内联函数更安全,宏可能带来更高效的内联。
C++中虚析构函数的作用及需要场景
在C++中,虚析构函数的作用是:
当删除一个派生类对象时,调用派生类的析构函数,确保释放派生类的资源。
场景:
- 基类指针指向派生类对象,通过基类指针删除对象时,需要调用派生类的析构函数
1 | Base* p = new Derived(); |
包含虚函数的类需要声明虚析构函数
1
2
3
4
5class Base {
public:
virtual void foo() {}
virtual ~Base() {} // 虚析构函数
};有纯虚函数的类需要声明纯虚析构函数
1
2
3
4
5class Base {
public:
virtual void foo() = 0;
virtual ~Base() = 0;
};拥有虚基类的类需要声明虚析构函数
1
2
3class Derived : virtual public Base {
~Derived() {} // 需要是虚析构
};
所以包含虚函数或虚继承的类,都需要声明虚析构函数,否则删除派生类对象时可能导致未释放资源。
虚基类
在C++中,虚基类是一种让基类只继承一次的机制。
对于带有虚基类的继承:
- 虚基类只会继承一次,不管出现多少次虚继承
- 派生类的对象中只会包含一份虚基类成员
- 虚基类指针在各个派生类中都是共享的
示例:
1 | class Base { |
使用virtual关键字声明虚基类。
虚基类机制可以解决多继承时基类被继承多次的问题。
需要注意的是,虚继承会增加对象大小并降低构造效率。应该在确实需要共享基类时使用。
虚基类和虚继承的关系?什么情况下用虚基类?
虚基类和虚继承的关系是:
- 虚继承(Virtual Inheritance)是一种继承方式,用于解决多重继承时重复继承的问题。
- 虚基类(Virtual Base Class)是虚继承的基类,通过虚继承后,该基类就称为虚基类。
使用虚基类的典型情况:
需要解决多重继承 Repeat Inheritance 问题时。
例如类A,B都继承自Base,C继承A,B,则Base会重复继承两次,这时可以将Base标记为虚基类。需要在继承链中共享基类数据时。
虚基类只会继承一次,所以派生类中只有一份基类数据的拷贝。基类需要动态绑定或RTTI信息时。
虚基类可以正确实现动态绑定和RTTI的表现。某些设计模式需要重复继承同一抽象基类。
如Visitor模式中,各个具体类都需要继承抽象访问者类。
总之,虚继承是一种继承机制,虚基类是进行虚继承的基类。
当需要重复继承同一基类时,将其设置为虚基类以实现只继承一次。
例如类A,B都虚继承自Base,C继承A,B, 那么ABC共享一份Base数据吗?
如果是这样:
1 | class Base {}; |
则A、B、C会共享同一份Base数据,因为Base被虚继承了。
但是如果是:
1 | class Base {}; |
则A、B各自含有一份Base数据的拷贝,C会包含两份Base,因为Base没有被虚继承。
总结
所以,要使得多重继承链上共享同一份基类数据,该基类必须被所有中间链上的类使用虚继承,否则还是会出现重复实例。