面试题总结2
C++ thread_local 详解
thread_local 是 C++11 引入的一个关键字,用于声明线程局部变量。线程局部变量 是指每个线程都拥有独立副本的变量,互不影响。
作用:
- 提高线程安全性:避免多个线程共享同一变量带来的数据竞争问题。
- 减少资源开销:每个线程只拥有自己的变量副本,无需为所有线程分配相同的空间。
原理:
thread_local 变量实际上是存储在线程局部存储(TLS) 中的。TLS 是操作系统提供的一种机制,用于为每个线程分配独立的存储空间。
实现方式:
thread_local 变量的实现方式依赖于底层操作系统提供的 TLS 机制。常见的有两种实现方式:
- 静态 TLS: 在编译时为每个 thread_local 变量分配固定的 TLS 槽位。
- 动态 TLS: 在运行时为每个 thread_local 变量分配动态 TLS 槽位。
使用示例:
thread_local int count = 0;
void threadFunc() {
for (int i = 0; i < 100; ++i) {
++count;
}
std::cout << "count in thread " << std::this_thread::get_id() << " is " << count << std::endl;
}
int main() {
std::thread t1(threadFunc);
std::thread t2(threadFunc);
t1.join();
t2.join();
return 0;
}
输出:
count in thread 1 is 100
count in thread 2 is 100
注意事项:
- thread_local 变量只能在函数内部使用,不能在类声明中使用。
- thread_local 变量的默认值是未定义的,需要在第一次使用前进行初始化。
- thread_local 变量不能用于引用类型,如指针、引用等。
NULL和nullptr区别?为什么要引入nullptr?
主流编译器中,NULL 实际上是一个整数常量,被定义为 0,在 C++11 之前,当我们想要将一个指针初始化为空时,我们通常使用 NULL;nullptr 是 C++11 中引入的新的关键字,专门用于表示空指针,它不是整数类型,而是特殊的指针类型nullptr_t。之所以引入nullptr,第一,NULL是整数类型,用户调用foo(NULL)的时候,不能区分调用的是foo(int)还是foo(int*)函数;第二,主流编译器中NULL值为0,通过0表示一个无效地址,但是有的架构下,0地址有特定用途,而nullptr指向的永远是一个无效地址。
C++ placement new 详解
placement new 是 C++ 中的一个运算符,用于在已分配的内存上构造对象。它与new 运算符的区别在于,placement new 不需要分配内存,而是直接使用指定的内存地址。
用法:
placement new 的语法格式如下:
(void *)operator new(size_t size, void *ptr);
其中:
size
:要构造对象的字节大小。ptr
:指向已分配内存的地址。
示例:
// 分配内存
char *buffer = new char[sizeof(Foo)];
// 在已分配的内存上构造对象
Foo *foo = new (buffer) Foo();
// 使用对象
foo->doSomething();
// 销毁对象
foo->~Foo();
// 释放内存
delete[] buffer;
在这个例子中,我们首先分配了一个足够大的字符数组 buffer
。然后,我们使用 placement new
在 buffer
上构造一个 Foo
对象。我们使用 foo
指针调用对象的成员函数 doSomething()
。最后,我们使用 foo
的析构函数 ~Foo()
销毁对象,并使用 delete[]
运算符释放内存。
使用场景:
placement new 经常用于以下场景:
- 在内存池中分配对象。
- 在预先分配的内存区域中构造对象。
- 避免内存碎片。
注意事项:
- 使用 placement new 时,需要确保指定的内存地址有效且大小足够。
- placement new 不会自动调用对象的析构函数,需要手动调用。
- placement new 不能用于构造虚函数表指针。
C++ 中 vector
和 array
的区别
1. 容量:
vector
是动态数组,容量可以根据需要自动增长或缩减。array
是静态数组,容量在编译时确定,不能动态改变。
示例:
// vector
std::vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
// array
std::array<int, 3> a = {1, 2, 3};
// 尝试添加元素,会导致编译错误
// a.push_back(4);
2. 性能:
vector
在插入和删除元素时可能会导致内存重新分配,因此运行时效率可能略低。array
内存占用固定,不会发生内存重新分配,因此访问速度更快。
示例:
// vector
std::vector<int> v(100000);
for (int i = 0; i < 100000; ++i) {
v[i] = i;
}
// array
std::array<int, 100000> a;
for (int i = 0; i < 100000; ++i) {
a[i] = i;
}
// 测试访问速度
std::cout << v[50000] << std::endl;
std::cout << a[50000] << std::endl;
3. 功能:
vector
提供丰富的成员函数,用于添加、删除、查找和遍历元素。array
功能相对较弱,没有提供类似vector
的成员函数。
示例:
// vector
std::vector<int> v = {1, 2, 3, 4, 5};
// 查找元素
auto it = std::find(v.begin(), v.end(), 3);
if (it != v.end()) {
std::cout << "Found element 3" << std::endl;
}
// 删除元素
v.erase(it);
// array
std::array<int, 5> a = {1, 2, 3, 4, 5};
// 无法使用 find 和 erase 函数
总结:
vector
和array
都是 C++ 中常用的容器。vector
是动态数组,array
是静态数组。- 选择使用哪种容器取决于具体的需求。
建议:
- 如果需要频繁地插入和删除元素,
vector
是更好的选择。 - 如果需要快速访问元素,并且不需要动态改变数组大小,
array
是更好的选择。
C++11 中提供了四种类型强制转换函数:
static_cast
:用于进行静态类型转换,转换的安全性由编译器保证。dynamic_cast
:用于进行动态类型转换,转换的安全性由运行时检查保证。const_cast
:用于转换对象的 const/volatile 属性。reinterpret_cast
:用于进行低级别类型转换,不保证转换的安全性。
1. static_cast:
- 用于将一种类型转换为另一种类型,两者之间存在隐式转换关系。
- 常用于基本类型之间的转换,例如将
int
转换为float
。 - 也可用于指针和引用的转换,例如将
int*
转换为void*
。
示例:
int i = 10;
float f = static_cast<float>(i);
int* p = &i;
void* q = static_cast<void*>(p);
2. dynamic_cast:
- 用于将一种类型转换为另一种类型,两者之间存在继承关系。
- 在运行时检查转换是否安全,如果转换失败,则抛出
std::bad_cast
异常。 - 常用于将基类指针或引用转换为派生类指针或引用。
示例:
class Base {
public:
virtual ~Base() {}
};
class Derived : public Base {
};
Base* b = new Derived();
Derived* d = dynamic_cast<Derived*>(b);
if (d != nullptr) {
// 转换成功
} else {
// 转换失败
}
3. const_cast:
- 用于转换对象的 const/volatile 属性。
- 不改变对象的类型。
示例:
const int i = 10;
int* p = const_cast<int*>(&i);
// 可以修改 i 的值
*p = 20;
4. reinterpret_cast:
- 用于进行低级别类型转换,不保证转换的安全性。
- 可以将任何类型转换为任何类型。
- 常用于将指针转换为整数,或将整数转换为指针。
示例:
int i = 10;
void* p = reinterpret_cast<void*>(&i);
// 可以将 p 转换为任何类型的指针
int* q = reinterpret_cast<int*>(p);
注意事项:
- 使用类型强制转换函数时,需要谨慎考虑转换的安全性。
static_cast
和const_cast
通常是安全的,但dynamic_cast
和reinterpret_cast
可能导致程序崩溃。
C++中inline关键字的作用和原理
作用
C++17以前,inline关键字主要有两个作用:
- 内联优化建议:告诉编译器在调用处展开函数,但最终是否展开由编译器决定。
- 解决符号重定义问题:不同文件内定义了同签名的函数,若被inline关键字修饰,则不会引发符号重定义错误。
C++17开始,inline只保留第二个作用,若用户希望函数内联展开,则可以使用__attribute((always_inline))__
关键字,它是 GCC 和 Clang 中的一个扩展,用于强制内联函数。
原理
内联展开
原理: 编译器在编译阶段将内联函数的函数体直接复制到调用它的语句块中,而不是像普通函数那样进行函数调用。
优点:
- 减少了函数调用开销,提高了执行效率。
- 方便代码阅读和理解。
缺点:
- 容易引起代码膨胀。
- 可能导致函数调用语义改变。
符号重定义
原理: 被inline关键字修饰的函数名,编译期间会被标记为weak符号。链接目标文件的时候,多个同签名weak符号不会引发编译器报错,运行期间,会选取其中一个函数进行调用。
优点:
- 提高了代码的灵活性。
- 避免了符号重定义错误。
缺点:
- 增加了代码的复杂度。
- 可能导致符号查找问题。
C++ lambda 表达式实际上是一个匿名类的成员函数,该类由编译器为 lambda 创建,该函数被隐式地定义为内联。因此,调用 lambda 表达式相当于直接调用它的 operator() 函数,这个函数可以被编译器内联优化(建议)。
具体实现原理如下:
- 编译器为 lambda 表达式创建一个匿名类,该类包含以下成员:
- 一个构造函数,用于捕获 lambda 表达式中引用的外部变量。
- 一个名为 operator() 的成员函数,该函数包含 lambda 表达式的代码。
- 编译器为 lambda 表达式创建一个该类的对象。
- 当调用 lambda 表达式时,编译器会将该对象的 operator() 函数作为普通函数进行调用。
示例:
auto add = [](int a, int b) { return a + b; };
int result = add(1, 2);
编译器将上述代码转换为以下类似代码:
struct lambda_0 {
lambda_0(int a, int b) : a_(a), b_(b) {}
int operator()(void) { return a_ + b_; }
private:
int a_;
int b_;
};
int main() {
lambda_0 lambda_0_obj(1, 2);
int result = lambda_0_obj();
return 0;
}
lambda 表达式的捕获方式
lambda 表达式可以捕获外部变量,捕获方式有两种:
- 值捕获:通过值捕获外部变量,lambda 表达式内部对该变量的修改不会影响外部变量的值。
- 引用捕获:通过引用捕获外部变量,lambda 表达式内部对该变量的修改会影响外部变量的值。
示例:
int x = 1;
// 值捕获
auto add1 = [](int a) { return a + x; };
// 引用捕获
auto add2 = [&](int a) { return a + x; };
int result1 = add1(2); // result1 = 3
x = 2;
int result2 = add2(2); // result2 = 4
C++11 中 lambda 表达式的实现
在 C++11 中,lambda 表达式是通过闭包实现的。闭包是指一个函数可以访问其定义环境中的外部变量。
C++14 中 lambda 表达式的实现
在 C++14 中,lambda 表达式可以通过编译器生成的匿名类来实现。这种实现方式更加高效,并且可以支持模板 lambda 表达式。
C++ 零三五原则
C++ 零三五原则指的是 C++ 类中特殊成员函数的定义规则,旨在帮助开发者避免资源泄漏、内存错误等问题,并提高代码的健壮性和效率。
零之法则
对于不需要通过析构函数回收资源的类,只定义普通构造函数即可。
三之法则
如果某个类需要用户定义析构函数回收资源,那么这个类除了要定义普通构造函数外,也一定要定义复制构造函数、赋值运算函数。
五之法则
C++11 引入了移动语义,为了充分利用移动语义,类应当定义移动构造函数和移动赋值运算符。因此,任何想要移动语义的类必须声明全部五个特殊成员函数:
- 普通构造函数
- 析构函数
- 复制构造函数
- 赋值运算符
- 移动构造函数
- 移动赋值运算符
以下是零三五原则的具体内容:
零之法则
- 类中没有指针成员,也不需要进行资源管理,则不需要定义析构函数,编译器会自动生成默认的析构函数。
- 默认的析构函数不会释放任何资源,如果类中存在指针成员,则需要定义析构函数显式释放资源,以避免内存泄漏。
三之法则
- 定义了析构函数,意味着类需要进行资源管理,此时必须定义复制构造函数和赋值运算符,以确保资源在对象复制和赋值操作时得到正确处理。
- 编译器会自动生成默认的复制构造函数和赋值运算符,但默认的实现可能不符合预期,例如,对于包含指针成员的类,默认的复制和赋值操作只会进行浅拷贝,导致资源被重复引用,造成内存泄漏。
- 因此,建议用户为这类类显式定义复制构造函数和赋值运算符,以实现深拷贝,确保资源的独立性。
五之法则
- C++11 引入了移动语义,可以避免复制操作带来的资源开销,提高效率。
- 移动构造函数和移动赋值运算符用于实现资源的移动,而不是复制。
- 为了充分利用移动语义,类应当定义移动构造函数和移动赋值运算符。
- 如果类已经定义了析构函数、复制构造函数或赋值运算符,编译器不会自动生成移动构造函数和移动赋值运算符,需要用户显式定义。
为什么把析构函数定义为虚函数?
主要原因:
解决 delete 指向子类对象的基类指针的时候,只析构基类、不析构子类 的问题。
详细解释:
C++ 中的 多态性 允许基类指针指向子类对象。当使用基类指针删除指向子类对象的指针时,如果不将析构函数定义为虚函数,则会导致以下问题:
- 只会调用基类的析构函数,而不会调用子类的析构函数。
- 子类中分配的资源不会被释放,导致内存泄漏。
定义虚函数析构函数可以解决这个问题:
- 当基类指针指向子类对象时,调用基类析构函数时,会先调用子类的析构函数。
- 子类的析构函数会释放子类中分配的资源。
- 这样,就可以确保所有资源都被正确释放,避免内存泄漏。
示例:
class Base {
public:
virtual ~Base() {} // 虚函数析构函数
};
class Derived : public Base {
public:
~Derived() {} // 子类析构函数
};
int main() {
Base* p = new Derived(); // 基类指针指向子类对象
delete p; // 只会调用基类析构函数
// 如果不定义虚函数析构函数,Derived 中分配的资源不会被释放
return 0;
}
extern "C" 的作用
extern "C" 是 C++ 提供的一个关键字,用于指示编译器将某个函数或变量的名称按照 C 语言的方式进行处理,以便与 C 语言进行交互。其原理上就是关闭编译器的 name mangling。
具体作用:
- C++ 函数可以被 C 语言调用:C++ 函数默认会被编译器进行 name mangling,导致 C 语言无法识别。使用 extern "C" 可以关闭 name mangling,使 C 语言能够识别 C++ 函数。
- C 语言变量可以被 C++ 代码访问:C++ 代码默认无法访问 C 语言变量。使用 extern "C" 可以使 C++ 代码访问 C 语言变量。
extern "C" {
int add(int a, int b); // C++ 函数可以被 C 语言调用
}
int main() {
int result = add(1, 2);
return 0;
}
可以在运行时访问 private 成员吗?
简短回答: 可以。
详细解释:
C++ 中的访问权限关键字 (public、protected、private) 仅在编译期有效,运行期 没有这些概念。因此,可以在运行时访问对象内的任何成员,包括 private 成员。
访问 private 成员的方法:
- 直接访问: 可以直接使用对象的成员指针或引用来访问 private 成员。
- 友元函数: 可以将需要访问 private 成员的函数声明为类的友元函数。
- 反射机制: 可以使用 C++ 的反射机制来访问 private 成员。
示例:
class MyClass {
private:
int m_private;
public:
void setPrivate(int value) { m_private = value; }
int getPrivate() { return m_private; }
};
int main() {
MyClass obj;
// 直接访问
obj.m_private = 10; // 编译通过,运行时也能正常访问
// 友元函数
void printPrivate(MyClass& obj) {
std::cout << obj.m_private << std::endl;
}
printPrivate(obj); // 编译通过,运行时也能正常访问
// 反射机制
// ...
return 0;
}
注意事项:
- 访问 private 成员可能会破坏类的封装性,导致代码不稳定。
- 应该谨慎使用访问 private 成员的方法,尽量避免在非必要的情况下使用。
C++ RAII思想
RAII 是 Resource Acquisition Is Initialization 的缩写,是一种 C++ 编程技术,它将 资源获取 与 对象生命周期 绑定在一起。
核心思想:
- 在对象的构造函数中获取资源。
- 在对象的析构函数中释放资源。
优点:
- 避免资源泄漏。
- 提高代码的健壮性和可靠性。
- 简化资源管理代码。
示例:
#include <iostream>
#include <fstream>
class File {
public:
File(const std::string& filename) {
m_file.open(filename);
if (!m_file.is_open()) {
throw std::runtime_error("无法打开文件");
}
}
~File() {
m_file.close();
}
void write(const std::string& data) {
m_file << data;
}
private:
std::fstream m_file;
};
int main() {
try {
File file("test.txt");
file.write("Hello, world!");
} catch (const std::exception& e) {
std::cerr << e.what() << std::endl;
return 1;
}
return 0;
}
上述示例中:
- File 类构造函数中打开文件,析构函数中关闭文件。
- 即使出现异常,也能保证文件被正确关闭,避免资源泄漏。
RAII 典型应用:
- 内存管理:使用智能指针管理内存,例如 std::unique_ptr、std::shared_ptr 等。
- 文件操作:使用 RAII 类封装文件操作,例如 std::ifstream、std::ofstream 等。
- 锁操作:使用 RAII 类封装锁操作,例如 std::lock_guard、std::mutex 等。