1.10 C++ 面向对象(重头戏)
一、面向对象和面向过程区别
面向过程:强调过程的抽象化和模块化,以过程为中心处理客观世界问题。
面向对象:强调把解决问题的方法直接绑定到对象身上。
二、 面向对象的基本特征有哪些?
抽象(抽象出共同的、本质的特征)、继承(基类与派生类)、封装(将数据和方法封装在一个类中,只提供一些公有接口来访问)、多态(相同方法在不同对象上的表现不同)。
三、什么是深拷贝和浅拷贝
主要是针对指针成员变量来说的,深拷贝要求重新申请一段新的内存,将源对象指针所指向的内存内容拷贝到新对象中。浅拷贝就会简单的进行指针赋值。
四、什么是友元
一些类会给成员以外的函数或类(即友元函数、友元类)提供访问、操作自身成员变量(包括私有变量和保护变量)的机会,但是这种赋权是单向的(友元类、友元方法可以操作该类的私有成员,但反过来不行)。
#include <iostream>
using namespace std;
class BankAccount {
private:
double balance; // 私有成员,外部无法直接访问
public:
BankAccount(double b) : balance(b) {}
// 声明审计函数为友元(允许访问私有成员)
friend void audit(const BankAccount& account);
};
// 友元函数定义(可以访问BankAccount的私有成员)
void audit(const BankAccount& account) {
cout << "[审计] 当前余额: " << account.balance << endl; // 直接访问私有成员
}
int main() {
BankAccount acc(1000.0);
audit(acc); // 输出: [审计] 当前余额: 1000
return 0;
}
特性 | 友元函数 | 友元类 |
访问权限 | 可以访问目标类的私有( | 该类的所有成员函数均可访问目标类的私有和保护成员 |
定义方式 | 在类内使用 | 在类内使用 |
访问方式 | 需通过对象实例或指针/引用访问成员(如 | 可直接访问目标类成员(无需通过实例) |
关系方向 | 单向:目标类成员函数不能访问友元函数的局部变量 | 单向:目标类成员函数不能访问友元类的私有成员(除非友元类反向声明) |
典型应用场景 | 1. 运算符重载( | 1. 紧密协作的类(如 |
五、基类的构造函数、析构函数能否被派生类继承?
不能。首先二者名字就不一样,其次基类与派生类的资源管理需求也不一样,构造函数、析构函数有其自身的调用机制。
六、初始化列表和构造函数初始化的区别?
初始化列表会先于构造函数执行,也就是在定义阶段执行。对const或引用类型成员初始化时,只能用初始化列表的方式;以及派生类对基类传递值进行初始化的时候;以及没有默认构造函数的成员类(因为如果不进行显示初始化,编译器将自动调用其默认构造函数,但是没有默认构造函数,然后就会报错)。
七、类的成员变量的初始化顺序
按在类中声明的顺序依次初始化,与初始化列表的顺序无关。
若使用构造函数初始化,则与构造函数中的初始化顺序有关。
在 c++11 以前,普通变量成员不能进行类内初始化;c++11 之后可以,类内初始化会被编译器合并到构造函数的初始化列表中,若同时存在类内初始化和构造函数初始化列表,后者会覆盖前者
在 c++17 以前,静态成员必须在类外单独初始化;c++17 引入 内联静态成员,允许在类内初始化:
class MyClass {
static int count; // 声明
static constexpr float PI = 3.14f; // 声明+初始化
};
int MyClass::count = 0; // 必须类外定义
class MyClass {
static inline int count = 0; // 声明+定义+初始化
static constexpr float PI = 3.14f; // 同C++11
};
// 无需类外定义!
底层原理:
inline告诉编译器:- 该变量的定义可以出现在多个翻译单元(.cpp文件)中。
- 链接器会合并所有重复定义,最终只保留一个实例,在程序全局数据区有唯一地址。
八、public 继承、protected 继承、private 继承的区别?
公有继承会向下传递,公有部分在其子孙派生类中将一直是公有部分。
保护继承会使得只能通过其最近派生类访问被继承的类。基类的公有部分和保护部分全都将变为派生类的保护部分。
私有继承使基类的一切都将变为私有,在第三代的时候会导致完全屏蔽,第三代只能通过第二代的公有接口访问基类的公有接口,进而访问基类的私有成员。
九、虚继承
解决棱形继承的问题。避免对爷爷成员变量访问的二义性。会由编译器选择一个中间类的虚基类指针找到基类的数据。
内存布局:
普通继承:
D的内存布局:[B::A::data] [C::A::data] [D的成员](两份 data)。
虚继承:
D的内存布局:[虚基类指针] [B的成员] [C的成员] [A::data] [D的成员](只有一份 data)。
十、C++ 类内可以定义引用数据成员吗?
可以,必须通过成员函数初始化列表初始化。
十一、泛型编程是什么?
让类型参数化,是程序可以从逻辑功能上抽象,把被处理对象的类型作为参数传递。c++ 里面主要通过模板实现(函数模板、类模板)。类模板可以进行 类型参数化 和 值参数化 。
template <typename T, int size> // 使用值参数 size
class Array {
private:
T arr[size];
public:
T& operator[](int index) {
return arr[index];
}
}
模板特化(模板函数、模板类):
template <typename T>
void print(T value) {
cout << "Generic: " << value << endl;
}
//针对 int类型的特化
template
void print<int>(int value) {
cout << "Specialized for int: "<< value << endl;
}
int main() {
print(10); // 调用特化版本
print(3.14); // 调用通用版本
return 0;
}
template <typename T>
class Box {
public:
void display() {
cout << "Generic Box" << endl;
}
}
//针对 int类型的特化
template
class Box<int> {
public:
void display() {
cout << "Specialized Box for int" << endl;
}
}
十二、什么是右值,跟左值又有什么区别?
左值和右值的概念:
左值:能对表达式取地址、或具名的对象/变量。一般指表达式结束后依然存在的持久对象。
右值:不能对表达式取地址,或匿名对象。一般指表达式结束就不再存在的临时对象。
右值和左值的区别:
1、左值可以寻址,而右值不可以。
2、左值可以被赋值,右值不可以被赋值,可以用来给左值赋值。
3、左值可变,右值不可变(仅对基础类型适用,用户自定义类型右值引用可以通过成员函数改变)。
4、左值不能直接移动(通过std::move将左值强制转换为右值(将亡值),允许资源转移(如指针交换),避免深拷贝。),右值可以被直接移动。
十三、左值引用 与 右值引用
左值引用绑定到一个可修改的对象,通过左值引用,可以访问原始对象并修改它。
右值引用用于绑定到 右值,即表示 临时对象 或 不可修改的值。右值引用最常用的场景是 移动语义,它允许 资源从一个对象转移到另一个对象,从而避免不必要的拷贝,提高效率。
十四、完美转发
std::forward() 能够保持参数传递时的类别不变(左值还是右值),能够在一个函数里面将参数完美精确、原封不动转发到另一个函数中,避免不必要的拷贝和移动。主要是为了当一个参数以右值引用的方式传递进一个函数,如果在这个函数里面又将这个参数传递给另外一个函数,就会变成左值传递,因为在第一个函数里面参数已经是一个有名字的引用了,在第一个函数内部表现为左值。
只有模板可以自动进行引用折叠推导:可以保持传递参数的类型。
左值引用折叠:T& & 折叠为 T&;T&& & 折叠为 T &;T& && 折叠为 T&。
右值引用折叠:T&& && 折叠为 T&&。
#include <iostream>
#include <utility> // for std::forward
// 下层处理函数(验证值类别)
void process(int& x) {
std::cout << "处理左值: " << x << std::endl;
}
void process(int&& x) {
std::cout << "处理右值: " << x << std::endl;
}
// 包装函数(完美转发)
template <typename T>
void wrapper(T&& arg) {
std::cout << "包装函数接收到: ";
if constexpr (std::is_lvalue_reference_v<T&&>) {
std::cout << "左值" << std::endl;
} else {
std::cout << "右值" << std::endl;
}
process(std::forward<T>(arg)); // 关键:完美转发
}
int main() {
int x = 42;
wrapper(x); // 传递左值
wrapper(100); // 传递右值
wrapper(std::move(x)); // 传递将亡值(右值)
return 0;
}
十五、 C++ 中空类默认产生哪些类成员函数?
#include <iostream>
class EmptyClass {
public:
// 默认构造函数
EmptyClass() { std::cout << "Default constructor called\n"; }
// 默认析构函数
~EmptyClass() { std::cout << "Destructor called\n"; }
// 复制构造函数
EmptyClass(const EmptyClass& other) { std::cout << "Copy constructor called\n"; }
// 拷贝赋值运算符
EmptyClass& operator=(const EmptyClass& other) {
std::cout << "Copy assignment operator called\n";
return *this;
}
// 移动构造函数
EmptyClass(EmptyClass&& other) noexcept { std::cout << "Move constructor called\n"; }
// 移动赋值运算符
EmptyClass& operator=(EmptyClass&& other) noexcept {
std::cout << "Move assignment operator called\n";
return *this;
}
};
1、如果定义了析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符中的任意一个,编译器会认为你可能需要手动管理资源,因此不再自动生成移动语义相关的函数(但保留拷贝语义的函数)。
2、如果定义了拷贝构造函数或拷贝赋值运算符。
不会自动生成:移动构造函数、移动赋值运算符。
仍然自动生成:默认构造函数、拷贝赋值运算符、析构函数。
3、如果定义了移动构造函数或移动赋值运算符
不会自动生成:拷贝构造函数、拷贝赋值运算符(因为移动语义通常意味着资源独占)。
仍然自动生成:默认构造函数、析构函数。
十六、多态的三个条件
继承、虚函数、基类指针/引用指向子类对象实例。
十七、静态多态与动态多态
静态多态是在编译时通过函数重载或模板机制实现的多态性。这意味着多态行为在编译阶段就已确定。动态多态是在运行时通过虚函数机制实现的多态。它允许基类指针或引用在运行时调用派生类的函数。
特性 | 静态绑定 | 动态绑定 |
绑定时间 | 编译时 | 运行时 |
适用情况 | 非虚函数、函数重载、模板实例化 | 虚函数(多态性) |
运行效率 | 高(无需运行时决定) | 稍慢(需查找虚函数表) |
灵活性 | 较低 | 高(支持多态) |
示例方法 |
|
|
十八、静态成员函数与普通成员函数的区别?
特性 | 静态成员函数 | 普通成员函数 |
调用方式 | 通过类名调用,无需创建对象 | 必须通过对象或指针调用 |
访问权限 | 只能访问静态成员变量和静态成员函数 | 可以访问所有成员,包括静态和非静态成员 |
存储方式 | 共享内存,不影响对象大小 | 所有对象共享同一份函数代码,不单独占用对象内存 |
| 没有 | 有隐含的 |
类作用域 | 属于类的作用域,可以直接访问静态成员 | 属于对象的作用域,需要通过对象访问 |
十九、运算符重载
返回值类型 operator 被重载的运算法 ( 形参列表 ){}
/* 不能重载的运算符:
:: (作用域解析运算符)
. (成员访问运算符)
.* (成员指针运算符)
sizeof (大小运算符)
typeid (类型识别运算符)
?: (条件运算符)
二十、虚函数指针 与 虚函数表
当一个类在实现的时候,如果存在一个或以上的虚函数时,那么这个类对象便会包含一张虚函数表。而当一个子类继承并重写了基类的虚函数时,它的实例也会有自己的一张虚函数表。
当我们在设计类的时候,如果把某个函数设置成虚函数时,也就表明我们希望子类在继承的时候能够有自己的实现方式;如果我们明确这个类不会被继承,那么就不应该有虚函数的出现。
class A {
public:
virtual void vfunc1();
virtual void vfunc2();
void func1();
void func2();
private:
int m_data1, m_data1;
};
class B : public A {
public:
virtual void vfunc1();
void func2();
private:
int m_data3;
};
class C : public B {
public:
virtual void vfunc1();
void func2();
private:
int m_data1, m_data4;
};
父类指针指向子类对象时的函数调用规则:
1、调用虚函数时 通过对象的虚函数指针(vptr)找到子类的虚函数表(vtable)。 根据函数在虚函数表中的偏移量定位具体实现。 最终调用的是子类重写的版本(如果子类有重写)。
2、调用非虚函数时 直接静态绑定到父类的函数实现。 完全忽略对象的实际类型(子类),仅根据指针的声明类型(父类)决定调用。
一名985硕,在25年秋招中斩获多个C++/嵌入式开发Offer。本专栏将分享我的面经,涵盖C/C++、操作系统、计算机网络、ARM体系与架构、Linux应用/驱动开发、Qt、通信协议及开发工具链等核心内容。
查看11道真题和解析