C/C++ 八股文之基础语法(2)
Author:Once Day Date:2025年12月23日
漫漫长路,才刚刚开始...
全系列文章请查看专栏: C语言_Once-Day的博客-CSDN博客
参考文档:
- 史上最全C/C++面试、C++面经八股文,一文带你彻底搞懂C/C++面试、C++面经!_c++八股-CSDN博客
- 【秋招】嵌入式面试八股文-C语言 关键字/变量_牛客网
- 一文搞懂 C/C++ 面试重点知识和常见面试题(2025 年更新) | 编程指北-计算机学习指南
文章目录
-
-
- [C/C++ 八股文之基础语法(2)](#C/C++ 八股文之基础语法(2))
-
- [1. 匿名函数](#1. 匿名函数)
- [2. 指针](#2. 指针)
- [3. 右值引用与左值引用](#3. 右值引用与左值引用)
- [4. define 和 const 的区别](#4. define 和 const 的区别)
- [5. 静态变量初始化](#5. 静态变量初始化)
- [6. 移动语义](#6. 移动语义)
- [7. 完美转发](#7. 完美转发)
- [8. 运算符重载](#8. 运算符重载)
- [9. auto 关键字](#9. auto 关键字)
- [10. 回调函数](#10. 回调函数)
- [11. 函数重载、重写和隐藏](#11. 函数重载、重写和隐藏)
-
1. 匿名函数
在 C++ 中,匿名函数(Anonymous Function),通常是指 Lambda 表达式(Lambda Expression)。Lambda 是 C++11 引入的一种语法机制,用于定义无须命名、可内联定义、可捕获上下文变量的函数对象(function object)。这种机制极大地增强了函数式编程风格在 C++ 中的表达能力,也提升了代码的简洁性与灵活性。
匿名函数是什么?
在 C++ 中,匿名函数的表现形式是 Lambda 表达式,它的基本语法如下:
c
[捕获列表](参数列表) -> 返回类型 {
函数体
};
其中各部分含义如下:
- 捕获列表:指定从外部作用域捕获哪些变量。
- 参数列表:类似普通函数的参数列表。
- 返回类型:可以省略,编译器会类型推导(C++14 起)。
- 函数体:函数执行的代码块。
下面是一个简单的示例:
c++
#include <iostream>
int main() {
int a = 5, b = 10;
auto sum = [a, b]() -> int {
return a + b;
};
std::cout << sum() << std::endl; // 输出 15
}
匿名函数的本质是什么?
Lambda 表达式的本质是编译器自动为你生成的一个类(闭包类型),该类实现了 operator(),因此它是一个函数对象(Function Object / Functor),可以像函数一样调用。
编译器背后的行为(等价于):
c++
class __LambdaClosure {
int a, b;
public:
__LambdaClosure(int a_, int b_) : a(a_), b(b_) {}
int operator()() const {
return a + b;
}
};
实际上 sum 是一个编译器生成的闭包类的实例,具有 operator() 成员函数。
Lambda 表达式带来了多方面的优势:
- 代码简洁:无需定义额外函数或函数对象类。
- 上下文捕获:可以直接访问当前作用域的变量。
- 内联定义:非常适合在 STL 算法、回调中临时定义行为。
- 提高代码可读性和封装性:逻辑靠近使用位置,避免污染全局命名空间。
- 性能优秀:由于是编译期生成,可以被内联优化,不引入运行时开销。
Lambda 表达式的捕获方式
捕获列表是 Lambda 表达式的关键部分,它决定了闭包对象中存储哪些变量,以及如何存储它们。主要捕获方式包括:
[=],按值捕获所有外部变量(默认)。[&],按引用捕获所有外部变量(默认)。[a],仅按值捕获变量a。[&a],仅按引用捕获变量a。[=, &b],默认按值捕获,但对b使用引用。[&, a],默认按引用捕获,但对a使用值捕获。[this],捕获当前对象的this指针(隐式捕获成员变量)。[=, this],按值捕获所有变量,并捕获this指针。[x = std::move(obj)],C++14 起支持的初始化捕获(按值/引用自定义初始化)。
引用捕获的核心问题是生命周期管理,当 Lambda 表达式按引用捕获变量时,它并不拥有变量的所有权,仅仅是对原变量的别名引用。
(1)引用悬空(Dangling Reference)风险。如果 Lambda 被延迟执行(如异步、线程、回调),而原变量已经销毁,那么 Lambda 内部对变量的访问将是未定义行为。
c++
std::function<void()> f;
{
int x = 42;
f = [&]() { std::cout << x << std::endl; }; // 引用捕获 x
} // x 已销毁
f(); // 未定义行为(悬空引用)
如果 Lambda 要在当前作用域之外使用,优先使用值捕获或智能指针捕获。
(2)线程安全问题。引用捕获容易导致在多线程环境下访问共享变量而未加锁,从而引发数据竞争。
c++
int counter = 0;
std::thread t([&]() {
counter++; // 非线程安全
});
在并发环境中,引用捕获变量应加锁或使用原子类型。
(3)不可复制/不可移动的变量。如果引用捕获了一个不可复制或不可移动的对象,拷贝 Lambda 时可能出错(比如使用 std::function 存储 Lambda)。此时可以选择 std::reference_wrapper 或使用智能指针辅助管理生命周期。
2. 指针
在 C 和 C++ 中,指针(Pointer)是语言中极具表现力但也极具风险的一个核心概念。
指针是一种变量,其值是另一个变量的地址。换句话说,指针"指向"一个内存位置,这个位置存储着某个变量的值。
在语法上,指针的声明形式如下:
c++
int x = 10;
int* p = &x; // p 是一个整型指针,指向变量 x 的地址
int* p 表示指针变量 p 可以指向一个 int 类型的变量。
&x 是取地址操作符,返回变量 x 的内存地址。
*p 是解引用操作符,用来访问指针所指向地址上的值。
指针是 C/C++ 中强大而灵活的工具,其主要特点包括:
- 直接访问内存,指针可以访问任意内存地址(在权限允许的范围内),这使得 C/C++ 能够完成许多底层操作,例如:操作硬件寄存器,实现动态内存分配,构建各种数据结构(如链表、树等)。
- 支持动态内存管理 ,通过标准库函数如
malloc()/free()(C)或new/delete(C++),程序员可以使用指针实现运行时的内存分配。 - 支持复杂数据结构和函数接口,指针是构建链表、树、图等动态数据结构的基础。指针可以作为函数参数,实现传引用效果。C/C++ 中函数指针可用于实现回调、策略模式等。
- 不同类型的指针 ,C/C++ 支持指向不同类型的指针,如
int*、char*、void*等。void*是一种通用指针类型,可以指向任何类型的数据,但不能直接解引用,需先进行类型转换。数组名在表达式中可以退化为指向其首元素的指针,函数名也会退化为指向函数的指针。
虽然指针非常强大,但它也引入了 C/C++ 语言中最常见的错误源:
- 野指针(Dangling Pointer),指针指向了无效的地址,例如指向已释放的内存或未初始化的指针。
- 内存泄漏(Memory Leak),动态分配的内存未被释放,导致内存资源浪费,严重时可能耗尽系统内存。
- 缓冲区溢出(Buffer Overflow),指针操作不当可能导致写越界,破坏程序的其他数据,甚至被利用执行恶意代码(经典的安全漏洞)。
- 空指针解引用,解引用一个空指针是未定义行为,可能导致程序崩溃。
- 类型不匹配(Type Mismatch),将一个类型的指针错误地解释为另一个类型,可能导致访问错误的数据。
- 多重释放 (Double Free),对同一块内存多次调用
free(),行为未定义。
3. 右值引用与左值引用
在 C++ 中,值类别(value categories)的概念是理解现代 C++(尤其是 C++11 及以后)中引用类型、移动语义、完美转发等高级特性所必需的基础。
C++ 中的左值(Lvalue)和右值(Rvalue)
左值(Lvalue)是指表达式结束后仍然存在的对象。它有明确的内存地址,因此可以取地址(即 & 操作是合法的)。常见的左值包括:
c++
int x = 10;
x = 20; // x 是左值
int* p = &x; // 可以取地址
右值是临时值、字面量或不会持续存在的表达式结果,它没有可获取的内存地址。右值不能作为赋值语句的左边。
c++
int y = 5 + 3; // 5 + 3 是右值
int z = x * 2; // x * 2 是右值
C/C++ 值类别细分:
- 左值,有名字、可取地址,如变量名 x。
- 右值 ,不可取地址、临时对象,如
x + y,42。 - 纯右值(prvalue),纯粹的右值,如字面量、返回值为值的函数。
- 将亡值(xvalue) ,即将被移动的对象,如
std::move(obj)的结果。 - 泛左值(glvalue),包括左值和将亡值。
- 值(value),包括纯右值和将亡值。
什么是右值引用?
C++11 引入了右值引用(Rvalue Reference),以 T&& 的形式声明。它只能绑定到右值(临时对象):
c++
int&& r = 42; // 42 是右值,r 是右值引用
右值引用扩展了引用的能力,使得我们可以:
- 捕获右值(临时对象),避免不必要的拷贝。
- 实现移动语义(move semantics)以提高性能。
- 实现完美转发(perfect forwarding)。
为什么引入右值引用?
主要目的是为了资源的高效管理。在 C++03 中,临时对象只能被拷贝(代价高),C++11 引入右值引用后,可以对临时对象执行"资源窃取",避免深拷贝,提高性能。
c++
class MyVector {
int* data;
size_t size;
public:
// 移动构造函数
MyVector(MyVector&& other)
: data(other.data), size(other.size) {
other.data = nullptr;
other.size = 0;
}
};
如果没有右值引用,我们只能进行拷贝,代价昂贵;有了右值引用,可以直接"搬走"资源。
++i是左值还是右值?
++i 和 i++ 都是自增表达式,它们的值类别存在重要差异:
++i是左值:可以取地址(&++i是合法的)。i++是右值:返回的是旧值的副本,不能取地址。
c++
int i = 0;
int* p = &++i; // OK,++i 是左值
int* q = &i++; // 错误,i++ 是右值
++i 和 i++ 的效率取决于数据类型和编译器优化能力:
- 对于原始类型(如 int),两者几乎没有性能差异,现代编译器会优化为等效代码。
- 对于复杂类型(如迭代器、类),++i 通常更高效,因为
i++需要保存旧值(返回值),可能涉及拷贝构造,++i修改后直接返回引用,不产生临时对象
4. define 和 const 的区别
#define 和 const 是 C/C++ 中用于定义 "常量" 的两种常用方式,但二者的本质、作用机制和使用场景有显著区别。
(1)本质与编译阶段不同:
#define 是预处理指令,属于 "文本替换" 规则。预处理阶段(编译前)会把代码中所有匹配 #define 标识符的地方,直接替换成对应的值 / 代码片段,编译阶段编译器根本看不到 #define 定义的标识符,也不识别其类型。
const 是关键字,定义的是 "只读变量"(编译期 / 运行期常量),属于语言层面的语法。const 常量有明确的类型,编译阶段会参与类型检查,且会占用内存(存储在数据段),本质是 "值不能被修改的变量"。
(2)类型检查与安全性不同:
#define 没有类型检查,仅做无脑文本替换,容易引发隐蔽错误。
const 有严格的类型检查,编译器会确保其使用符合类型规则,能提前发现错误。
(3)作用域与生命周期不同:
#define 作用域是 "从定义处到文件结束",若想提前终止作用域,需用 #undef ;且 #define 无生命周期概念,因为预处理阶段就已被替换,不占用运行时内存。
const 作用域遵循普通变量规则。局部 const(函数内定义)作用域仅限函数内,全局 const 默认作用域为本文件(需加 extern 才能跨文件使用);const 常量有生命周期,局部 const 存储在栈上(运行期创建、销毁),全局 const 存储在数据段(程序启动时创建,退出时销毁)。
(4)内存占用不同:
#define 不占用内存,只是文本替换,代码中多次使用 #define 常量,会生成多份替换后的代码,但无额外内存开销。
const 占用内存(即使是编译期常量,也会分配存储地址),代码中多次使用 const 常量,都是引用同一块内存地址,更节省内存(尤其频繁使用时)。
简单来说,#define 是 "无脑替换",轻量但易出错;const 是 "带类型的只读值",安全且符合语言规范,C++ 中优先推荐用 const(尤其是编译期常量),仅在特殊预处理场景(如条件编译、宏函数)用 #define。
5. 静态变量初始化
在 C/C++ 中,静态变量(static 修饰的变量)具有持久的生命周期和固定的存储位置。无论是在函数内部、类中,还是在全局作用域中声明,静态变量的内存分配和初始化都遵循特定的规则。
静态变量都存储在程序的全局数据区(也称为静态存储区,Static Storage Area),这一区域在程序加载时由操作系统分配,其生命周期从程序开始到程序结束。
根据变量是否被初始化,静态变量可能被划分到不同的段:
- 已初始化的静态变量,位于
.data段。 - 未初始化的静态变量,位于
.bss段。
这些变量不随函数调用或栈帧变化而创建或销毁,而是全局唯一地存在于内存中。
在 C 中,静态变量的初始化发生在程序加载阶段(load time),由操作系统或运行时系统完成。编译器在编译时将初始值写入可执行文件的 .data 或 .bss 段中。程序加载器在程序启动时将这些段加载到内存中,即完成初始化。
C++ 中局部静态变量(函数内 static)初始化在第一次执行到该语句时进行(懒初始化),C++11 起,局部静态变量的初始化是线程安全的,编译器负责生成检查逻辑,确保只初始化一次。
C++ 中全局/命名空间静态变量初始化发生在程序执行前的静态初始化阶段,即 before main()。初始化顺序遵循编译单元内的顺序,但不同编译单元之间顺序未定义(可能导致"静态初始化顺序问题")。
C++ 类内静态成员变量如果是内置类型,初始化在程序启动时完成。如果是类类型对象,可能涉及构造函数,在执行期初始化。
6. 移动语义
在 C++11 引入 右值引用(T&&) 之后,移动语义(Move Semantics)成为现代 C++ 性能优化的核心技术之一。它允许程序将资源的所有权从一个对象"移动"到另一个对象,而不是进行开销较大的资源复制,这在处理动态内存、文件句柄、网络连接等资源时尤为重要。
什么是移动语义?
移动语义是指在对象的赋值或构造过程中,通过"转移资源的所有权"来避免昂贵的资源复制。与传统的拷贝语义不同,移动语义的目标是复用已有资源,而不是重复创建。
c++
std::string a = "Hello";
std::string b = std::move(a); // a 被"移动"到 b,a 变为空状态
这里,std::move(a) 将 a 转换为右值,使得移动构造函数可以被调用,b 直接接管 a 的内部资源(如堆内存),避免了字符串内容的复制。
为了支持移动语义,C++11 引入两个新的特殊函数:
- 移动构造函数(Move Constructor),
ClassName(ClassName&& other);,参数为右值引用(ClassName&&),将other的资源"搬到"当前对象,释放other的资源拥有权(通常设为nullptr或空状态)。 - 移动赋值运算符(Move Assignment Operator),
ClassName& operator=(ClassName&& other);,先释放当前对象已有资源,接管other的资源, 将other置于空状态。
移动语义性能更高,避免了调用拷贝构造函数和深拷贝操作,对于资源占用较大的对象(如 std::vector、std::string、文件流),可显著减少内存分配和释放的次数。
移动语义支持资源所有权转移,更容易实现 资源管理类(RAII),比如智能指针 std::unique_ptr,支持将资源从返回值中"取出",避免临时对象的重复构造。
std::move 是一个类型转换工具函数,并不执行实际的"移动"操作:
c++
template<typename T>
constexpr typename std::remove_reference<T>::type&& move(T&& t) noexcept {
return static_cast<typename std::remove_reference<T>::type&&>(t);
}
本质是将一个左值强制转换为右值引用类型,它不会移动任何内容,只是告诉编译器"这个对象可以被移走"。
7. 完美转发
在现代 C++(C++11 及以后)中,完美转发(Perfect Forwarding)是一个非常重要的技术,广泛应用于泛型编程、函数模板、构造函数转发等场景。它的目标是在模板函数中,将接收到的参数"原封不动"地转发给另一个函数,使其保留原来的类型特征(左值/右值、const/volatile 等)。
什么是完美转发?
完美转发指的是:将函数参数以其原始的值类别和修饰属性(如左值/右值,const 等),完整地传递给另一个函数,而不会在中间发生不必要的拷贝、移动或类型退化。
c++
template<typename T>
void wrapper(T&& arg) {
callee(std::forward<T>(arg)); // 完美转发
}
T&& 是转发引用(又称万能引用),std::forward<T>(arg) 实现了完美转发,callee() 将接收到正确的参数类型(左值/右值)。
完美转发的原理是什么?
结合模板类型推导 + 右值引用折叠规则 + std::forward 实现值类别的恢复和正确传递。
转发引用(Forwarding Reference)的形式如下:
c++
template<typename T>
void func(T&& arg);
被称为转发引用或万能引用(Universal Reference),其行为依赖于模板参数的推导结果。
std::forward<T>(arg) 的作用是:根据 T 的推导结果,恢复参数的原始值类别(左值或右值)。
c++
template<typename T>
T&& forward(typename std::remove_reference<T>::type& t) noexcept {
return static_cast<T&&>(t);
}
如果 T 是左值引用类型(如 int&),则 forward<T>(arg) 返回 int&。
如果 T 是非引用(如 int),则 forward<T>(arg) 返回 int&&。
和 std::move 不同,std::forward 会根据模板推导结果判断左值/右值,而 std::move 总是转换为右值。
结合使用实现完美转发:
c++
template <typename T>
void wrapper(T&& arg) {
callee(std::forward<T>(arg)); // 完美转发
}
T 推导为传入参数的原始类型(包括引用修饰),T&& 变为转发引用(左值或右值),std::forward<T>(arg) 根据 T 的类型决定是否转换为右值,从而实现值类别的完整保留。
8. 运算符重载
在 C++ 中,运算符重载(Operator Overloading)是一种允许程序员为自定义类型(如类、结构体)定义特定运算符行为的机制。通过重载运算符,可以让用户自定义的类型像内置类型一样使用常见的运算符(如 +、==、[] 等),提高代码的可读性和直观性。
运算符重载本质上是函数重载的一种,可以为类定义一个带有特定名称的函数,使得某个运算符(如 +)在该类的对象之间使用时有自定义行为。
c++
class Point {
public:
int x, y;
Point(int x, int y) : x(x), y(y) {}
// 重载加法运算符
Point operator+(const Point& other) const {
return Point(x + other.x, y + other.y);
}
};
虽然 C++ 支持大多数运算符的重载,但有 6 个运算符是不能被重载的:
.,成员访问运算符(点运算符)。.*,成员指针访问运算符。::,作用域解析运算符。sizeof,类型大小运算符。typeid,类型信息运算符(RTTI)。?:,条件运算符(三目运算符)。
这些运算符是语言的核心部分,它们在编译器层面具有特殊语义,无法通过函数重载的形式改变其行为。
重载不能改变运算符的优先级或结合性。
运算符重载应遵循直觉语义,避免造成困惑。
某些运算符(如 =、[]、(), ->)只能作为成员函数重载。
不会自动继承基类的运算符重载,需手动定义或使用 using。
9. auto 关键字
在 C++11 中,引入了 auto 关键字,大大简化了变量声明的语法,尤其在涉及复杂类型(如迭代器、函数返回值等)时,使代码更清晰、简洁。auto 让编译器在编译时自动推导变量的类型,无需程序员显式写出完整类型名。
c++
auto x = 42; // 推导为 int
auto y = 3.14; // 推导为 double
auto str = "hello"; // 推导为 const char*
auto vec = std::vector<int>{1, 2, 3}; // 推导为 std::vector<int>
C++11 中
auto是怎么实现类型推导的?
C++11 中的 auto 类型推导与模板类型推导机制几乎一致,其核心原理是:编译器在编译阶段根据初始化表达式的类型推导出变量的实际类型。
auto 默认会去掉引用和 const,除非显式加上 &、const 等。
10. 回调函数
在程序设计中,回调函数(Callback Function)是一个非常重要的概念,尤其在异步编程、事件驱动、框架设计、函数式编程等场景中非常常见。
回调函数指的是:一个函数作为参数传递给另一个函数,并在特定时机被调用(回调)的函数。
它不是由调用者直接调用,而是由另一个函数在某一事件或条件发生时自动调用。可以是普通函数、Lambda 表达式、函数指针、std::function 等。
c++
void doSomething(int x, void(*callback)(int)) {
std::cout << "Processing " << x << std::endl;
callback(x); // 回调函数被调用
}
void onFinished(int result) {
std::cout << "Callback: result is " << result << std::endl;
}
int main() {
doSomething(42, onFinished); // 把函数作为参数传入
}
为什么要有回调函数?
回调函数可以实现解耦,被调用模块不需要知道具体的处理逻辑,只需在合适时机调用回调。比如:系统库调用用户自定义函数。
回调函数灵活性强,通过传入不同的回调函数,实现不同的逻辑处理。
回调函数支持异步编程,异步操作完成后可以通知调用者(如网络请求、IO 操作等)。
回调函数是事件驱动机制核心,GUI 编程、游戏开发、消息系统中广泛使用。
回调函数的本质是什么?
回调函数的本质是函数作为"第一类对象"传递和调用的一种形式。
函数可以像变量一样被传递、存储、调用。在 C++ 中,这种能力通过函数指针、Lambda 表达式、std::function 实现。
C++ 实现回调的常见方式:
- 函数指针,
void (*callback)(int)。 - 函数对象(仿函数),
struct MyCallback { void operator()(int); };。 - Lambda 表达式,
[](int x){ ... }。 std::function,std::function<void(int)>,支持所有可调用对象。
11. 函数重载、重写和隐藏
在 C++ 中,函数重载(Overload)、重写(Override)、隐藏(Hide)是三个易混但本质不同的语法机制,它们都与"函数名相同"相关,但作用范围、发生条件、语义含义完全不同。
(1)函数重载是指在同一作用域中,函数名相同,但参数列表不同(参数类型、个数或顺序不同)的多个函数共存。注意,返回值类型不参与判断。
c++
void print(int x);
void print(double x);
void print(const std::string& str);
重载允许根据所提供的参数不同来调用不同的函数。它主要在以下情况下使用:
- 方法具有相同的名称。
- 方法具有不同的参数类型或参数数量。
- 返回类型可以相同或不同。
- 同一作用域,比如都是一个类的成员函数,或者都是全局函数。
(2)函数重写(覆盖)是指子类中重新定义一个与基类中虚函数具有相同签名的函数,以实现多态行为。
c++
cpp
class Base {
public:
virtual void speak() {
std::cout << "Base speak" << std::endl;
}
};
class Derived : public Base {
public:
void speak() override { // 重写
std::cout << "Derived speak" << std::endl;
}
};
当派生类需要改变或扩展基类方法的功能时,就需要用到重写,需要满足条件:
- 方法具有相同的名称。
- 方法具有相同的参数类型和数量。
- 方法具有相同的返回类型。
- 重写的基类中被重写的函数必须有virtual修饰。
- 重写主要在继承关系的类之间发生。
(3)函数隐藏是指子类中定义了与基类同名函数(不管参数是否相同),会隐藏基类中所有同名函数,导致名称查找只在子类中进行。
c++
class Base {
public:
void show(int x) {
std::cout << "Base show(int)" << std::endl;
}
};
class Derived : public Base {
public:
void show(double x) { // 隐藏了 Base::show(int)
std::cout << "Derived show(double)" << std::endl;
}
};
总结如下:
- 函数重载:同一作用域中,函数名相同,参数不同,编译器根据参数选择正确函数。
- 函数重写:子类中定义与基类虚函数签名相同的函数,实现多态行为,运行时绑定。
- 函数隐藏:子类中出现与基类同名函数时,隐藏基类中所有同名函数,不论参数是否相同。