百度C++开发一面面经
公众号:阿Q技术站
来源:https://www.nowcoder.com/discuss/396064848512122880
1、介绍一下面向对象的三大特征?
- 封装(Encapsulation): 封装是指将数据和操作数据的方法封装在一起,形成一个类(Class)。类将数据和对数据的操作封装在一起,隐藏了数据的具体实现细节,只提供了对外的接口。这样可以有效地保护数据,防止外部直接访问和修改数据,提高了代码的可维护性和安全性。
- 继承(Inheritance): 继承是指一个类可以继承另一个类的特性和行为。被继承的类称为基类或父类,继承的类称为派生类或子类。通过继承,子类可以继承父类的属性和方法,并且可以在此基础上扩展新的属性和方法。继承可以提高代码的重用性,并且可以建立类之间的关系,使得代码结构更加清晰。
- 多态(Polymorphism): 多态是指同一个函数名在不同的对象中有不同的实现方式,即同一个接口可以有多个不同的实现。C++ 中的多态性通过虚函数和运算符重载来实现。多态性可以提高代码的灵活性和可扩展性,使得代码更加易于维护和扩展。
2、派生类与基类中具有名称相同的成员定义时,会不会有问题?
当派生类和基类中具有名称相同的成员定义时,会产生名称遮蔽(Name Hiding)的问题。具体表现为,派生类中的成员会隐藏基类中同名的成员,导致在派生类中无法直接访问基类中被隐藏的成员。
直接给个例子:
#include <iostream>
class Base {
public:
int value = 10;
void display() {
std::cout << "Base::display() called" << std::endl;
}
};
class Derived : public Base {
public:
int value = 20; // 隐藏了基类中的 value
void display() {
std::cout << "Derived::display() called" << std::endl;
}
};
int main() {
Derived d;
std::cout << d.value << std::endl; // 输出 20,访问的是派生类中的 value
d.display(); // 输出 "Derived::display() called",调用的是派生类中的 display()
// 如果想访问基类中的成员,需要通过作用域解析运算符 ::
std::cout << d.Base::value << std::endl; // 输出 10,访问的是基类中的 value
d.Base::display(); // 输出 "Base::display() called",调用的是基类中的 display()
return 0;
}
3、菱形继承时派生类访问基类定义的成员时会出现什么问题,如何解决?
菱形继承指的是一种多重继承的情况,其中一个类同时继承了两个其他类,而这两个其他类又各自继承了同一个基类。这样就会形成一个菱形的继承结构,如图:
A
/ \
B C
\ /
D
在这样的继承结构中,如果派生类 D 中访问了被多次继承的基类 A 中的成员,就会出现二义性问题,即编译器无法确定应该使用哪个基类 A 中的成员。
为了解决菱形继承带来的二义性问题,C++ 提供了虚继承(virtual inheritance)的机制。虚继承可以确保在多重继承的情况下,共同的基类只会被继承一次,从而消除了二义性。
使用虚继承的语法是在派生类对基类的声明中使用 virtual
关键字,例如:
#include <iostream>
class A {
public:
int value;
};
// 虚继承
class B : virtual public A {
public:
void setValue(int v) {
value = v;
}
};
// 虚继承
class C : virtual public A {
public:
int getValue() {
return value;
}
};
// 派生类 D 继承 B 和 C
class D : public B, public C {
public:
void display() {
std::cout << "Value from B: " << B::getValue() << std::endl; // 使用作用域解析运算符明确指定调用的是 B 类中的 getValue()
std::cout << "Value from C: " << C::getValue() << std::endl; // 使用作用域解析运算符明确指定调用的是 C 类中的 getValue()
}
};
int main() {
D d;
d.setValue(42); // 调用 B 类中的 setValue()
d.display(); // 调用 D 类中的 display()
return 0;
}
例子中,B
和 C
类都使用了 virtual
关键字来继承 A
类,这样在 D
类中只会有一个 A
类的子对象,从而避免了二义性问题。
4、介绍一下虚函数表和虚函数指针,以及其工作方式?
虚函数表(Virtual Function Table,简称 vtable)是 C++ 中用于实现多态的关键机制之一。它是一个存储了虚函数地址的表格,每个类(有虚函数的类)都有一个对应的虚函数表。虚函数表中的每个条目都是指向虚函数的指针。
在使用虚函数时,编译器会为每个类生成一个虚函数表,并在对象的内存布局中添加一个指向虚函数表的指针(称为虚函数指针)。这个指针通常位于对象的开头部分,也就是所谓的虚函数表指针或者 vptr。
当调用一个虚函数时,实际上是通过对象的虚函数指针找到对应的虚函数表,然后再通过虚函数表找到要调用的虚函数的地址,最后通过该地址调用虚函数。
这样的设计使得 C++ 能够在运行时动态地确定应该调用哪个版本的虚函数,从而实现了多态性。通过修改虚函数表指针的值,可以实现在运行时切换对象的虚函数表,从而实现动态绑定。
5、介绍一下纯虚函数?
纯虚函数是一种在基类中声明但没有在基类中实现的虚函数。它通过在函数声明的末尾使用 = 0
来指示。纯虚函数是一种抽象接口,它要求任何派生类都必须提供实现。
纯虚函数的主要作用是定义接口规范,强制派生类实现特定的接口功能,同时允许基类中定义一些通用的行为。它使得类的设计更加灵活,能够更好地应对不同派生类的需求。
在 C++ 中,包含纯虚函数的类称为抽象类,抽象类不能被实例化,只能作为基类来派生其他类。派生类必须实现基类中的所有纯虚函数,否则派生类也会变成抽象类。
再来个例子:
#include <iostream>
// 抽象类 Shape,包含一个纯虚函数 area()
class Shape {
public:
// 纯虚函数 area(),必须在派生类中实现
virtual double area() const = 0;
};
// 派生类 Circle,实现了 Shape 的纯虚函数 area()
class Circle : public Shape {
public:
Circle(double radius) : radius(radius) {}
// 实现了纯虚函数 area()
double area() const override {
return 3.14 * radius * radius;
}
private:
double radius;
};
int main() {
// Shape 是抽象类,不能实例化
// Shape shape; // Error: 抽象类不能被实例化
// 创建 Circle 对象,并计算其面积
Circle circle(5.0);
std::cout << "Circle area: " << circle.area() << std::endl;
return 0;
}
6、构造函数中可不可以调用虚函数,为什么?
当基类的构造函数调用一个虚函数时,由于派生类对象尚未完全构造完成,因此此时调用的虚函数将会是基类版本的虚函数,而不是派生类版本的虚函数。这是因为派生类对象的构造过程是从基类开始向下构造的,只有当派生类的构造函数执行完毕后,对象才算完全构造完成。
这种行为可能导致一些意外的结果,因为在基类构造函数中调用的虚函数可能会依赖于派生类中的数据成员或虚函数的实现,而此时这些派生类特有的部分尚未初始化。
因此,在构造函数中调用虚函数时需要格外小心,最好避免在构造函数中调用虚函数,特别是在基类构造函数中调用虚函数。如果确实需要在构造函数中执行一些类似于虚函数的行为,可以考虑将其设计为非虚函数,并在构造函数中直接调用,或者通过其他方式来避免可能导致问题的情况。
7、析构函数为什么需要是虚函数?
在 C++ 中,如果一个类中包含了虚函数,通常情况下都应该将其析构函数声明为虚函数。这样做的主要原因是确保在使用多态时能够正确地调用对象的析构函数,从而避免内存泄漏和未定义行为。
当基类的析构函数不是虚函数时,如果通过基类指针删除一个派生类的对象,只会调用基类的析构函数,而不会调用派生类的析构函数。这样就会导致派生类中可能存在的资源未能正确释放,从而造成内存泄漏或其他问题。
通过将基类的析构函数声明为虚函数,可以确保在删除对象时会根据指针所指向的对象的实际类型来调用析构函数,从而正确地释放对象占用的资源。
8、什么时候会合成默认版本的拷贝构造函数?
在 C++ 中,如果一个类没有显式地定义拷贝构造函数,编译器会自动生成一个默认的拷贝构造函数。这个默认的拷贝构造函数执行的操作是按照成员变量的方式进行复制。以下情况会导致编译器合成默认版本的拷贝构造函数:
- 当类中没有定义任何拷贝构造函数时,编译器会自动生成一个默认的拷贝构造函数。
- 当类中没有定义移动构造函数、移动赋值运算符、拷贝赋值运算符,并且没有显式声明析构函数时,编译器会自动生成一个默认的拷贝构造函数。
需要注意的是,如果类中有指针成员或者资源管理的情况,需要特别注意默认生成的拷贝构造函数可能会导致浅拷贝的问题。在这种情况下,通常需要显式地定义拷贝构造函数,以确保正确地复制对象的内容。
9、介绍下拷贝构造函数的功能?
拷贝构造函数是 C++ 中一种特殊的构造函数,用于通过已存在的对象创建一个新的对象。拷贝构造函数的功能是按值(即深拷贝)复制一个对象,创建一个新的对象,新对象的内容与原对象相同。通常情况下,拷贝构造函数的原型为:
ClassName(const ClassName& other);
拷贝构造函数的功能:
- 创建新对象: 拷贝构造函数用于创建一个新的对象,该对象的内容与另一个同类型的对象相同。
- 按值复制: 拷贝构造函数执行的是按值复制,即复制对象的成员变量的值。这意味着对于类中的每个成员变量,拷贝构造函数都会复制其值,而不是简单地复制指针或引用。
- 深拷贝与浅拷贝: 拷贝构造函数通常用于执行深拷贝操作,即对于类中的动态分配的资源(如指针所指向的内存),会创建一个新的副本,而不是简单地复制指针。这样可以避免多个对象共享同一块内存区域带来的问题。
- 默认生成: 如果一个类没有显式地定义拷贝构造函数,编译器会自动生成一个默认的拷贝构造函数。这个默认的拷贝构造函数执行的是浅拷贝,即简单地复制成员变量的值。
10、合成版本的拷贝构造函数直接使用会存在什么问题?
成版本的拷贝构造函数是编译器根据需要自动生成的默认拷贝构造函数,如果一个类没有显式地定义拷贝构造函数,编译器会自动生成一个默认的拷贝构造函数。这个合成版本的拷贝构造函数执行的是浅拷贝(对于指针等资源未进行深拷贝),这可能会导致一些问题,主要包括以下几点:
- 浅拷贝问题: 合成版本的拷贝构造函数执行的是浅拷贝,对于类中的指针等资源,只是简单地复制指针的值,而不是复制指针所指向的内容。这样,多个对象可能会共享同一块内存区域,导致潜在的内存管理问题,比如在一个对象析构后,另一个对象仍然持有一个无效的指针。
- 资源泄漏: 如果类中包含动态分配的资源(比如堆内存),合成版本的拷贝构造函数不会为新对象分配新的资源,而是简单地复制指针。这可能导致资源泄漏,因为多个对象共享同一份资源,但只有一个对象会在析构时释放该资源。
- 不符合需求: 在一些情况下,类的设计可能需要特殊的拷贝行为,例如深拷贝或者禁止拷贝。合成版本的拷贝构造函数可能无法满足这些需求,因为它们只是简单地按值复制成员变量。
11、介绍一下左值引用和右值引用的区别?
- 左值引用:
- 左值引用是对左值(Lvalue)的引用,左值是指在表达式中具有名称的对象,或者可以取地址的对象。例如,变量、函数返回的左值、表达式中的左值等都属于左值。
- 左值引用用
&
表示,声明方式为Type& ref = object;
,其中Type
是对象的类型,ref
是引用变量名,object
是被引用的对象。 - 左值引用主要用于函数参数传递、函数返回值、以及实现赋值操作符等场景。它可以延长左值的生命周期,可以修改所引用的对象。
- 右值引用:
- 右值引用是对右值(Rvalue)的引用,右值是指在表达式中没有名称的临时对象,或者无法取地址的对象。例如,字面值常量、函数返回的右值、表达式中的右值等都属于右值。
- 右值引用用
&&
表示,声明方式为Type&& ref = std::move(object);
,其中Type
是对象的类型,ref
是引用变量名,object
是被引用的对象。 - 右值引用主要用于移动语义(Move Semantics)和完美转发(Perfect Forwarding)等场景。它可以绑定到临时对象,允许移动语义的实现,提高性能。
12、介绍一下移动构造函数的语法以及输入参数,底层是如何实现的?
移动构造函数是 C++11 引入的一个新特性,它允许对象的资源(比如堆上的内存、文件句柄等)在拷贝时被“移动”而不是复制,从而提高了程序的性能。
移动构造函数的语法:
class MyClass {
public:
// 移动构造函数的声明
MyClass(MyClass&& other) noexcept {
// 在移动构造函数中,通常会直接将资源从 other 转移到当前对象中
// 并将 other 对象置于有效但未指定状态,以避免重复释放资源
}
};
移动构造函数的输入参数是一个右值引用(&&
),表示该构造函数接受一个右值(临时对象)作为参数。在移动构造函数中,通常会将参数对象 other
的资源直接移动到当前对象中,并将 other
置于有效但未指定状态。
移动构造函数的底层实现通常是通过使用移动语义来实现的。移动语义是一种特殊的语义,允许在对象之间转移资源的所有权,而不是进行传统的复制。在移动构造函数中,可以使用标准库提供的 std::move
函数来实现移动语义,该函数将对象转换为右值引用,从而可以调用移动构造函数。移动构造函数通常会在实现中使用移动语义来实现高效的资源管理,比如通过移动指针、释放临时对象等方式来实现资源的转移。
13、介绍下std::move()和forward的实现原理,以及功能?
std::move():
-
std::move()
是一个用于将左值转换为对应的右值引用的函数。定义如下: -
template <typename T> constexpr std::remove_reference_t<T>&& move(T&& t) noexcept { return static_cast<std::remove_reference_t<T>&&>(t); }
-
std::move()
的主要作用是将传入的左值引用t
转换为对应的右值引用,并返回。这样做的目的是为了告诉编译器,我们希望对t
进行移动操作,而不是复制操作。 -
使用
std::move()
可以触发移动语义,将对象的资源所有权从一个对象转移到另一个对象,从而提高程序的性能。
std::forward():
-
std::forward()
是一个用于完美转发的函数模板,定义如下: -
template <typename T> constexpr T&& forward(std::remove_reference_t<T>& t) noexcept { return static_cast<T&&>(t); }
-
std::forward()
的主要作用是在泛型编程中进行完美转发,即将传入的参数以原始的引用类型传递给其他函数。它通常用于实现转发函数(forwarding functions)或者在模板中传递参数。 -
std::forward()
的参数是一个通用引用(T&&
),它会根据传入参数的类型,将参数转发为对应的左值引用或右值引用。
14、C++可变参数如何实现,模板可以使用可变参数吗?
在 C++11 及以上的标准中,可以使用可变参数模板(Variadic Templates)来实现可变参数的功能。可变参数模板允许模板接受任意数量的参数,这在编写泛型代码时非常有用。
可变参数模板的语法如下:
// 模板定义中的 "..." 表示可变参数模板的参数包
template <typename... Args>
void myFunction(Args... args) {
// 使用 "args..." 来展开参数包,可以在函数体中对每个参数进行操作
// 例如,可以使用递归、展开或者其他技术来处理参数包中的每个参数
}
在模板中,Args
是一个模板参数包(Template Parameter Pack),它表示可变参数模板中的参数列表。在模板实例化时,可以将任意数量的参数传递给 myFunction
,编译器会自动将这些参数打包成参数包 Args...
。
使用可变参数模板可以实现许多功能,比如实现通用的函数、类或容器,能够接受不定数量的参数。
另外,模板本身也可以使用可变参数模板。例如,可以定义一个可变参数的模板类或者模板函数,从而使得模板本身能够接受不定数量的模板参数。
15、用过可变参数容器吗?
可变参数模板通常用于实现可变参数的函数或类模板,而不是直接用于创建可变参数的容器。在 C++ 中,STL(标准模板库)提供了 std::tuple
类,它可以用来存储任意数量的值,类似于一个容器,而且可以通过模板参数来实现可变参数的功能。
给个简单例子:
#include <iostream>
#include <tuple>
// 递归终止条件:处理最后一个元素
void printTupleElement(std::tuple<>&) {}
// 递归处理函数:打印当前元素并递归处理下一个元素
template <typename T, typename... Args>
void printTupleElement(std::tuple<T, Args...>& t) {
std::cout << std::get<0>(t) << " ";
printTupleElement(std::tuple<Args...>(std::get<Args>(t)...));
}
// 打印任意数量的参数
template <typename... Args>
void printTuple(Args... args) {
printTupleElement(std::tuple<Args...>(args...));
}
int main() {
printTuple(1, "two", 3.14, '4');
return 0;
}
定义了一个 printTuple
函数模板,它接受任意数量的参数,并使用 std::tuple
将这些参数打包成一个元组。然后,我们定义了一个递归处理函数 printTupleElement
,它逐个打印元组中的元素,并递归处理下一个元素。最后,我们在 main
函数中调用了 printTuple
函数,并传入了四个不同类型的参数。
这个例子展示了如何使用 std::tuple
和可变参数模板来实现一个函数,该函数可以接受任意数量和任意类型的参数,并按顺序打印这些参数。虽然 std::tuple
并不是一个容器,但它能够提供类似于容器的功能,用于存储和处理可变数量的参数。
16、讲一下二叉树的深度遍历?
这里讲一下二叉树的前序遍历。
-
概念:
- 前序遍历的顺序是:先访问根节点,然后递归地对左子树进行前序遍历,最后递归地对右子树进行前序遍历。
- 在前序遍历中,对于任意一个节点,先访问该节点,然后递归地访问其左子树,最后递归地访问其右子树。
-
思路:
- 如果二叉树为空,则直接返回。
- 否则,首先访问根节点,然后递归地对左子树进行前序遍历,最后递归地对右子树进行前序遍历。
-
参考代码:
#include <iostream>
#include <stack>
#include <vector>
using namespace std;
// 定义二叉树的节点结构
struct TreeNode {
int val;
TreeNode* left;
TreeNode* right;
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};
// 前序遍历函数(栈实现)
vector<int> preorderTraversal(TreeNode* root) {
vector<int> result; // 存储遍历结果的数组
if (root == nullptr) {
return result; // 如果根节点为空,直接返回空的结果数组
}
stack<TreeNode*> nodeStack; // 辅助栈,用于存储待遍历的节点
nodeStack.push(root); // 将根节点入栈
while (!nodeStack.empty()) {
TreeNode* node = nodeStack.top(); // 取出栈顶节点
nodeStack.pop(); // 弹出栈顶节点
result.push_back(node->val); // 将节点值加入结果数组
if (node->right != nullptr) {
nodeStack.push(node->right); // 如果右子节点不为空,将右子节点入栈
}
if (node->left != nullptr) {
nodeStack.push(node->left); // 如果左子节点不为空,将左子节点入栈
}
}
return result; // 返回遍历结果数组
}
int main() {
// 创建二叉树
TreeNode* root = new TreeNode(1);
root->left = new TreeNode(2);
root->right = new TreeNode(3);
root->left->left = new TreeNode(4);
root->left->right = new TreeNode(5);
// 进行前序遍历
vector<int> result = preorderTraversal(root);
// 输出遍历结果
cout << "Preorder Traversal Result: ";
for (int num : result) {
cout << num << " ";
}
cout << endl;
return 0;
}
17、写个代码,将N*N的数组绕着中心点顺时针旋转90°,输出旋转后的矩阵。
简单思路:
- 转置矩阵: 首先,我们需要将矩阵转置。转置是指将矩阵的行和列对调位置。例如,矩阵中第 i 行第 j 列的元素会变成第 j 行第 i 列的元素。
- 水平翻转: 转置完成后,我们需要对转置后的矩阵进行水平翻转。水平翻转是指以矩阵中心水平线为轴进行翻转,即矩阵的上半部分和下半部分对称交换位置。
参考代码:
#include <iostream>
#include <vector>
using namespace std;
// 旋转矩阵的函数
void rotate(vector<vector<int>>& matrix) {
int n = matrix.size(); // 获取矩阵的大小
// 转置矩阵
for (int i = 0; i < n; ++i) {
for (int j = i + 1; j < n; ++j) {
// 交换 matrix[i][j] 和 matrix[j][i] 的值
swap(matrix[i][j], matrix[j][i]);
}
}
// 水平翻转
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n / 2; ++j) {
// 交换 matrix[i][j] 和 matrix[i][n-1-j] 的值
swap(matrix[i][j], matrix[i][n - 1 - j]);
}
}
}
int main() {
// 获取矩阵的大小
int n;
cout << "请输入方阵的大小:";
cin >> n;
// 输入矩阵元素
vector<vector<int>> matrix(n, vector<int>(n));
cout << "请输入方阵的元素:" << endl;
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
cin >> matrix[i][j];
}
}
// 旋转矩阵
rotate(matrix);
// 输出旋转后的矩阵
cout << "旋转后的矩阵为:" << endl;
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
cout << matrix[i][j] << " ";
}
cout << endl;
}
return 0;
}