C++ 知识要点:类

三.类

1. 面向对象的三大特性(封装、继承、多态)

封装(Encapsulation)

封装是面向对象编程的核心思想之一,它指的是将对象的状态信息(属性)和行为(方法)捆绑在一起,形成一个独立的单元,并对外部隐藏其内部实现细节,仅对外公开接口(public methods)。这样做的好处是提高了数据的隐蔽性和安全性,减少了外部对对象内部状态的直接操作,增加了程序的模块化和可维护性。

继承(Inheritance)

继承是面向对象编程中实现代码复用的重要手段之一。它允许我们定义一个类(子类/派生类)来继承另一个类(父类/基类)的属性和方法。子类可以拥有父类的所有属性和方法(除非被声明为私有private或保护protected),并可以添加自己特有的属性和方法或重写(Override)父类的方法。通过继承,我们可以基于已有的类来构建新的类,无需从头开始编写所有的代码,从而提高了代码的复用性和可扩展性。

多态(Polymorphism)

多态是面向对象编程中一个非常强大的特性,它允许我们以统一的接口(通常是基类的指针或引用)来操作不同的对象(这些对象都继承自同一个基类或实现了同一个接口)。具体调用哪个类的成员函数,由编译器在运行时决定(动态绑定),这称为运行时多态(动态多态)。多态性可以通过函数重载(编译时多态)和虚函数(运行时多态)来实现。多态的主要好处是提高了程序的灵活性和可扩展性,使得我们可以在不修改现有代码的情况下增加新的功能。

2. structclass 的区别?

在C++中,structclass都可以用来定义类,但它们在默认情况下有一些差异:

  • 成员访问权限:默认情况下,struct的成员是公有的(public),而class的成员是私有的(private)。这意味着在struct中定义的成员可以直接被外部访问,而在class中定义的成员默认只能被类内部的方法访问。
  • 继承方式:在C++11之前,struct默认继承方式是公有继承(public inheritance),而class的默认继承方式是私有继承(private inheritance)。但自从C++11开始,class的默认继承方式也被修改为公有继承(public inheritance),与struct一致。不过,这个差异更多地与继承相关,而不是structclass本身的直接区别。

除此之外,structclass在C++中几乎可以互换使用,选择哪个更多地取决于编程习惯和个人偏好。然而,由于struct默认成员是公开的,它通常用于表示简单的数据结构,如点(Point)或矩形(Rectangle)等;而class则更适合用于表示具有复杂行为和隐藏内部状态的对象。

3. 访问权限说明符?(目的是加强类的封装性)

C++中提供了三种访问权限说明符来控制类成员的访问权限,从而加强类的封装性:

  • public:公有成员可以被任何外部代码访问。它们是类与外部世界交互的接口。
  • protected:受保护成员在类内部及其派生类中是可访问的,但在类外部是不可访问的。这有助于在继承关系中共享数据和方法,同时保持对外部世界的隐藏。
  • private:私有成员只能在类内部被访问,类外部的代码无法直接访问它们。这是封装的核心,它确保了对象内部状态的完整性和安全性。

通过合理使用这些访问权限说明符,我们可以控制哪些信息是对外公开的(public),哪些信息是在类内部使用的(private),以及哪些信息是可以被子类继承的(protected),从而增强类的封装性、安全性和可扩展性。

4. 类的静态成员

所属
静态成员属于类本身,而不是类的某个具体对象。这意味着无论创建多少个类的对象,静态成员都只有一份拷贝,并且所有对象共享这份拷贝。静态成员变量在程序的整个生命周期内存在,而静态成员函数则可以没有对象而直接被调用,但只能通过类名或对象名来访问。

静态成员函数不能声明成 const
实际上,静态成员函数可以声明为 const,但这种声明是多余的,因为静态成员函数不作用于类的任何特定对象实例,所以它不会修改任何对象状态,也就没有必要声明为 const

不能是类类型的成员
这个表述可能有些误导。静态成员可以是类类型的成员,但这里的“类类型”指的是任何类型的对象,包括用户自定义的类型。只是,静态成员的生命周期和存储位置与类实例无关,它们属于类本身。

定义时不能重复使用 static
在类的定义中声明静态成员时,使用 static 关键字。但在类的外部定义这个静态成员时,不能再次使用 static 关键字。这是因为类定义中的 static 关键字是用来声明成员的静态性质,而在类外部定义时,该成员已经通过其声明知道了其静态性质。

具有类内初始值的静态成员定义时不可再设初值
如果静态成员在类内被初始化(C++11及以后),那么在类的外部定义时就不能再指定初始值。这是因为初始化只应发生一次,以避免重复初始化的问题。

5. 构造函数相关

有哪些构造函数

  • 默认构造函数:没有参数或所有参数都有默认值的构造函数。
  • 委托构造函数(C++11及以后):一个构造函数调用同一类的另一个构造函数来初始化对象。
  • 拷贝构造函数:接受一个同类型对象的引用(通常为常量引用)作为参数,用于复制对象。
  • 移动构造函数(C++11及以后):接受一个右值引用(通常通过 std::move 转换得到)作为参数,用于窃取资源而非复制。

合成的默认拷贝构造函数(默认行为?什么情况下不会合成?怎么解决?)

  • 默认行为:逐成员复制(对于基本类型和指针,是浅拷贝;对于支持深拷贝的类类型成员,取决于该类的拷贝构造函数)。
  • 不会合成的情况
    • 类包含至少一个用户定义的构造函数。
    • 类包含至少一个用户定义的拷贝赋值运算符。
    • 类包含至少一个用户定义的移动构造函数或移动赋值运算符。
    • 类的基类包含拷贝构造函数且基类拷贝构造函数被声明为删除或不可访问。
  • 解决:定义自己的拷贝构造函数,明确指定如何复制对象。

拷贝构造函数(调用时机、合成版的行为、explicit?、为何第一个参数必须是引用类型)

  • 调用时机:使用同类型的另一个对象初始化新对象时。
  • 合成版行为:逐成员复制。
  • explicit:不适用于拷贝构造函数,因为它不是类型转换构造函数。
  • 为何第一个参数必须是引用类型:防止无限递归和不必要的对象复制。

移动构造函数(非拷贝而是窃取资源、与 noexcept?、何时合成)

  • 非拷贝而是窃取资源:移动构造函数从源对象窃取资源,并将源对象置于一个安全可析构但内容未定义的状态(通常是清空)。
  • noexcept:移动构造函数被标记为 noexcept 时,编译器在可能的情况下更倾向于使用移动构造函数(例如,在返回局部对象时)。
  • 何时合成:如果类没有定义任何移动构造函数、移动赋值运算符、拷贝构造函数或拷贝赋值运算符,且所有非静态数据成员都可以被移动,则编译器会合成移动构造函数。

可否通过对象或对象的引用(指针或引用)调用
构造函数不是成员函数,不能通过对象或对象的引用(指针或引用)调用。它们是在创建对象时自动调用的。

6. 初始值列表

顺序
成员变量的初始化顺序与它们在类中声明的顺序相同,与初始值列表中的顺序无关。

效率
对于内置类型,确实不需要显式初始化(因为它们会自动初始化),但使用初始值列表可以避免在构造函数体内进行不必要的赋值操作,特别是对于复杂类型或需要计算的初始化表达式,初始值列表通常更高效。

无默认构造函数的成员、const 成员、引用成员必须通过初始值列表初始化

  • 无默认构造函数的成员:必须显式初始化,因为编译器无法自动调用不存在的默认构造函数。
  • const 成员:一旦构造完成,其值就不能改变,因此必须在初始化列表中初始化。
  • 引用成员:必须被初始化,且一旦初始化后就不能再改变指向,因此也必须在初始化列表中初始化。

7.拷贝赋值运算符

合成版的行为

当C++编译器没有为类找到显式的拷贝赋值运算符时,它会生成一个默认的合成拷贝赋值运算符。这个运算符会逐成员地将源对象(即赋值运算符的右侧对象)的内容复制到目标对象(即赋值运算符的左侧对象)中。对于基本数据类型(如intfloat等),这通常意味着直接赋值。对于类类型成员,编译器会递归地调用这些成员的拷贝赋值运算符(如果它们存在的话)。

然而,如果类包含指针成员,并且这些指针指向动态分配的内存或其他资源,那么默认的拷贝赋值运算符可能会导致问题。这是因为两个对象最终会指向相同的资源,这可能导致资源被错误地释放多次(双重释放)或资源被释放后仍然被访问(悬挂指针)。

delete

在C++11及以后的版本中,如果类不应该被拷贝(例如,它管理着唯一的资源,如文件句柄、网络连接等),那么可以通过将拷贝赋值运算符声明为=delete来明确禁止拷贝。这样做的好处是,编译器会在尝试进行拷贝时立即报错,而不是在链接时或运行时出现更难以追踪的错误。

class NonCopyable {
public:
    NonCopyable& operator=(const NonCopyable&) = delete;
    // 可能还需要删除拷贝构造函数
    NonCopyable(const NonCopyable&) = delete;
};
自定义时的注意事项
  • 自赋值:在自定义拷贝赋值运算符时,必须检查源对象和目标对象是否为同一个对象。这通常通过比较它们的地址来完成。如果是同一个对象,则不应执行任何操作,以避免不必要的资源释放和重新分配。
  • 参数:参数通常被设计为常量引用,以避免不必要的拷贝,并允许传递const对象。
  • 返回类型:拷贝赋值运算符通常返回对当前对象的引用(*this),以支持链式调用。
阻止拷贝

在C++11之前,阻止拷贝的常见做法是将拷贝构造函数和拷贝赋值运算符声明为private,并且不在类内部或友元中定义它们。然而,这种方法的一个缺点是,编译器错误消息可能不够清晰,因为链接器会在尝试调用这些函数时报告错误,而不是在编译时。

C++11引入了=delete语法,提供了一种更清晰、更直接的方式来禁止拷贝。如上例所示,通过将拷贝构造函数和拷贝赋值运算符声明为=delete,可以明确表达类不应被拷贝的意图。

移动赋值运算符

移动赋值运算符用于从一个临时对象(即将被销毁的对象)中“窃取”资源,并将其转移到当前对象中。这通常比拷贝更高效,因为它避免了不必要的资源复制。

  • noexcept:将移动赋值运算符声明为noexcept可以通知编译器,在移动过程中不会抛出异常。这允许编译器在某些优化场景中(如标准库容器中的元素重新分配)更积极地使用移动而不是拷贝,因为移动通常更快且更安全(如果不会抛出异常)。
  • 合成条件:如果类定义了移动构造函数、析构函数或拷贝赋值运算符中的任何一个,并且没有显式定义移动赋值运算符,编译器将不会合成移动赋值运算符。这意呀着,如果需要移动赋值功能,开发者必须显式提供。
不可重载的操作符

C++中有一些操作符由于其特殊用途或语言设计的考虑,被设计为不可重载。这些操作符包括:

  • 条件运算符(?::用于根据条件选择两个值之一。
  • 成员访问运算符(.->*:用于访问对象的成员。
  • 作用域解析运算符(:::用于指定类成员或命名空间中的名称。
  • sizeof类型转换运算符:虽然可以定义类型转换函数(如operator int()),但它们不是以操作符重载的形式出现的。sizeof是一个编译时操作符,用于获取对象或类型的大小,也不能被重载。
  • newdelete:虽然可以重载全局的newdelete操作符以提供自定义的内存分配和释放策略,但这并不是通过操作符重载机制实现的,而是通过特殊的函数签名和链接规则。对于类成员,可以定义newdelete操作符的特定版本,但这与全局newdelete的重载不同。

8. 析构函数相关

销毁过程的理解

在C++中,delete 操作符用于释放动态分配的内存(通过 new 分配)。当使用 delete 释放一个对象时,会执行以下操作:

  1. 调用析构函数:首先,会调用对象的析构函数。析构函数负责执行清理工作,如释放对象占用的资源(如动态分配的内存、文件句柄、网络连接等)。
  2. 释放内存:析构函数执行完毕后,delete 操作符会释放对象所占用的内存空间,使其可以被再次使用。

逆序析构成员:如果对象包含成员对象(无论是通过组合还是继承),这些成员对象的析构函数会在对象自身的析构函数执行之前,按照它们被声明的逆序被调用。对于继承体系,首先调用派生类的析构函数,然后是基类的析构函数,以此类推,直至最顶层的基类。

为什么析构函数中不能抛出异常?(不能是指“不应该”)

虽然C++标准并不直接禁止析构函数中抛出异常,但在析构函数中抛出异常通常被认为是一个坏主意,原因如下:

  • 异常安全保证:析构函数通常用于清理资源,如果在清理过程中抛出异常,可能会使程序处于不一致的状态,因为部分资源已经被释放,而另一部分还未处理。
  • 异常传播:如果析构函数抛出异常,并且这个异常没有被捕获,那么程序会调用 std::terminate() 立即终止。如果析构函数是在处理另一个异常的过程中被调用的(如在栈展开期间),这会导致异常处理代码无法正确执行,进一步加剧问题。
如果析构函数中包含可能抛出异常的代码怎么办?

如果析构函数中确实包含可能抛出异常的代码,应该采取以下措施来避免问题:

  • 避免异常:尽可能修改代码,使析构函数不会抛出异常。例如,使用不会失败的资源释放方法,或者捕获并处理可能抛出的异常。
  • 记录错误:如果无法避免异常,可以在析构函数中捕获异常并记录错误信息,而不是让异常传播出去。
可否通过对象或对象的引用(指针或引用)调用析构函数?

直接调用析构函数(如 obj.~ClassName())在C++中是合法的,但通常不推荐这样做,因为这绕过了正常的对象生命周期管理。析构函数通常通过 delete 操作符(对于动态分配的对象)或作用域结束(对于自动对象)自动调用。

通过引用或指针调用析构函数 实际上是不可能的,因为引用和指针本身不拥有对象,它们只是访问对象的途径。但是,你可以通过指针或引用访问对象,并间接地通过 delete 指针来调用其析构函数(如果对象是动态分配的)。

为什么将继承体系中基类的析构函数声明为虚函数?

在继承体系中,将基类的析构函数声明为虚函数是为了实现多态删除。当通过基类指针删除派生类对象时,如果基类的析构函数不是虚函数,那么只会调用基类的析构函数,而不会调用派生类的析构函数,这会导致资源泄露。通过将基类的析构函数声明为虚函数,可以确保在删除对象时,会先调用派生类的析构函数,然后是基类的析构函数,从而正确释放资源。

不应该将非继承体系中的类的虚函数声明为虚函数

在非继承体系中,将类的成员函数声明为虚函数是没有必要的,因为虚函数主要用于实现多态,而多态通常与继承相关。在非继承体系中,使用虚函数会增加额外的开销(如虚函数表),而这些开销是没有必要的。

不应该继承析构函数非虚的类

如果基类的析构函数不是虚函数,那么通常不建议从这个基类派生新的类,特别是如果这些派生类对象可能会通过基类指针被删除。这样做会导致资源泄露,因为只会调用基类的析构函数,而不会调用派生类的析构函数。

防止继承的方式

在C++中,有几种方式可以防止类被继承:

  • 将构造函数设为私有或删除:虽然这可以防止对象被实例化,但它并不直接阻止继承。然而,如果构造函数是私有的或删除的,并且没有提供公共或受保护的构造函数,那么继承的类将无法实例化其对象,这在某种程度上可以视为一种“阻止继承”的手段。
  • 使用 final 关键字:从C++11开始,可以使用 final 关键字来防止类被继承。将类声明为 final 或将虚函数声明为 final,可以明确指示该类或函数不应被继承或重写。
  • 使用静态断言:在某些情况下,可以使用静态断言(如 static_assert)来在编译时检查继承关系,但这并不是C++标准提供的直接防止继承的机制。

9. 删除的合成函数

在C++中,合成函数(也称为特殊成员函数)是编译器自动为类生成的成员函数,如果类中没有显式定义这些函数,且它们的使用是合法的(即根据类的定义需要它们),编译器就会为类生成它们。这些合成函数包括默认构造函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数。

关于“删除的合成函数”,这是指通过显式地在类定义中将这些合成函数声明为= delete;来阻止编译器自动生成它们,从而避免这些函数的调用。这在某些情况下是非常有用的,比如当类的某些操作在逻辑上不应该被允许时,或者当类拥有某些资源(如动态分配的内存、文件句柄等)且这些资源的管理需要特殊的逻辑时。

为什么要删除合成函数?
  1. 防止意外拷贝:对于管理了如动态内存、文件句柄、锁等资源的类,拷贝可能会导致资源被重复释放或共享不当。通过将拷贝构造函数和拷贝赋值运算符声明为= delete;,可以防止类的实例被意外拷贝。

  2. 明确语义:通过删除合成函数,可以明确表达类的设计意图,即哪些操作是允许的,哪些是不允许的。这有助于类的使用者理解如何使用该类。

  3. 避免编译器生成的默认行为:在某些情况下,编译器生成的默认行为可能不是你所期望的。通过显式删除这些函数,可以确保类的行为完全按照你的意图来。

示例
class NonCopyable {
protected:
    // 允许派生类访问构造函数
    NonCopyable() = default;
    ~NonCopyable() = default;

public:
    // 删除拷贝构造函数和拷贝赋值运算符
    NonCopyable(const NonCopyable&) = delete;
    NonCopyable& operator=(const NonCopyable&) = delete;

    // 移动构造函数和移动赋值运算符可以根据需要定义或删除
    // 例如,如果类也管理了不可移动的资源,则可以同样删除它们
    // NonCopyable(NonCopyable&&) = delete;
    // NonCopyable& operator=(NonCopyable&&) = delete;
};

class MyClass : public NonCopyable {
public:
    // MyClass 的其他成员函数...
};

int main() {
    MyClass obj1; // 允许
    // MyClass obj2(obj1); // 编译错误,拷贝构造函数被删除
    // obj1 = obj2; // 编译错误,拷贝赋值运算符被删除
}

在上面的例子中,NonCopyable类通过删除拷贝构造函数和拷贝赋值运算符来防止其被拷贝。这样,任何尝试拷贝NonCopyable类(或其派生类)实例的操作都将导致编译错误。这种模式(也称为“不可拷贝”或“不可复制”模式)在C++中非常常见,特别是在资源管理类和智能指针的实现中。

10. 继承相关

继承体系中的构造、拷贝、析构顺序

在C++中,继承体系中的对象构造、拷贝、析构顺序是严格规定的,以确保资源的正确管理。

  • 构造顺序:首先调用基类的构造函数(如果有多个基类,则按照基类声明的顺序调用),然后是成员对象的构造函数(按照成员声明的顺序调用),最后是派生类自身的构造函数。
  • 拷贝顺序(针对拷贝构造函数或拷贝赋值操作符):与构造顺序类似,但通常是在赋值或复制对象时考虑。对于拷贝构造函数或拷贝赋值操作符,如果手动实现,则需要确保正确复制或赋值基类部分和成员对象。
  • 析构顺序:与构造顺序相反,首先是派生类自身的析构函数被调用,然后是成员对象的析构函数(按照成员声明的逆序调用),最后是基类的析构函数(按照基类声明的逆序调用)。
继承中的名字查找

在C++中,继承引入了作用域嵌套的概念,使得子类可以访问父类的成员。名字查找(Name Lookup)遵循以下规则:

  • 从子类到父类查找:如果子类中有某个名字的定义,则直接使用子类的定义;如果没有,则查找基类,直到找到为止或到达最顶层的基类。
  • 作用域规则:成员名字的处理首先在当前作用域(即子类)中查找,如果找不到,则向基类中查找,以此类推。
成员函数体内、成员函数的参数列表的名字解析时机
  • 成员函数体内:名字查找首先在当前函数作用域内进行,然后是类作用域,接着是外围作用域(如全局作用域)。
  • 成员函数的参数列表:参数列表中的名字仅在参数列表本身中可见,它们不会与成员函数体内的局部变量或类成员发生冲突。

注意:内嵌的类型声明(如typedefusing声明等)应该放在类的起始处,以确保在类的其他部分(如成员函数体)中可见。

同名名字隐藏
  • 问题:当派生类和基类有同名成员时,派生类的成员会隐藏基类的同名成员。
  • 解决方法
    • 使用域作用符:通过基类名::成员名的方式来访问基类的成员。
    • using声明:在派生类中使用using声明来引入基类的成员,这样派生类和基类中的同名成员就都可以被访问了。
    • 避免命名冲突:最佳实践是避免在继承体系中定义同名的成员,以减少混淆和错误。

注意:不同作用域中的同名函数不能构成重载,因为它们处于不同的作用域中。using声明可以将基类的成员引入到派生类的作用域中,从而允许重载。

虚继承
  • 解决的问题:虚继承主要用于解决多继承中的子对象冗余问题。在多继承中,如果两个或多个基类共享一个公共基类,则在不使用虚继承的情况下,派生类中将包含多个该公共基类的实例,导致资源浪费和可能的逻辑错误。
  • 工作原理:通过虚继承,公共基类在派生类中的实例被共享。即,无论派生类通过多少条路径继承该公共基类,它都只在派生类中有一个实例。这通过在继承声明中使用virtual关键字来实现。

虚继承增加了额外的复杂性和开销(如虚基类表),因此只应在必要时使用。

11. 多态的实现?

在C++中,多态性(Polymorphism)是一种允许通过基类指针或引用来调用派生类(子类)中成员函数的能力。多态性主要通过虚函数(Virtual Functions)实现,但也涉及到抽象基类(Abstract Base Classes)和纯虚函数(Pure Virtual Functions)等概念。

1. 虚函数

虚函数是实现多态的基石。当一个类中的成员函数被声明为virtual时,它就可以在派生类中被重写(Override)。通过基类指针或引用调用虚函数时,会根据对象的实际类型(运行时类型)来调用相应的函数版本,而不是根据指针或引用的静态类型。

示例代码

class Base {
public:
    virtual void show() {
        cout << "Base class show" << endl;
    }
    virtual ~Base() {} // 虚析构函数,确保基类指针指向派生类对象时能够正确析构
};

class Derived : public Base {
public:
    void show() override { // C++11 引入的 override 关键字,用于明确表示该函数是重写基类的虚函数
        cout << "Derived class show" << endl;
    }
};

int main() {
    Base* b = new Derived();
    b->show(); // 输出:Derived class show
    delete b;
    return 0;
}
2. 抽象基类与纯虚函数

当基类中有一个或多个纯虚函数时,该类成为抽象基类。抽象基类不能被实例化,但可以作为其他类的基类,强制派生类实现纯虚函数。纯虚函数在基类中没有实现体,通常定义为virtual ReturnType FunctionName() = 0;

示例代码

class AbstractBase {
public:
    virtual void pureVirtualFunction() = 0; // 纯虚函数
    virtual ~AbstractBase() {} // 虚析构函数
};

class DerivedFromAbstract : public AbstractBase {
public:
    void pureVirtualFunction() override {
        cout << "DerivedFromAbstract's pureVirtualFunction" << endl;
    }
};

int main() {
    // AbstractBase* ab = new AbstractBase(); // 错误,不能实例化抽象基类
    AbstractBase* ab = new DerivedFromAbstract();
    ab->pureVirtualFunction(); // 调用派生类的实现
    delete ab;
    return 0;
}
3. 多态的实现机制

C++通过虚函数表(Virtual Function Table, VTable)和虚函数指针(Virtual Function Pointer, VPtr)来实现多态。每个包含虚函数的类都有一个虚函数表,表中存储了类中所有虚函数的地址。每个对象(对于包含虚函数的类)都有一个指向其类虚函数表的指针,这个指针通常隐藏地存储在对象中。当通过基类指针或引用调用虚函数时,程序实际上是通过这个虚函数指针找到虚函数表中相应函数的地址,然后调用该函数。

4. 结论

多态性是面向对象编程中的一个核心概念,它允许程序以统一的方式处理不同的对象类型,增强了程序的灵活性和可扩展性。在C++中,多态性主要通过虚函数实现,同时涉及到抽象基类和纯虚函数等概念。理解多态性的实现机制对于深入掌握C++的面向对象编程至关重要。

12. 虚函数的实现原理及对类大小的影响

虚函数的实现原理

在C++中,虚函数是实现多态性的关键机制。当一个类中含有虚函数时,编译器会为这个类生成一个特殊的表,称为虚函数表(Virtual Table, 简称vtbl)。这个表是一个由函数指针组成的数组,每个函数指针指向该类中对应的虚函数的实现。

  • 虚函数表(vtbl):每个包含虚函数的类都有一个虚函数表。虚函数表中的每个条目都是一个指向类成员函数的指针。如果子类重写了父类的虚函数,则子类虚函数表中的相应条目会指向子类的函数实现,而不是父类的。

  • 虚函数指针(vptr):编译器还会在每个包含虚函数的类的对象中插入一个隐藏的指针,称为虚函数指针(vptr)。这个指针指向对象的虚函数表。通过这个指针,程序可以在运行时确定对象的实际类型,并调用相应类型的虚函数实现。

  • 调用机制:当通过基类指针或引用来调用虚函数时,程序不会直接调用函数,而是首先通过对象的vptr访问虚函数表,然后根据虚函数表中的函数指针找到并调用正确的函数实现。

对类大小的影响

由于每个包含虚函数的类的对象都需要存储一个指向虚函数表的vptr,因此这会增加对象的大小。具体来说,对象大小增加的量取决于指针的大小,这取决于编译器和目标平台(32位或64位)。

  • 在32位系统上,一个指针通常是4字节,因此每个包含虚函数的类的对象都会额外增加4字节来存储vptr。
  • 在64位系统上,一个指针通常是8字节,因此每个对象将额外增加8字节。

需要注意的是,这个额外的空间只影响包含虚函数的类的对象,而不影响不包含虚函数的类的对象。此外,如果类继承自一个包含虚函数的基类,那么即使子类没有定义自己的虚函数,其对象也会包含vptr,因为子类对象可以调用从基类继承的虚函数。

总结

虚函数的实现依赖于虚函数表和虚函数指针,这使得C++能够支持多态性。然而,这种机制也增加了对象的大小,因为每个包含虚函数的类的对象都需要存储一个指向虚函数表的指针。这种开销在大多数应用场景下是可以接受的,因为多态性带来的灵活性和扩展性远远超过了这点额外的内存开销。

13. 为什么不要在构造、析构函数中调用虚函数?

(子对象的 base class 构造期间,对象的类型是 base class 《Effective C++:条款 9》,设置虚函数指针的时机)

在C++中,构造和析构函数内调用虚函数是一个需要谨慎处理的场景,因为这涉及到对象的类型和虚函数表(vtable)的初始化和销毁过程。下面详细解释为什么不建议在构造和析构函数中调用虚函数,以及这背后的原因。

为什么不要在构造函数中调用虚函数?
  1. 对象类型未完全形成
    在构造过程中,对象的基类部分首先被构造,然后是派生类部分。当基类构造函数执行时,派生类部分尚未被初始化。此时,如果基类构造函数中调用了虚函数,由于对象的实际类型(派生类类型)尚未形成,调用的是基类中定义的虚函数版本,而不是派生类中可能重写的版本。这违反了多态的预期行为,可能导致不正确的逻辑执行。

  2. 虚函数表(vtable)的初始化时机
    对象的虚函数表指针(vptr)在构造过程中被设置。但是,在基类构造函数执行时,vptr 指向的是基类的 vtable,即使最终对象是一个派生类对象。这意味着虚函数调用将解析为基类中的实现,而不是派生类中的重写版本。

为什么不要在析构函数中调用虚函数?
  1. 资源释放的潜在问题
    析构函数的主要目的是释放对象占用的资源。如果在析构函数中调用虚函数,并且这个虚函数在派生类中被重写以执行特定于派生类的清理工作,这通常是安全的。然而,需要确保派生类的析构函数在执行任何依赖于虚函数的行为之前已经完成了所有必要的清理工作,以避免访问已经释放的资源。

  2. 基类析构函数的责任
    尽管在析构函数中调用虚函数不一定导致错误,但通常推荐将清理逻辑放在派生类的析构函数中,并在基类析构函数中调用虚析构函数(如果基类设计为支持多态删除)。这样做可以确保无论对象的实际类型如何,都会按照正确的顺序(从派生类到基类)释放资源。

注意事项
  • 确保虚析构函数:如果你打算通过基类指针删除派生类对象,那么基类必须有一个虚析构函数。这确保了删除对象时会调用正确的析构函数序列。

  • 避免在构造和析构函数中执行复杂逻辑:尽可能保持构造和析构函数的简单性,以减少出错的可能性。将复杂的初始化逻辑放在成员初始化列表中或单独的函数中,将清理逻辑放在单独的析构函数中(特别是当这些逻辑可能依赖于虚函数时)。

  • 理解对象的生命周期:对C++中对象的构造和析构过程有深入的理解,有助于编写更加健壮和可维护的代码。

综上所述,虽然在某些情况下在构造和析构函数中调用虚函数可能是必要的,但通常应避免这种做法,因为它们可能导致不可预测的行为和潜在的错误。

14. 虚函数被覆盖?

在C++中,虚函数(Virtual Function)被设计用于实现多态性,它允许通过基类指针或引用来调用派生类中的函数。当我们在派生类中重新定义了一个在基类中已经被声明为虚函数的成员函数时,我们说这个虚函数在派生类中被“覆盖”(Override)或“重写”(Overwrite)。这一过程有几个关键点需要注意:

1. 虚函数的定义

在基类中,虚函数通过在函数声明前加上virtual关键字来标识。这意味着这个函数在派生类中可能会被重新定义。例如:

class Base {
public:
    virtual void show() {
        std::cout << "Base class show" << std::endl;
    }
};
2. 覆盖虚函数

在派生类中,当定义一个与基类虚函数同名、同参数列表(也称为签名相同)的函数时,派生类的这个函数会覆盖基类的虚函数。覆盖后的函数成为实际被调用的函数,这取决于对象的实际类型(动态类型),而不是其引用或指针的静态类型。例如:

class Derived : public Base {
public:
    void show() override { // 使用override关键字是C++11的新特性,非必需但推荐使用,以提高代码的可读性和检查潜在的错误
        std::cout << "Derived class show" << std::endl;
    }
};
3. 覆盖的关键点
  • 函数签名必须相同:包括函数名、返回类型(协变返回类型除外)、参数列表都必须完全相同。
  • 访问修饰符:派生类中的覆盖函数可以有不同的访问修饰符(如publicprotectedprivate),但这会影响函数的可访问性,而不影响覆盖行为本身。
  • 使用override关键字:在C++11及之后的版本中,推荐使用override关键字显式表示该函数意图覆盖基类中的虚函数。这不仅有助于阅读代码,而且编译器还会检查该函数是否确实覆盖了基类中的虚函数,减少因拼写错误等原因导致的错误。
  • 析构函数:如果基类有一个虚析构函数,那么当通过基类指针删除派生类对象时,将首先调用派生类的析构函数,然后调用基类的析构函数,从而保证对象被正确销毁。
4. 注意事项
  • 虚函数只能覆盖虚函数。如果基类中的函数不是虚函数,派生类中的同名函数会隐藏基类中的函数,而不是覆盖它。
  • 覆盖函数的实现可以完全不同于基类中的实现,只要它们遵循上述的规则。

通过上述的解答,我们可以看到虚函数覆盖是C++多态性的一个核心特性,它允许在运行时根据对象的实际类型来确定调用的函数,从而使得面向对象的设计更加灵活和强大。

15. virtual 函数动态绑定,缺省参数值静态绑定(《Effective C++:条款 37》)

在C++中,virtual 函数与缺省参数值之间的行为差异主要体现在它们的绑定时机上,这是理解《Effective C++》中条款37“绝不要重新定义继承而来的缺省参数值”的关键。具体来说,virtual 函数的调用是动态绑定的,而函数参数的缺省值则是在编译时静态绑定的。

virtual 函数动态绑定

当一个类中包含virtual函数时,这个函数的调用将在运行时根据对象的实际类型(而不是指针或引用的类型)来确定应该调用哪个函数。这允许在派生类中重写基类中的virtual函数,并通过基类指针或引用来调用派生类的函数实现。动态绑定的基础是虚函数表(vtable),它存储了每个虚函数的地址,允许在运行时确定具体的函数实现。

缺省参数值静态绑定

virtual函数的动态绑定不同,函数参数的缺省值是在编译时确定的,具体取决于函数调用表达式的上下文。这意味着如果基类中的函数有缺省参数值,并且这个函数在派生类中被重写但没有重新定义缺省参数值,那么在通过基类指针或引用来调用这个函数时,即使实际上调用的是派生类的函数实现,使用的缺省参数值仍然是基类定义时的值。

为什么不应该重新定义继承而来的缺省参数值
  1. 行为不一致:由于缺省参数值是静态绑定的,而函数体是动态绑定的,这会导致行为上的不一致。开发者可能期望在调用重写函数时使用派生类定义的缺省参数值,但实际上使用的是基类的值。

  2. 维护困难:当基类中的缺省参数值改变时,所有继承这个基类的派生类都需要更新它们的函数签名(如果它们依赖于这些缺省参数值),否则可能会出现意外的行为。

  3. 难以理解:对于不熟悉代码库的人来说,理解这种静态绑定和动态绑定混合使用的方式可能会很困难,特别是当基类和派生类分布在不同的文件中时。

解决方案

如果需要在派生类中改变函数的缺省参数值,通常的做法是不要在基类中为这些参数提供缺省值,而是让调用者显式地提供所有参数。如果确实需要缺省参数值,并且这些值在派生类中会有所不同,那么应该考虑使用重载函数或不同的函数名来避免混淆。

总之,理解virtual函数动态绑定和缺省参数值静态绑定的差异是编写可维护和可理解C++代码的关键。在设计类和继承体系时,应该仔细考虑这些差异,并避免在不适当的场合下混合使用它们。

16. 纯虚函数与抽象基类

在C++中,纯虚函数和抽象基类是面向对象编程中非常重要的概念,它们允许创建一组具有共同接口但具体实现可能不同的类。以下是对这些问题的详细解答:

1. 纯虚函数与虚函数的区别

虚函数(Virtual Function)

  • 虚函数是基类中声明为virtual的成员函数。
  • 它允许在派生类中被重写(Override),以提供特定于派生类的实现。
  • 如果基类中的虚函数在派生类中没有重写,则调用该派生类对象的虚函数时,会执行基类中的函数。
  • 包含至少一个虚函数的类被称为多态类。

纯虚函数(Pure Virtual Function)

  • 纯虚函数在基类中声明为virtual后跟= 0
  • 它没有函数体,只有函数声明。
  • 继承自含有纯虚函数的类的派生类必须提供该纯虚函数的实现(除非派生类也被声明为抽象类),否则派生类也将成为抽象类。
  • 抽象类(含有纯虚函数的类)不能直接实例化对象,因为它至少有一个未实现的成员。
2. 纯虚函数与一般成员函数的选择

选择纯虚函数还是一般成员函数,主要取决于你的设计需求:

  • 如果你希望基类作为一个接口,仅定义方法签名,而让派生类提供具体的实现,那么应该使用纯虚函数。这样做可以确保所有派生类都实现了这些方法,从而保证了接口的一致性。

  • 如果你希望在基类中提供方法的默认实现,并且这个实现在某些派生类中可能被重写,那么应该使用虚函数。虚函数允许基类提供一个基本的实现,同时允许派生类根据需要修改这个行为。

3. 虚函数与纯虚函数的选择
  • 如果你希望类可以被实例化,即使它的某些成员函数在派生类中可能有不同的实现,那么这些函数应该被声明为虚函数。

  • 如果你希望类不能被实例化,而是作为一组派生类的共同接口,那么应该在该类中声明至少一个纯虚函数。这样做会使类成为抽象基类,从而阻止其实例化。

4. 示例
class Base {
public:
    virtual void display() { // 虚函数
        cout << "Base display" << endl;
    }

    virtual void pureVirtualDisplay() = 0; // 纯虚函数
};

class Derived : public Base {
public:
    void display() override { // 重写虚函数
        cout << "Derived display" << endl;
    }

    void pureVirtualDisplay() override { // 必须提供纯虚函数的实现
        cout << "Derived pureVirtualDisplay" << endl;
    }
};

// Base b; // 错误,Base是抽象类,不能直接实例化
Derived d; // 正确,Derived不是抽象类,可以实例化

在上面的示例中,Base类是一个抽象基类,因为它包含了一个纯虚函数pureVirtualDisplay。而Derived类通过提供pureVirtualDisplay的实现来继承Base,从而不是抽象类,可以被实例化。同时,Derived类也重写了Base中的虚函数display

17. 静态类型与动态类型(引用是否可实现动态绑定)

静态类型与动态类型的概念

静态类型(Static Typing)

  • 在编译时确定变量的类型。
  • 编译器需要知道所有变量的类型以及它们之间的操作是否合法。
  • 静态类型语言(如C++、Java)在编译时进行类型检查,这有助于减少运行时错误。

动态类型(Dynamic Typing)

  • 变量的类型在运行时确定。
  • 动态类型语言(如Python、JavaScript)在运行时检查变量类型和操作的合法性。
  • 这允许编写更灵活但可能难以调试的代码,因为类型错误可能在运行时才显现。
引用与动态绑定

在C++中,引用 本身是一个别名,它代表了一个已经存在的对象。引用在声明时必须被初始化,并且一旦被初始化,就不能再改变为引用另一个对象。引用的类型在编译时确定,并且这个类型在整个引用的生命周期内保持不变。因此,引用本身不支持直接的动态类型绑定

然而,C++中多态虚函数的概念可以实现一种“类似动态绑定”的效果,但这并不是通过引用本身实现的,而是通过引用所指向的对象的实际类型来决定的。

  • 多态:允许通过基类指针或引用来调用派生类中的重写函数。这种能力是通过虚函数表(vtable)实现的,每个包含虚函数的类都有一个虚函数表,它存储了类中所有虚函数的地址。
  • 虚函数:当通过基类指针或引用调用虚函数时,会根据指针或引用实际指向的对象的类型,来动态地选择调用哪个函数(即对象的实际类型中的虚函数实现)。
示例
class Base {
public:
    virtual void func() {
        std::cout << "Base::func()" << std::endl;
    }
    virtual ~Base() {}
};

class Derived : public Base {
public:
    void func() override {
        std::cout << "Derived::func()" << std::endl;
    }
};

int main() {
    Base* basePtr = new Derived();
    Base& baseRef = *basePtr;

    // 调用通过引用实现的多态
    baseRef.func();  // 输出: Derived::func()

    delete basePtr;
    return 0;
}

在这个例子中,baseRef 是一个对 Base 类型的引用,但它实际上引用了一个 Derived 类型的对象。通过 baseRef 调用 func() 时,尽管 baseRef 的静态类型是 Base,但调用的函数是 Derived 类中重写的 func(),这实现了动态绑定。然而,这种动态绑定是通过虚函数和对象的实际类型来实现的,而不是通过引用本身。

18. 浅拷贝与深拷贝(安全性、行为像值的类与行为像指针的类)

在C++中,理解浅拷贝(Shallow Copy)与深拷贝(Deep Copy)的概念对于管理资源、防止数据泄露和避免悬垂指针等问题至关重要。以下是对这两个概念及其在行为像值的类与行为像指针的类中的安全性和行为的详细解释。

浅拷贝(Shallow Copy)

定义:浅拷贝只是复制了对象中的数据成员的值,如果数据成员中包含指针,则仅仅复制指针的值(即内存地址),而不是指针所指向的内存区域。

安全性

  • 对于行为像值的类(即数据成员不包含指针或只包含不指向动态分配内存的指针),浅拷贝通常是安全的。
  • 对于行为像指针的类(即数据成员包含指向动态分配内存的指针),浅拷贝可能导致多个对象共享同一块内存区域,进而引发资源泄露(因为同一块内存可能被多次释放)或数据不一致(当一个对象修改了共享的数据时)。

行为

  • 浅拷贝后,如果原始对象和拷贝对象中有指针指向动态分配的内存,它们将指向同一块内存区域。
  • 如果其中一个对象修改了这块内存区域,另一个对象也会受到影响。
深拷贝(Deep Copy)

定义:深拷贝不仅复制了对象中的数据成员的值,对于指针类型的数据成员,还会在堆上分配新的内存区域,并将原始指针指向的内容复制到新分配的内存中,最后更新拷贝对象的指针,使其指向新的内存区域。

安全性

  • 深拷贝保证了每个对象都拥有自己独立的资源副本,避免了资源泄露和数据不一致的问题。
  • 对于行为像指针的类,深拷贝是必需的,以确保对象的完整性和独立性。

行为

  • 深拷贝后,原始对象和拷贝对象中的数据成员(包括指针)在内存中是独立的。
  • 修改其中一个对象的内存区域不会影响另一个对象。
实现深拷贝的方法
  1. 拷贝构造函数:为类提供一个拷贝构造函数,在该构造函数中,对于所有指针类型的数据成员,进行深拷贝操作。

  2. 赋值运算符重载:当类的对象通过赋值操作符=赋值时,也需要实现深拷贝。这通常通过“拷贝-交换”技术(Copy-and-Swap)或直接在赋值运算符重载函数中实现深拷贝来完成。

  3. 智能指针:在C++11及更高版本中,可以使用std::unique_ptrstd::shared_ptr等智能指针来自动管理资源,减少深拷贝的需求。这些智能指针通过自动释放资源来防止内存泄露,并通过内部机制处理多个智能指针指向同一块内存的问题。

总之,对于行为像值的类,浅拷贝通常是足够的;但对于行为像指针的类,为了避免资源泄露和数据不一致,应该使用深拷贝。在实际开发中,需要根据类的设计目的和内存管理需求来选择合适的拷贝策略。

19. 如何定义类内常量?(enum 而不是 static const 《Effective C++:条款 2》)

在C++中,定义类内常量时,虽然static const是一个常用的方法,但在某些情况下,特别是当你想要限制作用域、提高代码清晰性或遵循特定编码风格时,使用enum类(C++11及以后版本中的枚举类,也称为强类型枚举或作用域枚举)可能是一个更合适的选择,尤其是在遵循《Effective C++》中的建议时。

使用enum定义类内常量

在C++11及之后的版本中,你可以使用枚举类(enum class)来定义类内常量,这样做有几个好处:

  1. 作用域限制:枚举类的成员默认是enum类作用域内的,这意味着你需要通过枚举类名来限定其成员,这有助于减少命名冲突。
  2. 类型安全:枚举类提供了类型安全,因为它们是强类型的,不能隐式地转换为整数或其他枚举类型。
  3. 清晰的语义:枚举成员可以赋予更清晰的语义,使得代码更加易读。
示例

假设你有一个Circle类,你想要在类内部定义一些常量来表示不同的属性或状态,比如圆周率的近似值:

class Circle {
public:
    // 使用enum class定义类内常量
    enum class Constants {
        Pi = 3.14159
    };

    // 注意:你不能直接通过Constants::Pi获取浮点数值,因为Constants::Pi是一个枚举值
    // 如果你需要获取这个值,你可以这样做:
    static constexpr double piValue() {
        return static_cast<double>(Constants::Pi);
    }

    // 示例函数,使用piValue
    double getCircumference(double radius) const {
        return 2 * piValue() * radius;
    }

private:
    // 类的其他成员...
};

// 使用示例
int main() {
    Circle c;
    std::cout << "Circumference of a circle with radius 5 is: " << c.getCircumference(5) << std::endl;
    return 0;
}

然而,需要注意的是,上面的例子中enum class Constants实际上并没有直接提供一个可以使用的浮点数值常量,因为枚举成员默认是整数类型的。如果你需要浮点数值,通常的做法是像上面那样提供一个静态成员函数来返回这个值,或者简单地在类内部使用static constexpr定义常量:

class Circle {
public:
    // 使用static constexpr定义类内常量
    static constexpr double Pi = 3.14159;

    double getCircumference(double radius) const {
        return 2 * Pi * radius;
    }

private:
    // 类的其他成员...
};

// 使用示例
int main() {
    Circle c;
    std::cout << "Circumference of a circle with radius 5 is: " << c.getCircumference(5) << std::endl;
    return 0;
}

在这个例子中,static constexpr double Pi直接定义了一个可在类内部和外部使用的浮点常量,这更符合通常意义上“定义类内常量”的需求。然而,使用enum class在特定情况下(如需要限制作用域或提供额外的类型安全)仍然是一个有用的选择。

20. 继承与组合(复合)之间如何选择?(《Effective C++:条款 38》)

在C++中,继承(Inheritance)和组合(Composition,也常称为复合)是两种基本的代码复用和构建复杂系统的技术。选择哪一种机制主要取决于你的设计目标、类的关系以及你希望达到的代码结构和可维护性。《Effective C++》的条款38强调了理解并正确选择这两者的重要性。以下是对如何在这两者之间进行选择的详细解答:

继承(Inheritance)

定义:继承是面向对象编程中的一种基本特性,允许我们定义一个类(子类或派生类)来继承另一个类(基类或父类)的属性和方法。子类可以重用父类的代码,并可以添加或覆盖(override)父类的方法。

适用场景

  • “是一种”(Is-A)关系:当子类确实是父类的一个特殊化版本时,应该使用继承。例如,DogAnimal的一个特殊类型。
  • 多态性:当你需要实现多态行为时,即允许通过基类指针或引用来调用派生类的方法时,继承是必要的。
  • 代码复用:如果多个类共享相似的属性和方法,并且这些属性和方法是通过继承关系紧密相关的,则继承是合理的选择。

缺点

  • 继承打破了封装性,因为子类可以访问和修改父类的私有(protected)成员。
  • 继承关系可能导致过深的类层次结构,从而增加代码的复杂性和维护难度。
  • 继承使得类的耦合度增加,不利于代码的灵活性和扩展性。
组合(Composition)

定义:组合是一种将对象嵌入到另一个对象中的技术,使得嵌入的对象成为容器对象的一部分。组合通常通过包含另一个类的实例作为成员变量来实现。

适用场景

  • “有一个”(Has-A)关系:当一个类包含另一个类的对象时,应该使用组合。例如,Car有一个Engine
  • 数据封装:组合可以更好地封装数据,因为被组合的对象(成员变量)的接口和内部实现细节对外部是隐藏的。
  • 灵活性和低耦合:组合关系允许在运行时动态地改变组合对象的成员,从而提供了更高的灵活性和更低的类间耦合。

优点

  • 更好的封装性:组合对象对外部隐藏了其内部实现。
  • 更高的灵活性:组合关系可以在运行时动态地变化,而继承关系在编译时就确定了。
  • 更低的耦合度:使用组合的对象间依赖关系较弱,更易于修改和维护。
如何选择
  • 考虑“是一种”还是“有一个”的关系:如果类之间存在“是一种”的关系,并且需要多态行为,则使用继承。如果类之间存在“有一个”的关系,则使用组合。
  • 考虑代码的复用性和扩展性:如果主要是为了实现代码复用,并且类之间的关系较为紧密,则可以考虑继承。但如果需要更高的灵活性和扩展性,组合通常是更好的选择。
  • 考虑类的职责和封装性:确保类的设计符合单一职责原则,并且保持高内聚低耦合。组合通常能更好地支持这些原则。

总之,在继承与组合之间做出选择时,需要综合考虑设计目标、类的关系、代码的复用性、灵活性、扩展性以及维护的难易程度。正确的选择可以显著提高代码的质量和可维护性。

21. private 继承?(《Effective C++:条款 39》)

在C++中,private 继承是一种特殊的继承方式,它与其他两种继承方式(public 继承和 protected 继承)在访问控制上有显著的区别。理解 private 继承的含义和用途,特别是在阅读《Effective C++》的条款39时,对于深入理解C++的继承机制和封装性至关重要。

private 继承的基本特点
  1. 访问控制

    • private 继承中,基类的公有(public)和保护(protected)成员在派生类中都将变成私有(private)成员。这意味着这些成员只能通过派生类的成员函数(包括友元函数)来访问,而不能从派生类对象直接访问,也不能被派生类的派生类访问。
  2. 用途

    • private 继承主要用于实现(implementation inheritance),而不是接口继承(interface inheritance)。当你想复用基类的实现,但不想将这种实现作为派生类接口的一部分时,可以使用 private 继承。
    • 它允许派生类“拥有”一个基类的实例,但这个实例的接口不会暴露给派生类的用户或进一步的派生类。
  3. 与组合的区别

    • 虽然 private 继承在技术上可以看作是一种特殊的组合(即包含了一个基类的实例),但它们的语义和使用场景有所不同。组合通常用于表示“有一个”的关系,而 private 继承更多用于表示“是一个”的关系,但同时又想隐藏这种关系的接口。
    • 组合通常通过成员对象来实现,而 private 继承则通过继承机制来实现。
示例
class Base {
public:
    void publicFunc() { /* ... */ }
protected:
    void protectedFunc() { /* ... */ }
};

class Derived : private Base {
public:
    void callPublicFunc() {
        publicFunc();  // 可以访问,因为是在Derived的成员函数中
    }

    // 尝试在Derived中声明publicFunc()或protectedFunc()为public或protected将失败
    // 因为它们在Derived中已经是private的
};

int main() {
    Derived d;
    // d.publicFunc();  // 错误,publicFunc在Derived中是private的
    d.callPublicFunc(); // 正确,通过Derived的公有成员函数访问
    return 0;
}
《Effective C++》条款39的解读

条款39(可能的具体内容会有所不同,因为《Effective C++》有多个版本,但核心概念相似)可能会强调使用 private 继承时要小心,因为它会隐藏基类的接口,这可能会导致意料之外的访问控制问题。它可能建议:

  • 当你只是想复用基类的实现时,使用 private 继承。
  • 确保理解 private 继承如何改变基类成员的访问权限。
  • 考虑是否有更好的设计方式,比如使用组合或者 protected 继承(如果基类的一部分接口确实应该在派生类中公开)。
  • 记住 private 继承的派生类对象不能用作基类对象的替代品,因为它们没有共同的接口。

总之,private 继承是C++中一种强大的特性,但使用时需要谨慎,以确保设计的清晰性和代码的可维护性。

22. 如何定义一个只能在堆上(栈上)生成对象的类?

在C++中,定义一个只能在堆上或只能在栈上生成对象的类,通常涉及到对类的构造函数和析构函数进行特定的设计,以及利用一些C++的特性(如智能指针、禁用拷贝/赋值等)来限制对象的创建方式。然而,直接限制一个类只能在栈上或堆上创建并不是C++语言本身直接支持的功能,但可以通过一些技术手段来间接实现。

只能在堆上生成对象的类

要在堆上创建对象,通常是通过new操作符来完成的。要限制一个类只能在堆上创建,可以将其构造函数设为私有,并提供一个静态成员函数来返回指向该类对象的指针(通常是通过new分配的)。这样,外部代码就无法直接调用构造函数来在栈上创建对象了。

class HeapOnly {
private:
    // 私有构造函数,防止外部直接创建对象
    HeapOnly() {}

    // 禁止拷贝构造函数和赋值运算符,防止通过拷贝在栈上创建对象
    HeapOnly(const HeapOnly&) = delete;
    HeapOnly& operator=(const HeapOnly&) = delete;

public:
    // 静态成员函数,用于在堆上创建对象
    static HeapOnly* createInstance() {
        return new HeapOnly();
    }

    // 提供一个虚析构函数,以便能够安全地删除通过基类指针指向的派生类对象
    virtual ~HeapOnly() {}

    // 其他成员函数...
};

// 使用示例
auto objPtr = HeapOnly::createInstance();
// ... 使用objPtr ...
delete objPtr; // 显式删除堆上分配的对象
只能在栈上生成对象的类

直接在C++中定义一个只能在栈上创建的类是不可能的,因为C++没有直接的机制来阻止使用new操作符。但是,你可以通过使类不可被赋值、拷贝,并且不提供任何形式的动态内存分配(如不提供new操作符的包装函数),来间接地鼓励开发者仅在栈上创建对象。然而,这并不能完全阻止开发者使用new来创建对象。

一个更实际的方法是,通过设计来避免在堆上创建对象的需要,而不是强制限制。但是,如果你确实想要一个“栈上专用”的类,你可以通过不提供任何形式的动态内存分配接口(如前面提到的私有构造函数和静态创建函数,但这里不实现静态创建函数),并清楚地在文档中说明这个类的使用方式。

然而,需要注意的是,即使你尝试通过设计来限制对象的创建位置,C++的灵活性仍然允许开发者通过一些非标准或技巧性的方式来绕过这些限制(例如,通过指针解引用或类型转换)。因此,这种限制更多是一种约定或最佳实践,而不是一种强制性的语言特性。

23. 内联函数、构造函数、静态成员函数可以是虚函数吗?

对于C++岗位面试中提出的关于内联函数、构造函数、静态成员函数是否可以是虚函数的问题,我将给出更详细的解答:

1. 内联函数

结论:内联函数不可以是虚函数。

详细解释

  • 内联函数的定义:内联函数是C++中为了提高函数调用的效率而引入的一种机制。编译器在编译时尝试将内联函数的调用替换为函数体本身,以减少函数调用的开销。
  • 虚函数的定义:虚函数是C++中实现多态的一种机制。它允许在运行时根据对象的实际类型来选择调用哪个版本的函数。
  • 不兼容原因:内联函数需要在编译时确定其代码,以便进行内联替换。然而,虚函数的调用是在运行时根据对象的实际类型来确定的,这导致编译器在编译时无法知道将调用哪个版本的函数,因此无法将虚函数调用内联化。
2. 构造函数

结论:构造函数不可以直接作为虚函数,但可以通过其他方式实现类似多态的效果。

详细解释

  • 构造函数的定义:构造函数是一种特殊的成员函数,用于在创建对象时初始化对象。
  • 不能直接作为虚函数的原因
    • 构造函数在对象被创建时调用,而此时对象的类型已经确定,因此没有必要通过虚函数机制来动态选择构造函数。
    • 虚函数表(vtable)通常是在构造函数内部设置的,如果构造函数是虚的,那么在调用构造函数时就需要知道对象的实际类型来确定调用哪个构造函数,这在逻辑上是矛盾的,因为对象此时还未完全构造。
  • 实现类似多态的方式:虽然构造函数本身不能是虚的,但可以通过其他设计模式(如工厂模式、依赖注入等)在运行时动态选择对象的类型并构造它,从而实现类似多态的效果。
3. 静态成员函数

结论:静态成员函数不可以是虚函数。

详细解释

  • 静态成员函数的定义:静态成员函数属于类本身,而不是类的某个特定实例。它不依赖于任何对象实例的状态,因此没有this指针。
  • 虚函数的定义:虚函数是与对象实例相关联的,它允许在运行时根据对象的实际类型来选择调用哪个版本的函数。
  • 不兼容原因:由于静态成员函数与对象实例无关,因此它们不能是虚函数。虚函数的机制依赖于对象的实际类型,而静态成员函数则没有这个概念。

综上所述,内联函数、构造函数和静态成员函数都不能是虚函数,这是因为它们的定义和用途与虚函数的机制和设计目标不兼容。在C++中,应该根据具体的需求和场景来选择合适的函数类型和实现方式。

虚函数和虚函数表在C++中是紧密相关的概念,它们共同支持C++中的多态性特性。以下是对两者关系的详细解释:


虚函数

虚函数是在基类中被声明为virtual的函数,其目的是允许在派生类中重新定义该函数,并且可以通过基类指针或引用来访问派生类中定义的同名函数。当通过基类指针或引用调用虚函数时,会根据指针或引用实际指向的对象类型,动态地选择调用相应的函数实现,这就是多态性的体现。

虚函数表(Virtual Table,简称V-Table)

虚函数表是C++中用于实现虚函数机制的一个数据结构,它是一个函数指针数组,每个单元用来存放虚函数的地址。当一个类包含虚函数时,编译器会为该类创建一个虚函数表,并在类的每个对象中添加一个虚函数表指针(vptr),该指针指向该类对应的虚函数表。虚函数表位于只读数据段(.rodata),即C++内存模型中的常量区,而虚函数代码则位于代码段(.text),即C++内存模型中的代码区。

虚函数与虚函数表的关系
  1. 虚函数表的创建

    • 在编译过程中,编译器会为包含虚函数的类创建虚函数表。
    • 虚函数表包含了类中所有虚函数的地址,按照虚函数在类中声明的顺序排列。
  2. 虚函数表指针的添加

    • 对于每个包含虚函数的类的对象,编译器会在其内存布局中添加一个虚函数表指针(vptr)。
    • 这个指针指向对象所属类的虚函数表,使得对象能够知道如何调用其虚函数。
  3. 虚函数的调用

    • 当通过基类指针或引用调用虚函数时,编译器会先通过虚函数表指针找到对应的虚函数表。
    • 然后在虚函数表中查找要调用的虚函数的地址。
    • 最后,通过该地址调用相应的虚函数实现。
  4. 多态性的实现

    • 如果派生类重写了基类的虚函数,那么派生类对象的虚函数表中对应的虚函数地址会被替换为派生类重写后的函数地址。
    • 这样,当通过基类指针或引用调用虚函数时,就会根据对象的实际类型调用相应的函数实现,从而实现多态性。

综上所述,虚函数和虚函数表是C++中实现多态性的重要机制。虚函数表是存储虚函数地址的数据结构,而虚函数则是通过虚函数表来实现动态绑定的。两者共同工作,使得C++能够支持更加灵活和强大的面向对象编程特性。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

TrustZone_

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值