4、基础 | 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)来实现多态。每个包含虚函数的类都有一个虚函数表,表中存储了类中所有虚函数的地址。每个对象(对于包含虚函数的类)都有一个指向其类虚函数表的指针,这个指针通

剩余60%内容,订阅专栏后可继续查看/也可单篇购买

C/C++面试必考必会 文章被收录于专栏

【C/C++面试必考必会】专栏,直击面试核心,精选C/C++及相关技术栈中面试官最爱的必考点!从基础语法到高级特性,从内存管理到多线程编程,再到算法与数据结构深度剖析,一网打尽。助你快速构建知识体系,轻松应对技术挑战。希望专栏能让你在面试中脱颖而出,成为技术岗的抢手人才。

全部评论

相关推荐

1 1 评论
分享
牛客网
牛客企业服务