Functions⚓︎
约 7563 个字 486 行代码 预计阅读时间 44 分钟
Parameter Passing⚓︎
一般情况下,函数是按值传递(pass-by-value) 的。此时函数参数以实参拷贝为初值,而这些拷贝则是通过对象的拷贝构造函数生成的,这使得按值传递的成本较高。
例子
class Person {
public:
Person();
virtual ~Person();
...
private:
std::string name;
std::string address;
};
class Student : public Person {
public:
Student();
~Student();
private:
std::string schoolName;
std::string schoolAddress;
}
考虑以下代码:
高亮处函数被调用时,会有多少拷贝构造函数和析构函数的调用呢?
6 次拷贝构造函数 + 6 次析构函数!
以拷贝构造函数为例:
- 按值传入
plato
时显然会调用一次Student
类的拷贝构造函数 - 而调用
Student
类的拷贝构造函数的时候会用到基类Person
的拷贝构造函数 - 此外,
Student
的 2 个字段以及Person
的 2 个字段在创建时也要调用各自的拷贝构造函数
有多少(拷贝)构造函数调用,就应该有多少析构函数被调用。
好消息是,我们能够回避这些拷贝构造函数和析构函数——采用按常量引用传递(pass by reference-to-const) 的方式,传递的就是实参本身而无需创建副本;并且 const
修饰符表明函数不能对参数进行修改,所以也就无需担心改动实参的问题了。对于上面的例子,函数声明可以改为:bool validateStudent(const Student& s);
。
此外,按常量引用传递还可以规避切片问题(slicing problem):当派生类对象以按值传递给一个基类对象时,调用的是基类的拷贝构造函数,这导致派生类特有的部分被丢弃,仅保留了其中的基类成分。
例子
考虑以下类:
class Window {
public:
...
std::string name() const;
virtual void display() const;
};
class WindowWithScrollBars : public Window {
public:
...
virtual void display() const;
};
现在有一个打印窗口名称的函数:
如果将一个 WindowWithScrollBars
的对象传给该函数:
那么该函数调用的不是 WindowWithScrollBars
的 display
方法,而是 Window
的 display
方法——这就是一种切片问题。
解决方法是将按值传递改为按常量引用传递:
注意,请不要误以为按常量引用传递总是优于按值传递。对于内置类型(int
、double
之类的
Function Return⚓︎
一般而言,函数有两种创建新对象的途径:在栈(stack) 空间上或在堆(heap) 空间上创建。
- 其中局部变量就是在栈空间上创建的。在函数退出前,所有在函数内部创建的局部变量都将被销毁。如果尝试返回一个指向局部变量的引用,那么函数结束后,返回的引用实际上指向了一块没有任何意义的内存空间,这就带来了未定义行为(悬挂指针 / 野指针
) 。 - 而用
new
创建的对象就是基于堆空间的。如果函数返回一个指向这类对象的引用,那么就会带来一个问题:谁该对该对象执行delete
呢? (所有权不明确) - 另外,也不要尝试返回一个局部静态(
static
)对象。因为这样的对象存在于整个程序的生命周期,所以如果有多个地方需要用到这个对象,实际上指向的都是同一个对象。如果用户不清楚这一层的话,就会产生不符预期的后果。
实际上,像指针、引用这种能够代表对象内部数据的东西,在 C++ 语境下,通常被统称为句柄(handles)。它是一个广义的术语,指代了任何可以用来访问或操作另一个实体(如对象、资源、文件等)的标识符或引用。
总之一句话:除非自己很肯定,否则就应该让函数返回一个完整的对象,而不是指向该对象的句柄。
Overloaded Functions⚓︎
在 C++ 中,构造函数的名称必须与类名完全相同。有时,我们既需要一个不带参数的默认构造函数,也可能需要一个或多个接受参数的构造函数来满足不同的初始化需求。这两种情况可以通过函数重载(function overloading) 完美实现:它允许我们定义多个同名函数,只要它们的参数列表在类型、数量或顺序上有所不同即可。编译器会根据调用时提供的参数情况,自动选择最匹配的函数版本,并且能够进行适当的自动类型转换。
例子
注
在函数重载解析时,类型转换遵循一定的优先级规则:
- 精确匹配优于需要类型提升的匹配
- 类型提升优于标准类型转换
- 标准类型转换优于用户自定义类型转换
Delegating Constructors⚓︎
在设计类时,有时会遇到多个构造函数中包含了大量重复的初始化代码的情况,这通常被认为是一种不良实践,即代码重复(code duplication)。代码重复会增加维护难度,并可能引入不一致性。
C++11 引入了委托构造函数(delegating constructors) 来解决这个问题:一个构造函数可以在其成员初始化列表中调用同一个类的另一个构造函数,从而将共通的初始化任务“委托”给它。这样可以形成一个委托链。
例子
class class_c {
public:
int max;
int min;
int middle;
class_c(int my_max) {
max = my_max > 0 ? my_max : 10;
min = 1;
middle = (max + min) / 2;
std::cout << "Delegated to: class_c(int)" << std::endl;
}
// 此构造函数委托给 class_c(int)
class_c(int my_max, int my_min) : class_c(my_max) { // 委托调用
// max 已经由 class_c(my_max) 初始化
min = my_min > 0 && my_min < max ? my_min : 1;
middle = (this->max + this->min) / 2; // 使用 this-> 明确访问成员
std::cout << "Delegated to: class_c(int, int)" << std::endl;
}
// 此构造函数委托给 class_c(int, int)
class_c(int my_max, int my_min, int my_middle) : class_c(my_max, my_min) { // 委托调用
// max 和 min 已经由 class_c(my_max, my_min) 初始化
middle = my_middle < this->max && my_middle > this->min ? my_middle : (this->max + this->min) / 2;
std::cout << "Called: class_c(int, int, int)" << std::endl;
}
};
Default Arguments⚓︎
默认参数(default arguments) 是在函数声明时为参数指定的值。如果在函数调用时没有为这些带有默认值的参数提供实参,编译器会自动使用声明中提供的默认值。
例子
关键规则
在定义函数的参数列表时,默认参数必须从右向左依次指定。也就是说,如果某个参数有默认值,则其右侧的所有参数也必须有默认值。
注意
- 默认参数应当在函数的声明(原型)中指定。如果一个函数既有声明也有定义(分别在不同位置
) ,默认参数不应在定义中重复指定。 - 如果函数的定义即其首次声明,则默认参数在定义中指定。
- 最佳实践是将带有默认参数的函数声明放在头文件中。
Inline Functions⚓︎
常规函数的问题:函数调用的开销
常规的函数调用并非没有成本。在程序执行跳转到函数代码并返回之前,系统需要进行一系列准备工作,例如:
- 将参数压入栈中
- 将返回地址压入栈中
- 跳转到函数代码位置
- (函数执行完毕后)准备返回值
- 恢复栈,弹出之前压入的内容,并将控制权交还给调用者
学过计组的话对这部分应该比较熟悉。
这些操作都需要消耗处理时间,对于非常短小且频繁调用的函数,这些开销可能会变得很大。
内联函数(inline functions) 为我们提供了一种减少函数调用开销的机制。当编译器处理一个内联函数调用时,它会尝试直接将函数的代码体“嵌入”或“展开”到调用点,而不是执行常规的函数调用跳转。这类似于预处理器宏的文本替换,但内联函数是真正的函数,具备类型检查等优势。
通过在函数声明和定义前加上 inline
关键字来建议编译器将其作为内联函数处理:
- 如果內联函数在类外定义,声明和定义处都要使用
inline
关键字。 - 函数的定义对于编译器来说必须是可见的,以便它能在调用点进行展开。因此,内联函数的定义通常被放置在头文件中。这样,任何
#include
该头文件的源文件都能获取到函数定义,从而允许编译器进行内联。- 不用担心这会导致多重定义链接错误,
inline
关键字会处理好这个问题。
- 不用担心这会导致多重定义链接错误,
类内的隐式內联
即使没有显式使用 inline
关键字,任何在类声明内部定义的成员函数都会被自动视为内联函数。
陷阱
如果一个(成员)函数被声明为 inline
(或因定义在类内部而隐式内联inline
关键字。
内联函数的权衡
- 优点:
- 减少函数调用的开销,可能提高执行速度,尤其对于小型、频繁调用的函数。
- 比 C 语言的宏更安全,因为宏不进行参数类型检查,容易出错,而内联函数会进行类型检查。
- 缺点:
- 如果函数体较大,内联会使得调用处的代码膨胀,导致最终生成的可执行文件体积增大(这是一种典型的以空间换时间的策略
) 。 - 由于内联函数在编译时被展开,调试器可能无法像普通函数那样设置断点或单步执行内联函数的代码。
- 如果函数体较大,内联会使得调用处的代码膨胀,导致最终生成的可执行文件体积增大(这是一种典型的以空间换时间的策略
inline
是请求而非命令
需要注意的是,inline
关键字仅仅是对编译器的一个建议;编译器并不一定会采纳这个建议,它可能会根据自己的优化策略来决定是否真的内联一个函数:
- 如果函数体过大,编译器可能会拒绝内联
- 如果函数是递归的(直接或间接调用自身
) ,通常不会被完全内联(尽管某些编译器可能展开几层递归) - 其他复杂的因素,如函数中包含循环、虚函数调用等,也可能影响编译器的决定
- 有些编译器即使没有
inline
关键字,也可能自动内联一些简单的函数(特别是启用了优化选项时) - 取
inline
函数的地址(函数指针)会导致编译器强制为其生成一个非内联的函数体
何时使用内联函数?
- 适用场景:最适合内联的是那些函数体非常小(比如只有一两行代码)且被频繁调用的函数,例如简单的 getter 和修改器 setter 函数
- 不太适用的场景:
- 函数体较大的函数(例如超过 20 行代码的粗略标准,但这并非硬性规定
) :内联它们可能会显著增加代码体积,而性能提升可能微不足道,甚至可能因代码缓存效率降低而变差 - 递归函数
- 包含复杂逻辑(如循环、大量分支)的函数
- 包含虚函数调用的函数
- 函数体较大的函数(例如超过 20 行代码的粗略标准,但这并非硬性规定
- 在实践中,我们应审慎地使用
inline
:- 对于定义在类声明内部的简短成员函数,它们已经是隐式内联的,通常无需额外操作
- 对于类外的函数,只有当我们确定某个小型、频繁调用的函数确实是性能提升的瓶颈时,才考虑将其显式声明为
inline
- 过度使用
inline
可能不会带来好处,甚至可能适得其反——相信现代编译器的优化能力
注意
以下内容为补充内容,并不是 OOP 课程的考点
Lambda⚓︎
Lambda 函数是 C++11 标准引入的一项强大特性,允许我们在代码中定义匿名函数对象。Lambda 函数特别适用于需要简短、一次性使用函数的场景,例如作为算法的参数(如std::sort
, std::find_if
Lambda 函数的基本语法结构如下:
[capture_clause](parameters) mutable_specifier exception_specifier -> return_type {
// function body
}
[capture_clause]
:捕获子句,定义了 Lambda 函数可以访问其定义作用域内的哪些变量,以及如何访问它们(按值或按引用) 。具体可分为以下几种情况:[]
:不捕获任何外部变量[var]
:按值捕获变量var
;在 Lambda 函数内部,var
是一个副本,对其修改不会影响外部的var
[&var]
:按引用捕获变量var
;在 Lambda 函数内部对var
的修改会影响外部的var
[=]
:按值捕获所有在 Lambda 定义时可见的局部变量(包括this
,如果 Lambda 在成员函数内部定义)[&]
:按引用捕获所有在 Lambda 定义时可见的局部变量(包括this
,如果 Lambda 在成员函数内部定义) 。[this]
:按值捕获当前对象的this
指针。允许访问类的成员变量和成员函数。[&, var]
:按引用捕获所有变量,但按值捕获var
。[=, &var]
:按值捕获所有变量,但按引用捕获var
。- C++14 引入了初始化捕获(init-capture) 或称为广义 Lambda 捕获(generalized lambda capture),允许我们在捕获子句中声明和初始化新的变量,这些变量仅在 Lambda 内部可见
- 例如:
[x = std::move(my_large_object)](){ /* ... */ }
或[val = compute_value()](){ /* ... */ }
- 例如:
(parameters)
:参数列表,与普通函数的参数列表类似,定义了 Lambda 函数接受的参数,可以为空- 从 C++14 开始,参数类型可以使用
auto
实现泛型 Lambda
- 从 C++14 开始,参数类型可以使用
mutable_specifier
:可变说明符(可选)- 默认情况下,按值捕获的变量在 Lambda 函数体内部是
const
的;如果想在 Lambda 函数内部修改按值捕获的变量的副本,需要使用mutable
关键字。 - 如果 Lambda 没有捕获任何变量,或者所有捕获都是按引用进行的,则
mutable
通常是没有必要用到的
- 默认情况下,按值捕获的变量在 Lambda 函数体内部是
exception_specifier
:异常说明符(可选) ,用于指定 Lambda 函数可能抛出的异常类型- 例如
noexcept
表示 Lambda 不会抛出任何异常
- 例如
-> return_type
:返回类型(可选) ,指定 Lambda 函数的返回类型- 在很多情况下,编译器可以自动推断返回类型,此时可以省略
- 如果 Lambda 函数体包含多个
return
语句,或者return
语句的表达式类型不明显,或者你想显式指定一个不同的返回类型,则需要显式指定返回类型 - 如果函数体没有
return
语句,或者只有一个空的return;
,则返回类型被推断为void
{ // function body }
:函数体,包含 Lambda 函数的实际执行代码
例子
#include <iostream>
#include <vector>
#include <algorithm>
#include <string>
int main() {
// 1. 基本的 Lambda,无捕获,无参数,隐式返回 void
[] { std::cout << "Hello from Lambda!" << std::endl; }(); // 定义并立即调用
// 2. 带参数的 Lambda
auto add = [](int a, int b) -> int {
return a + b;
};
std::cout << "Sum: " << add(5, 3) << std::endl; // 输出: Sum: 8
// 3. 自动返回类型推断(省略 -> int)
auto multiply = [](double a, double b) {
return a * b;
};
std::cout << "Product: " << multiply(2.5, 4.0) << std::endl; // 输出: Product: 10.0
// 4. 捕获子句示例
int x = 10;
int y = 20;
// 按值捕获 x 和 y
auto capture_by_value = [x, y]() {
// x 和 y 在这里是副本,修改它们不会影响外部的 x 和 y
std::cout << "Inside by_value: x = " << x << ", y = " << y << std::endl;
// x = 100; // 编译错误,因为 x 是 const(除非使用 mutable)
};
capture_by_value();
// 按引用捕获 x 和 y
auto capture_by_reference = [&x, &y]() {
x = 100; // 修改会影响外部的 x
y = 200; // 修改会影响外部的 y
std::cout << "Inside by_reference: x = " << x << ", y = " << y << std::endl;
};
capture_by_reference();
std::cout << "Outside after by_reference: x = " << x << ", y = " << y << std::endl; // 输出: x = 100, y = 200
// 隐式按值捕获所有可见变量
int z = 30;
auto capture_all_by_value = [=]() {
std::cout << "Inside all_by_value: x = " << x << ", y = " << y << ", z = " << z << std::endl;
};
capture_all_by_value();
// 隐式按引用捕获所有可见变量
int w = 40;
auto capture_all_by_reference = [&]() {
x = 1; y = 2; z = 3; w = 4;
std::cout << "Inside all_by_reference: x = " << x << ", y = " << y << ", z = " << z << ", w = " << w << std::endl;
};
capture_all_by_reference();
std::cout << "Outside after all_by_reference: x = " << x << ", y = " << y << ", z = " << z << ", w = " << w << std::endl;
// 5. mutable 关键字
int val = 5;
auto mutable_lambda = [val]() mutable {
val = 10; // 现在可以修改按值捕获的 val 的副本
std::cout << "Inside mutable_lambda, val = " << val << std::endl;
};
mutable_lambda();
std::cout << "Outside mutable_lambda, val = " << val << std::endl; // 输出: 5(外部的 val 未改变)
// 6. 在 STL 算法中使用 Lambda
std::vector<int> numbers = {1, 5, 2, 8, 3, 7};
int threshold = 4;
// 计算大于 threshold 的元素数量
int count = std::count_if(numbers.begin(), numbers.end(), [threshold](int n) {
return n > threshold;
});
std::cout << "Numbers greater than " << threshold << ": " << count << std::endl; // 输出: 3
// 打印所有元素
std::for_each(numbers.begin(), numbers.end(), [](int n) {
std::cout << n << " ";
});
std::cout << std::endl;
// 7. C++14: 泛型 Lambda(使用 auto 作为参数类型)
auto generic_add = [](auto a, auto b) {
return a + b;
};
std::cout << "Generic add (int): " << generic_add(10, 20) << std::endl;
std::cout << "Generic add (double): " << generic_add(1.5, 2.5) << std::endl;
std::cout << "Generic add (string): " << generic_add(std::string("Hello, "), std::string("World!")) << std::endl;
// 8. C++14: 初始化捕获(广义 Lambda 捕获)
std::string message = "Original message";
auto generalized_capture_lambda = [captured_message = std::move(message)]() {
std::cout << "Inside generalized_capture_lambda: " << captured_message << std::endl;
// message 在这里已经被移走,通常不应再使用(除非重新赋值)
};
generalized_capture_lambda();
std::cout << "After generalized_capture_lambda, message: \"" << message << "\"" << std::endl; // message 可能是空的或处于未定义状态
return 0;
}
我们来简单认识一下 Lambda 的本质:在底层,编译器通常会将 Lambda 函数转换成一个匿名的函数对象(也称为闭包operator()
,其函数体就是 Lambda 的函数体。捕获的变量会成为这个匿名类的成员变量。
例子
Lambda [x, &y](){ /* ... */ }
可能会被编译器大致转换成类似这样的东西:
函子(函数对象)
函子(functor) 是一个行为类似函数的对象,任何重载了函数调用运算符 operator()
的类或结构体的实例都可以被称为函子。当我们创建一个函子类的对象,并对该对象使用 ()
运算符时,实际上是在调用该对象重载的 operator()
成员函数。
例子
#include <iostream>
#include <vector>
#include <algorithm>
// 定义一个函子
class Greeter {
private:
std::string greeting_prefix; // 函子可以拥有状态(成员变量)
public:
Greeter(const std::string& prefix) : greeting_prefix(prefix) {}
// 重载 operator(),使其可以像函数一样被调用
void operator()(const std::string& name) const { // 通常 operator() 会被声明为 const
std::cout << greeting_prefix << ", " << name << "!" << std::endl;
}
};
class SumFunctor {
private:
int sum_so_far;
public:
SumFunctor() : sum_so_far(0) {}
void operator()(int x) {
sum_so_far += x;
}
int getSum() const {
return sum_so_far;
}
};
int main() {
// 创建函子对象
Greeter hello_greeter("Hello");
Greeter hi_greeter("Hi");
//像调用函数一样调用函子对象
hello_greeter("Alice"); // 输出: Hello, Alice!
hi_greeter("Bob"); // 输出: Hi, Bob!
std::vector<int> numbers = {1, 2, 3, 4, 5};
SumFunctor summer;
// 将函子作为参数传递给 STL 算法
// std::for_each 会对 numbers 中的每个元素调用 summer(element)
summer = std::for_each(numbers.begin(), numbers.end(), summer); // for_each 返回其函数对象参数
std::cout << "Sum calculated by Functor: " << summer.getSum() << std::endl; // 输出: Sum calculated by Functor: 15
return 0;
}
函子的优点有:
- 可以拥有状态:函子是对象,因此它们可以用成员变量来存储状态,这使得它们比普通的函数指针更强大,因为普通函数通常没有与之关联的状态
- 类型安全:函子是类类型,可以参与模板元编程和类型推导
- 可内联性:编译器通常能够更好地内联函子的
operator()
调用,从而提高性能,尤其是当函子被用于 STL 算法时
这里之所以要介绍函子,是因为前面介绍的 Lambda 表达式本质上是编译器为我们隐式定义和实例化的一个匿名函子类。我们通常认为 Lambda 表达式是函子的语法糖,因为 Lambda 表达式具有以下特征:
- 简洁性:定义一个完整的函子类通常需要编写更多的样板代码(类定义、构造函数、成员变量、
operator()
重载) ;Lambda 表达式提供了一种非常紧凑和内联的方式来达到同样的目的 - 匿名性:Lambda 通常是匿名的,所以我们不需要为这些一次性使用的小型函数对象取名字
- 局部性:Lambda 可以直接在需要它们的地方定义,使得代码逻辑更清晰,更容易理解上下文
比较函子和 Lambda 表达式
#include <vector>
#include <algorithm>
#include <iostream>
class DescendingCompare {
public:
bool operator()(int a, int b) const {
return a > b; // 降序
}
};
int main() {
std::vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6};
std::sort(v.begin(), v.end(), DescendingCompare()); // 传递函子对象
for (int n : v) {
std::cout << n << " "; // 输出: 9 6 5 4 3 2 1 1
}
std::cout << std::endl;
return 0;
}
#include <vector>
#include <algorithm>
#include <iostream>
int main() {
std::vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6};
std::sort(v.begin(), v.end(), [](int a, int b) { // 直接定义 Lambda 表达式
return a > b; // 降序
});
for (int n : v) {
std::cout << n << " "; // 输出: 9 6 5 4 3 2 1 1
}
std::cout << std::endl;
return 0;
}
Lambda 函数有以下特点:
- 简洁性 : 对于简短的函数,Lambda 表达式比定义一个完整的函数或函数对象类更简洁
- 局部性 : Lambda 可以定义在使用它们的地方,此时逻辑更集中,使得代码更易读
- 闭包 : 捕获子句使得 Lambda 可以“记住”其创建时的上下文环境,非常适合回调和事件处理
- STL 算法 : Lambda 是 C++ 标准库算法的理想伴侣,可以轻松传递自定义的操作
- 异步编程 : 在多线程和异步任务中,Lambda 可以方便地封装要执行的代码块
注意事项
-
悬空引用:如果按引用捕获局部变量,并且 Lambda 的生命周期超过了这些局部变量的生命周期,那么 Lambda 内部的引用将变成悬空引用,导致未定义行为
-
this
指针的捕获:- 在成员函数内部,
[=]
会按值捕获this
指针(实际上是复制一份指针) [&]
会按引用捕获this
指针(这通常没有太大意义,因为this
本身就是个指针)[this]
显式按值捕获 this 指针- C++17 引入了
[*this]
,允许按值捕获当前对象(即创建对象的副本作为 Lambda 的成员) ,这在异步调用中尤其有用,可以避免对象在 Lambda 执行前被销毁的问题
- 在成员函数内部,
- 性能:Lambda 通常非常高效,因为它们可以被内联,并且其类型是在编译时确定的
- 然而,如果 Lambda 被存储在
std::function
中,可能会有一些间接调用的开销
- 然而,如果 Lambda 被存储在
Ranges and Views⚓︎
问题
在 C++20 之前,我们通常使用一对迭代器来表示一个序列或元素范围,例如 container.begin()
和 container.end()
。来自标准库 <algorithm>
的函数(如 std::sort
std::for_each
)也通常接受这样的迭代器对作为输入。
这种基于迭代器对的方式虽然有效,但也存在一些不便之处:
- 冗余和易错:我们总是需要传递两个参数。如果这两个迭代器不匹配(例如,来自不同的容器,或者
begin
在end
之后) ,就可能导致运行时错误或未定义行为 - 表达力受限:直接用迭代器对来表达更复杂的数据操作会变得非常笨拙,通常需要编写多个循环或辅助函数
- 接口不统一:虽然大多数容器都有
begin()
和end()
成员函数,但对于 C 风格数组或某些自定义的序列类型,获取迭代器对的方式可能不同,这使得编写泛型代码时需要额外的适配
C++20 标准引入了 <ranges>
来解决这些问题。
从概念上讲,一个范围(range) 是一个单一的对象或实体,它封装了遍历一个元素序列所需的所有信息,这样我们无需分别处理序列的“开始”和“结束”,而是将整个“序列”作为一个整体来引用和操作。总结成一句话就是:一个范围本质上是对任何“可迭代序列”的统一抽象。
<ranges>
库通过以下方式实现了范围这一概念:
- 概念 (concepts):它定义了一系列的概念(如
std::ranges::range
,std::ranges::input_range
,std::ranges::view
等)- 如果一个类型满足了
std::ranges::range
概念,那么它就被认为是一个范围
- 如果一个类型满足了
- 统一的接口函数:它提供了如
std::ranges::begin()
和std::ranges::end()
这样的自由函数(非成员函数) ,这些函数可以用于任何满足范围概念的类型,从而提供了一种用于获取范围起点和终点的统一方式。
以下内容都可以算作范围:
- C++ 标准库容器:因为这些容器类型自然地满足了范围概念的要求(它们有 begin() 和 end() 成员,或者可以通过 std::ranges::begin/end 获取)
- C 风格数组:因为数组的大小是已知的,可以确定其边界,并且
std::ranges::begin/end
也为它们提供了支持 - 由一对迭代器定义的序列:传统的迭代器对可以通过
std::ranges::subrange
包装成一个范围对象 - 用户自定义的、符合范围概念的类型:如果用户创建了一个类,并使其满足
std::ranges::range
,那么这个类的实例也算一个范围。 - 由视图(views)(后面马上介绍)生成的新范围:视图是
<ranges>
库的重要组成部分,它们本身也是范围,代表对其他范围的转换或筛选
例子
视图(views) 是 <ranges>
库的核心组成部分,它们代表了对一个底层序列的某种非拥有、惰性求值的操作或转换。视图具有以下显著特征:
- 非拥有(non-owning):视图本身不存储元素数据,它们只是“看待”或“引用”底层数据的一种方式;如果底层数据被销毁或修改,视图可能会变得无效或反映这些更改
- 惰性求值(lazily evaluated):对视图的操作通常不会立即执行,它们只有在实际需要结果时(例如,当迭代视图或将其传递给一个消耗数据的算法时)才会被求值;这可以带来显著的性能提升,特别是当处理大型数据集或复杂的转换链时,因为只有实际需要的元素才会被处理。
- 轻量级(lightweight):视图对象本身通常很小,创建和复制的开销很低,它们通常只存储指向底层范围的引用 / 迭代器以及一些配置参数。
- 可组合性(composable):这是视图最强大的特性,我们可以通过管道操作符
|
将多个视图适配器(view adaptors) 串联起来,形成一个复杂的数据处理流水线,而代码依然保持清晰易读。
<ranges>
库提供了一系列视图适配器,它们是接受一个或多个范围,并返回一个新视图的函数对象。它们通常位于 std::views
(或 std::ranges::views
)命名空间下。一些常用的视图适配器包括:
std::views::filter
:根据给定的谓词函数筛选元素std::views::transform
:对范围中的每个元素应用一个函数,并生成一个包含结果的新视图std::views::take(n)
:获取范围中的前n
个元素std::views::drop(n)
:跳过范围中的前n
个元素std::views::reverse
:反转范围中元素的顺序std::views::elements<n>
: (用于元组或类结构体范围)提取每个元组的第n
个元素std::views::keys
: (用于关联容器或类似结构的范围)提取键std::views::values
: (用于关联容器或类似结构的范围)提取值std::views::iota(start, end)
:生成一个从start
开始到end
(不含)的整数序列std::views::all(range)
:将range
转换为一个视图std::views::counted(iterator, count)
:从一个迭代器开始,取count
个元素构成一个视图
例子
假设我们有一个数字列表,我们想执行以下操作:
- 筛选出所有偶数
- 将这些偶数平方
- 取前 3 个结果
- 打印它们
#include <iostream>
#include <vector>
#include <ranges>
#include <algorithm> // for std::ranges::for_each
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto even_numbers = [](int n) { return n % 2 == 0; };
auto square = [](int n) { return n * n; };
// 使用管道操作符 | 组合视图
auto result_view = numbers
| std::views::filter(even_numbers) // 筛选偶数 {2, 4, 6, 8, 10}
| std::views::transform(square) // 平方 {4, 16, 36, 64, 100}
| std::views::take(3); // 取前3个 {4, 16, 36}
// 此时,上面的操作都是惰性定义的,并没有真正执行计算。
// 当我们迭代 result_view 时,计算才会按需发生。
std::cout << "Processed numbers: ";
for (int n : result_view) { // 迭代 result_view,触发计算
std::cout << n << " ";
}
std::cout << std::endl; // 输出: Processed numbers: 4 16 36
// 也可以直接传递给 ranges 算法
std::cout << "Processed numbers (using for_each): ";
std::ranges::for_each(numbers
| std::views::filter(even_numbers)
| std::views::transform(square)
| std::views::take(3),
[](int n){ std::cout << n << " "; });
std::cout << std::endl; // 输出: Processed numbers (using for_each): 4 16 36
return 0;
}
需要注意的是,result_view
本身不存储数字 4, 16, 36。它只是一个描述如何从原始 numbers
向量中获取这些值的“配方”或“指令集”。只有当我们开始迭代 result_view
时,这些操作才会按需执行。
下面简单总结一下范围和视图的优缺点:
-
优点:
- 简化迭代器操作:减少了直接操作迭代器的复杂性和潜在错误
- 更优的错误信息:受约束的算法(基于概念)可以在编译期提供更清晰的错误提示
- 高可读性的函数式语法:代码更易读,更接近声明式的函数式编程风格
-
缺点:
- 新特性,尚不完全成熟:作为较新的 C++ 特性,可能某些功能仍在发展和完善中
- 编译器支持可能不足:较老的编译器版本可能不支持或不完全支持范围和视图(但目前为止,主流现代编译器支持已相当不错)
- 可能存在性能开销:在某些情况下,与精心设计的传统循环相比,可能会有轻微的性能差异(尽管通常编译器优化后差异不大,且惰性求值反而可以带来性能优势)
Factory Functions⚓︎
注
以下内容完全由 Gemini 2.5 Flash 生成(当然我还是干了排版的活
工厂函数(factory functions) 是一种在编程中用于创建对象的函数。它封装了对象的创建逻辑,将客户端代码与具体类的实例化过程解耦。它通常会根据传入的参数或内部逻辑,决定创建哪种具体类型的对象。
为何要用工厂函数
在没有工厂函数的情况下,客户端代码通常会直接使用 new
关键字来创建对象:
// 没有工厂函数
class ProductA { /* ... */ };
class ProductB { /* ... */ };
// 客户端代码
ProductA* pA = new ProductA();
ProductB* pB = new ProductB();
这种直接创建对象的方式存在一些问题:
- ** 紧耦合 (tight coupling):客户端代码直接依赖于具体的类(
ProductA
,ProductB
) 。如果未来ProductA
的构造函数参数改变,或者需要替换成ProductC
,客户端代码就需要修改。 - 重复的创建逻辑:如果在多个地方需要创建相同类型的对象,并且创建过程比较复杂(例如,需要初始化多个成员,或者根据条件选择不同的子类
) ,那么这些创建逻辑就会在代码中重复出现。 - 违反开放 / 封闭原则 (ppen/closed principle):如果要添加新的产品类型,就需要修改所有创建该产品的地方。
而工厂函数解决了这些问题:
- 解耦:客户端代码不再直接依赖于具体的类,而是依赖于工厂函数。工厂函数负责知道如何创建对象。
- 封装创建逻辑:将复杂的对象创建过程封装在一个函数中,避免了重复代码。
- 易于扩展:当需要添加新的产品类型时,通常只需要修改工厂函数内部的逻辑,而不需要修改所有客户端代码。
- 隐藏实现细节:客户端不需要知道具体产品类的名称或构造细节。
例子
假设我们有一个图形绘制程序,需要创建不同形状的对象(圆形、矩形
// 抽象基类
class Shape {
public:
virtual void draw() = 0;
virtual ~Shape() = default;
};
// 具体产品类
class Circle : public Shape {
public:
void draw() override {
std::cout << "Drawing a Circle." << std::endl;
}
};
class Rectangle : public Shape {
public:
void draw() override {
std::cout << "Drawing a Rectangle." << std::endl;
}
};
// 客户端代码
void client_code(const std::string& type) {
Shape* shape = nullptr;
if (type == "circle") {
shape = new Circle(); // 直接创建
} else if (type == "rectangle") {
shape = new Rectangle(); // 直接创建
} else {
std::cout << "Unknown shape type." << std::endl;
return;
}
if (shape) {
shape->draw();
delete shape;
}
}
int main() {
client_code("circle");
client_code("rectangle");
client_code("triangle"); // 如果添加 Triangle,需要修改 client_code
return 0;
}
#include <iostream>
#include <string>
#include <memory> // For std::unique_ptr
// 抽象基类
class Shape {
public:
virtual void draw() = 0;
virtual ~Shape() = default;
};
// 具体产品类
class Circle : public Shape {
public:
void draw() override {
std::cout << "Drawing a Circle." << std::endl;
}
};
class Rectangle : public Shape {
public:
void draw() override {
std::cout << "Drawing a Rectangle." << std::endl;
}
};
// 工厂函数
// 返回智能指针以更好地管理内存
std::unique_ptr<Shape> createShape(const std::string& type) {
if (type == "circle") {
return std::make_unique<Circle>(); // 封装创建逻辑
} else if (type == "rectangle") {
return std::make_unique<Rectangle>(); // 封装创建逻辑
} else {
// 可以抛出异常,或者返回 nullptr
std::cerr << "Error: Unknown shape type '" << type << "'" << std::endl;
return nullptr;
}
}
// 客户端代码
void client_code_with_factory(const std::string& type) {
std::unique_ptr<Shape> shape = createShape(type); // 通过工厂函数获取对象
if (shape) {
shape->draw();
}
}
int main() {
client_code_with_factory("circle");
client_code_with_factory("rectangle");
client_code_with_factory("triangle"); // 如果添加 Triangle,只需修改 createShape 函数
return 0;
}
在这个例子中,createShape
就是一个工厂函数。客户端代码 client_code_with_factory
不再直接使用 new Circle()
或 new Rectangle()
,而是调用 createShape
函数。
工厂函数是许多更复杂的设计模式的基础:
-
简单工厂模式 (Simple Factory Pattern):
- 通常就是一个独立的函数(或一个静态方法
) ,如上面的createShape
。 - 它不是 GoF (Gang of Four) 设计模式中的一种,但它是一个非常常见的编程习惯。
- 通常就是一个独立的函数(或一个静态方法
-
工厂方法模式 (Factory Method Pattern):
- 这是一种 GoF 设计模式。
- 它将工厂函数抽象为一个虚方法,由子类来实现具体的对象创建。
- 通常涉及一个抽象的 Creator 类(声明工厂方法)和具体的 Concrete Creator 类(实现工厂方法
) 。 - 区别:简单工厂是一个函数,工厂方法是一个虚方法。
-
抽象工厂模式 (Abstract Factory Pattern):
- 这是一种 GoF 设计模式。
- 它提供一个接口,用于创建一系列相关或相互依赖的对象族,而无需指定它们具体的类。
- 通常涉及一个抽象工厂接口和多个具体工厂实现,每个具体工厂负责创建特定系列的产品。
- 区别:工厂方法创建单一产品,抽象工厂创建产品族。
最后总结一下:工厂函数 是一种简单而强大的技术,用于封装对象的创建逻辑,实现客户端代码与具体产品类的解耦。它是许多更复杂工厂模式的基础,也是面向对象设计中实现依赖倒置原则和开放 / 封闭原则的重要手段。
当你发现代码中存在以下情况时,可以考虑使用工厂函数:
- 客户端代码直接使用
new
关键字创建对象,并且创建逻辑复杂或重复。 - 客户端代码需要根据某些条件创建不同类型的对象。
- 你希望将对象的创建过程与使用过程分离,以便于维护和扩展。
评论区