# C++

# C++11 新特性有什么?

C++11 是 C++ 语言的一个重要版本,提供了许多新特性和改进,包括:

  • 模板 alias:可以为模板定义别名,以简化代码。
  • 初始化列表:可以使用 {} 进行初始化,避免代码冗长。
  • 类型推导:可以自动推导变量的类型,减少代码冗长。
  • 区间 for:可以简化循环语句,并使得代码更加易读。
  • Lambda 表达式:支持在代码中匿名定义函数,简化代码。
  • 并发支持:提供了多线程编程的功能,包括互斥量、条件变量、原子操作等。
  • 静态断言:可以在编译时进行断言,提高代码的可靠性。
  • 智能指针:提供了更加方便的内存管理机制,避免内存泄漏问题。
  • 右值引用,移动语义。
  • uniform initialization、构造函数别名等。

# 模板 alias:可以为模板定义别名,以简化代码。

template<typename T>
using MyVector = std::vector<T, std::allocator<T>>;

MyVector<int> v;
1
2
3
4

上面的代码定义了一个名为 MyVector 的别名,它是一个 std::vector<T, std::allocator<T>> 的简写。因此,我们可以直接使用 MyVector 来定义一个元素类型为 int 的 vector 对象。

# 初始化列表:可以使用 {} 进行初始化,避免代码冗长。

std::vector<int> v{1, 2, 3, 4};
1

# 类型推导:可以自动推导变量的类型,减少代码冗长。

auto x = 42;
1

# 区间 for:可以简化循环语句,并使得代码更加易读。

std::vector<int> v{1, 2, 3, 4};
for (int x : v)
  std::cout << x << " ";
1
2
3

# Lambda 表达式:支持在代码中匿名定义函数,简化代码。

std::vector<int> v{1, 2, 3, 4};
std::for_each(v.begin(), v.end(), [](int x) { std::cout << x << " "; });
1
2

# 并发支持:提供了多线程编程的功能,包括互斥量、条件变量、原子操作等。

原子操作:

std::atomic<int> x(0);
++x;
1
2

共享锁:

std::shared_mutex m;
std::unique_lock<std::shared_mutex> l(m);
1
2

线程库:

std::vector<std::thread> threads;
for (int i = 0; i < 5; ++i)
  threads.emplace_back([i]() { std::cout << i << " "; });
for (auto& t : threads)
  t.join();
1
2
3
4
5

异步任务:

auto f = std::async([]() { return 42; });
std::cout << f.get() << std::endl;
1
2

# 静态断言:可以在编译时进行断言,提高代码的可靠性。

static_assert(sizeof(int) == 4, "int must be 4 bytes");
1

# 智能指针:提供了更加方便的内存管理机制,避免内存泄漏问题。

智能指针是 C++11 的特性,通过封装原始指针并提供自动的内存管理来提高代码的可靠性和安全性。相较于传统指针而言,智能指针实现了自动释放动态分配的内存,避免了内存泄漏和野指针的问题。

智能指针通常采用 RAII(资源获取即初始化)技术实现,即在构造函数中分配内存,并在析构函数中释放内存。这样可以确保当智能指针超出作用域时,分配的内存将被自动释放,而不需要手动调用 delete 语句。

  • 什么是 RAII(资源获取即初始化)? RAII 是一种 C++ 编程技术,全称是 "Resource Acquisition Is Initialization",即 "资源获取即初始化"。

RAII 技术是一种在对象的构造函数中获取资源(如内存、文件句柄、互斥量、信号量等)并在对象的析构函数中释放资源的技术。它的基本思想是通过对象的生命周期管理资源的获取和释放,从而保证资源的正确释放。

RAII 技术的核心是利用 C++ 对象的生命周期和构造/析构函数的调用机制来实现资源的自动管理。当 RAII 对象被创建时,它会获取所需的资源,当 RAII 对象被销毁时,它会自动释放资源,不需要显式地调用释放资源的函数。

使用 RAII 技术的好处在于可以避免资源泄漏和忘记释放资源的问题,同时也可以使代码更加简洁、可读性更好、错误更少。RAII 技术在 C++ 中广泛应用于各种场景,如智能指针、锁、文件句柄等资源的管理。

C++11 引入了三种标准智能指针:

  • unique_ptr:表示独占所有权,只能有一个 unique_ptr 指向同一块内存,当 unique_ptr 被销毁时,它所指向的对象也会被销毁。
  • shared_ptr:表示共享所有权,可以有多个 shared_ptr 指向同一块内存,内部使用引用计数来管理对象的生命周期。
  • weak_ptr:是 shared_ptr 的一种扩展,可以观测一个 shared_ptr,但不会增加引用计数,因此不会影响对象的生命周期,主要用于解决 shared_ptr 循环引用的问题。

除了这三种标准智能指针,还有一些第三方实现的智能指针,如 boost::scoped_ptrboost::intrusive_ptr 等,它们可以提供更多的功能和灵活性。

std::unique_ptr<int> p1(new int(42));
std::shared_ptr<int> p2 = std::make_shared<int>(42);
1
2

# uniform initialization、构造函数别名等。

uniform initialization 是 C++11 中的一项新特性,它允许使用一种统一的语法初始化所有类型的对象,包括类、数组、标准库容器等。

int x{42};
std::vector<int> v{1, 2, 3};
1
2

在上面的代码中,我们使用了大括号 {} 进行初始化,这是一种统一的初始化语法。

# 什么是左值,什么是右值,什么时候用到右值,move 是干啥的(超高频了)

左值(Lvalue)是可以放在赋值语句左边的表达式,它通常表示具有持久性的对象,具有地址,可以被取地址符号 & 获取。左值可以是变量、数组、函数、类的成员等。

右值(Rvalue)是指赋值语句右边的表达式,它通常表示临时性的值,不具有持久性和地址。右值不能被取地址符号 & 获取,因为它们不在内存中存储,而是在寄存器中或编译器内部临时生成的。

在 C++ 中,还有一个叫做“纯右值”(Pure Rvalue)的概念,它指的是不具有任何持久性的表达式,如字面量和返回临时对象的函数调用等。可以通过 std::move 函数将左值转换为右值引用,从而将左值标记为可移动对象,从而允许移动语义。

C++11 引入了右值引用(Rvalue Reference)和移动语义,可以更有效地管理临时对象和避免不必要的拷贝。右值引用和移动语义一起使用,可以大大提高程序效率。

右值引用表示临时对象的引用。它的特点是可以对一个将要销毁的对象进行引用,从而在许多情况下大大提高了程序效率。右值引用是以两个及以上个 & 符号表示的:

int&& r = 42;
1

上面的代码声明了一个名为 r 的右值引用,它引用了一个临时对象 42。右值引用的一个重要用途是实现移动语义,即可以使用右值引用将一个对象从一个地方移动到另一个地方,而不是复制。例如:

std::string s1 = "hello";
std::string s2 = std::move(s1);
1
2

在上面的代码中,我们使用了 std::move 函数将 s1 的所有权转移到了 s2。这种做法可以避免构造临时对象和销毁对象,从而大大提高程序效率。

移动语义是通过右值引用移动对象而不是复制它,从而节省复制对象的代价。

例如:

void swap(int &a, int &b)
{
    int temp = a;
    a = b;
    b = temp;
}

void swap(int &&a, int &&b)
{
    int temp = std::move(a);
    a = std::move(b);
    b = temp;
}
1
2
3
4
5
6
7
8
9
10
11
12
13

在上面的代码中,我们定义了两个 swap 函数,一个用于处理左值,一个用于处理右值。在处理右值的 swap 函数中,我们使用了右值引用和移动语义,使用 std::move 函数移动右值而不是复制它。

这样做的好处是,当处理临时对象时,可以避免复制对象,提高程序效率。而对于左值,则仍然使用传统的交换方式,维护其不变性。

为什么右值可以避免复制代价,提高效率?

当我们复制一个对象时,会发生以下操作:

  • 首先,构造一个临时的对象。
  • 然后,复制临时对象到目标位置。
  • 最后,析构临时对象。

这些操作都是需要代价的,特别是当对象大时,复制代价会非常昂贵。

但是,对于右值,它是一个临时的、不可修改的对象,生命周期很短。因此可以将其移动到目标位置,从而避免复制代价,提高效率。

注意移动复制不等价,二者的区别在于复制会创建一个新的对象并将原对象的值复制到新对象中,而移动并不创建一个新对象,而是将原对象的资源直接移动到目标对象中。这样就不需要进行复制操作,从而提高效率。右值本质上是移动,而左值是复制

使用右值引用来代替复制,从而避免复制代价,提高效率,是移动语义的核心思想。我们可以定义移动构造函数和移动赋值运算符,从而让编译器在合适的情况下使用移动语义来代替复制,从而避免复制代价,提高效率。

因此,右值在提高效率的关键在于:右值是一个临时对象,生命周期很短,我们可以直接将其移动到目标位置,从而避免复制代价。

# 移动构造函数/移动赋值运算符函数的流程是什么?

移动构造函数/移动赋值运算符函数的流程如下:

  • 首先,移动构造函数/移动赋值运算符函数会接收一个右值引用作为参数,这个右值引用代表的是将要被移动的对象。
  • 接着,移动构造函数/移动赋值运算符函数将原对象的资源直接移动到目标对象中。
  • 然后,移动构造函数/移动赋值运算符函数将原对象的资源指针设为 null,以防止在原对象生命周期结束后资源被释放两次。
  • 最后,移动构造函数/移动赋值运算符函数返回目标对象。

整个移动构造函数/移动赋值运算符函数的流程都是一种尽量快速,尽量有效地移动原对象的资源到目标对象中的操作,从而提高效率。

# C++ 的 new 运算符流程

C++ 的 new 运算符流程如下:

  • 首先,C++ 中的 new 运算符申请内存空间。它调用底层的内存分配函数,如 malloc,给目标对象分配内存。
  • 然后,new 运算符调用目标对象的构造函数,在分配的内存中初始化对象。
  • 最后,new 运算符返回指向初始化好的对象的指针。

如果构造函数抛出异常,new 运算符会自动释放内存,并抛出相应的异常。如果内存分配失败,new 运算符会抛出 std::bad_alloc 异常。

# 什么自由存储区?和堆有什么关系?

  • 自由存储区,即 free store,特指使用 new 和 delete 来分配和释放内存的区域。一般而言,这是堆的一个子集。
  • new 和 delete 底层使用 malloc 和 free 实现。

# malloc 函数返回的地址是什么地址

虚拟地址, malloc 返回什么样的地址,如果分配10字节。

说了下 malloc ,在分配空间小于128k使用 brk 系统调用,将堆区顶部上推

实际上不是每次都调用,会维护内存池,把一堆空闲块,用链表链接起来

  • 面试官追问,如果链表太长咋办?

  • 我就把C++ allocator的二级空间配置器的原理搬过来了(维护不同块长度的空闲链表)

  • 这个链表的并发访问,除了加锁还有啥方法吗

  • 说了下原子操作(也不知道对不对)

  • 了解thread local吗?(听过名字,没了解具体原理,寄)

  • 【动态链接库】和【静态链接库】的区别?

  • 我本来想先说【动态链接】和【静态链接】的区别的,我就试图先说这个,想杀点时间

  • 但是,又问静态库 .a 和动态库 .so 有啥不同。麻了,没回答上,回去好好看

  • 然后又追问,怎么查看使用到的动态链接库?麻了,忘了,滚去复习(寄)

  • 知道 unique_ptr 吗?

  • 知道,是独占所有权的智能指针

  • 怎么实现独占所有权?怎么转移所有权?

  • b = a; 怎么实现?b = move(a); 怎么实现?

    • 就是说了移动构造函数/移动赋值运算符的流程
  • 知道 shared_ptr 吗?

    • 知道,是共享所有权的智能指针
  • 怎么实现的共享所有权?

    • 引用计数
  • 引用计数和管理的内存地址怎么实现的?是同一块内存空间吗?

    • 都在堆区
    • make_shared 会放在同一个空间
  • 引用计数和管理的内存是相生相依的吗?

    • 没搞懂,最后就问了这两个是同时创建的吗?
    • 其实不是,传入 new xxx 的构造函数就不是
  • 同样问了 shared_ptr 的 b = a; 怎么实现?b = move(a); 怎么实现?

    • 同样说了下拷贝构造函数/拷贝赋值运算符、移动构造函数/移动赋值运算符的流程
  • 你经常使用C++是吧,那你用过智能指针吗?

    • 没用过,但是我了解过原理
  • 面试官追问,如果没用过,你是怎么管理指针的?

    • 确保new、delete成对出现
    • 在构造函数中new、析构函数中delete
    • 当然,以后写项目,会尽可能使用的,毕竟大一点的项目,谁也不能这么精确的控制new、delete成对出现
  • 可以解释智能指针的原理吗?

    • 说了shared_ptr的原理,引用计数
    • 感觉没答好
  • shared_ptr循环引用问题?怎么解决?

    • 双向链表节点
    • weak_ptr(在聊天框里面还傻逼的拼成了week_ptr,面试官还提醒我了)

# const 的作用

  • 在 C++ 中,"const" 是一个修饰符,用于声明一个变量或函数的值不能被修改。它可以应用于变量和函数的声明,以防止误用。

  • 例 1:声明常量变量

const int MAX_SIZE = 100;
1

这个变量 MAX_SIZE 的值不能被修改。

  • 例 2:常量参数
void print_array(const int arr[], int size) {
    //...
}
1
2
3

这个函数的参数数组arr 不能被修改.

  • 例 3:常量返回值
const std::string& get_name() {
    static std::string name = "Alice";
    return name;
}
1
2
3
4

这个函数返回一个string 的引用是常量, 所以不能修改返回值.

  • 例 4:常量指针
int value = 10;
const int* ptr = &value;
1
2

这里 ptr 是指向 int 类型的常量指针,它指向的值不能通过该指针修改。

总而言之,使用 const 修饰符声明变量是为了提高代码的可读性和可维护性,可以避免因为误用导致的错误。

# static 作用?

在 C++ 中,"static" 是一个关键字,可以用来实现多种不同的功能。具体而言,可以在类中、函数中、局部变量中使用。

  • 例 1: 静态全局变量
static int counter = 0;
1

这里的 counter 是静态成员变量,它不属于任何特定的类对象,而是属于整个类。

  • 例 3: 静态局部变量
void MyFunction() {
    static int counter = 0;
    //...
}
1
2
3
4

这里的 counter 是静态局部变量,它在函数第一次被调用时被初始化,并在后续调用中保留它的值。

  • 例 4: 静态函数
static void MyStaticFunction() {
    //...
}
1
2
3

这里的 MyStaticFunction 是静态函数,它只在定义它的文件中可见。

总而言之,使用 static 修饰符是在为类和函数提供一种访问控制,可以限制它们的作用域,并且在变量,局部变量的作用域中提供了静态存储。

# inline

inline 修饰符用于修饰函数,表示这个函数将被编译器内联展开,而不是生成函数调用的代码。 这样能够提高程序的运行效率,但是会增加编译文件的大小。 例如:

inline void MyFunction() {
    //...
}
1
2
3
  • 虚函数可以是内联函数,内联是可以修饰虚函数的,但是当虚函数表现多态性的时候不能内联。
  • 内联是在编译期建议编译器内联,而虚函数的多态性在运行期,编译器无法知道运行期调用哪个代码,因此虚函数表现为多态性时(运行期)不可以内联。
  • inline virtual 唯一可以内联的时候是:编译器知道所调用的对象是哪个类(如 Base::who()),这只有在编译器具有实际对象而不是对象的指针或引用时才会发生。

# volatile

  • volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素(操作系统、硬件、其它线程等)更改。所以使用 volatile 告诉编译器不应对这样的对象进行优化。
  • volatile 关键字声明的变量,每次访问时都必须从内存中取出值(没有被 volatile 修饰的变量,可能由于编译器的优化,从 CPU 寄存器中取值)
  • const 可以是 volatile (如只读的状态寄存器)
  • 指针可以是 volatile

# assert()

断言,是宏,而非函数。assert 宏的原型定义在 <assert.h>(C)、<cassert>(C++)中,其作用是如果它的条件返回错误,则终止程序执行。可以通过定义 NDEBUG 来关闭 assert,但是需要在源代码的开头,include <assert.h> 之前。

# sizeof()

  • sizeof 对数组,得到整个数组所占空间大小。
  • sizeof 对指针,得到指针本身所占空间大小。

# Struct 和 Class 的区别

  • 默认的访问控制:struct 是 public ,而 class 是 private 。

# new 和 malloc 的区别?

  1. 申请内存所在位置:
    1. new 从自由存储区申请内存,而 malloc 从堆中申请内存。
    2. 自由存储区是建立在堆上,在 C++ 中使用 new 和 delete 抽象出来的概念。
  2. new 返回的是对象指针,而 malloc 返回的是 void *

# 内联函数

普通函数在调用的时候需要入栈,如果操作频繁会导致栈内存大量消耗。

内联函数每次调用都复制了代码消耗的空间,省去了函数调用的开销。

inline 放在函数声明前不会起作用。必须放在函数定义体前。

如果函数调用的开销小于(代码长/有循环)函数本身运行的开销那么不建议使用内联。

不能在递归中使用。

  1. 相当于把内联函数里面的内容写在调用内联函数处;

  2. 相当于不用执行进入函数的步骤,直接执行函数体;

  3. 相当于宏,却比宏多了类型检查,真正具有函数特性;

  4. 编译器一般不内联包含循环、递归、switch 等复杂操作的内联函数;

  5. 在类声明中定义的函数,除了虚函数的其他函数都会自动隐式地当成内联函数。

    // 声明1(加 inline,建议使用) inline int functionName(int first, int second,...);

    // 声明2(不加 inline) int functionName(int first, int second,...);

    // 定义 inline int functionName(int first, int second,...) {/****/};

    // 类内定义,隐式内联 class A { int doA() { return 0; } // 隐式内联 }

    // 类外定义,需要显式内联 class A { int doA(); } inline int A::doA() { return 0; } // 需要显式内联

优缺点

优点

  1. 内联函数同宏函数一样将在被调用处进行代码展开,省去了参数压栈、栈帧开辟与回收,结果返回等,从而提高程序运行速度。
  2. 内联函数相比宏函数来说,在代码展开时,会做安全检查或自动类型转换(同普通函数),而宏定义则不会。
  3. 在类中声明同时定义的成员函数,自动转化为内联函数,因此内联函数可以访问类的成员变量,宏定义则不能。
  4. 内联函数在运行时可调试,而宏定义不可以。

缺点

  1. 代码膨胀。内联是以代码膨胀(复制)为代价,消除函数调用带来的开销。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。
  2. inline 函数无法随着函数库升级而升级。inline函数的改变需要重新编译,不像 non-inline 可以直接链接。
  3. 是否内联,程序员不可控。内联函数只是对编译器的建议,是否对函数内联,决定权在于编译器。

# static

  1. 修饰变量的时候,变量存储在静态区中。在 main 函数启动的时候就分配空间。
  2. 修饰函数的时候只能在文件内起作用,主要是防止命名冲突。
  3. 修饰成员变量的时候,所有的成员变量都只保存一个该变量并且不用生成对象就可以直接访问。
  4. this 指针,

# volatile

volatile 修饰的变量可能会被其他因素修改,所以编译器不应当优化。每次访问的时候必须从内存中取值。

# 宏定义 #define 和 const 常量

宏定义可以实现类似于函数的功能,但是它终归不是函数,而宏定义中括弧中的“参数”也不是真的参数,在宏展开的时候对 “参数” 进行的是一对一的替换。

  • 宏定义 #define const 常量
  • 宏定义,相当于字符替换 常量声明
  • 预处理器处理 编译器处理
  • 无类型安全检查 有类型安全检查
  • 不分配内存 要分配内存
  • 存储在代码段 存储在数据段
  • 可通过 #undef 取消 不可取消

# 虚函数

在C++中,虚函数是一种允许子类重写父类中定义的函数的特殊函数。使用虚函数可以实现多态,即不同类型的对象可以使用相同的函数名调用不同的实现。

在父类中定义一个虚函数时,子类可以覆盖该函数并提供自己的实现。当子类对象调用该函数时,程序将使用子类的实现而不是父类的实现。

以下是一个简单的示例,其中定义了一个基类Animal和一个子类Cat,并使用虚函数实现多态:

#include <iostream>
using namespace std;

class Animal {
public:
    virtual void makeSound() {
        cout << "The animal makes a sound." << endl;
    }
};

class Cat : public Animal {
public:
    void makeSound() {
        cout << "The cat meows." << endl;
    }
};

int main() {
    Animal *a;
    Cat c;
    a = &c;
    a->makeSound();
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

在这个例子中,Animal类包含一个虚函数makeSound,而Cat类覆盖了该函数。在main函数中,创建了一个Animal类型的指针a,并将其指向一个Cat类型的对象c。当调用a->makeSound()时,程序将使用Cat类中的实现,输出"The cat meows."。这种行为是由虚函数实现的。

  • 虚函数实现原理

虚函数实现的原理涉及到C++的面向对象编程机制中的多态性。在C++中,每个对象都有一个虚函数表指针(也称为vptr),指向存储在内存中的虚函数表。虚函数表是一个指向虚函数地址的指针数组,其中每个虚函数对应一个数组项。每个对象的虚函数表包含的虚函数地址是根据该对象的实际类型和继承关系确定的。当调用虚函数时,程序将根据对象的实际类型查找虚函数表,并跳转到适当的虚函数实现。这种查找和跳转的过程是由编译器自动生成的,程序员无需手动实现。

具体来说,当程序调用虚函数时,编译器会首先检查该函数是否为虚函数。如果是虚函数,编译器会在对象的虚函数表中查找该函数的地址,并跳转到相应的函数实现。如果不是虚函数,则直接调用该函数的实现。

在实际编程中,通常将虚函数声明为基类中的成员函数,并在派生类中重写该函数。这样,在调用虚函数时,将根据对象的实际类型选择相应的函数实现。这种机制使得C++中的继承关系可以实现多态,从而简化了代码的实现和维护。