现代C++金融编程指南:2 用户自定义的类
介绍
用户定义的类从一开始就是金融C++发展的支柱,同样,工具和数据,如债券、期权合约、利率曲线等,也可以自然地用对象表示。在 C++11 之前存在的一个非常有用的功能是圆括号 () 运算符的重载,它使对象能够用作或。正如我们将看到的,在我们可能需要找到函数的根的情况下,这特别方便,例如计算交易期权合约的隐含波动率。
一种较新的函子形式,称为 ,在 C++11 中引入。通常也称为lambda函数,或只是一个可以在其他函数中“动态”编写。除其他优点外,lambda 表达式还可用于将功能重构到单个位置,从而避免代码重复。在其他情况下,它们可用于更清楚地将计算逻辑与值(或对象)容器的迭代分开。与上面提到的函数对象一样,lambda 也可以作为函数参数传递。lambda 表达式和函数对象也将是 上下文中的关键,将在第四章中介绍。
是 C++11 标准的另一个主要补充。与由于按值初始化对象的成员数据而导致可能的性能下降相反,移动语义通常通过将构造函数输入参数的其各自的成员变量来显着提高效率。在使用独特的指针时也需要移动语义 - C++11中包含的另一个实质性改进 - 将在下一章中介绍。
本章还将讨论头文件中成员数据的类内初始化、默认和删除关键字以及运算符(通常也称为)。C++11 中的每一个添加都可以提高类设计的可靠性和可维护性。
布莱克-斯科尔斯类
Black-Scholes模型有时看起来像定量金融编程的“Hello World!”,但我们可以使用它来回顾有关编写用户定义类的一些要点,并介绍一些在C++11及之后出现的有用功能。
首先,回想一下布莱克-斯科尔斯定价公式适用于计算执行价格为 $X 美元的(股票)期权的价值$V美元,其中标的股票现货价格为 $S 美元,距离到期还有时间 $T 美元,以年为单位(或一年分数)。一个非常好的版本,非常适合在代码中实现,可以在(James)中找到,并在这里采用:
代表收益
在进行类设计之前,我们可以求助于枚举类来表示看涨期权或看跌期权。仍然可以将整数值与每种可能性相关联,同时仍确保任何两个作用域枚举类型保持不可比性。这样,我们可以“免费”获得值 $\pm 1$。
enum class PayoffType{ Call = 1, Put = -1};
我们可以通过将作用域枚举转换为整数来恢复 $\phi$ 的值(如第一章所述):
auto corp = PayoffType::Call; // "corp" = "call or put". . .int phi = static_cast<int>(corp); // phi = 1
然后,可以在定价公式中使用此值。
类声明
首先,我们可以提出以下声明:
class BlackScholes{public: BlackScholes(double strike, double spot, double rate, double time_to_exp, PayoffType pot); double operator()(double vol);private: void compute_norm_args_(double vol); // d_1 and d_2 double strike_, spot_, rate_, sigma_, time_to_exp_; PayoffType pot_; double d1_{ 0.0 }, d2_{ 0.0 };};
构造函数将接受除波动性之外的每个必需参数,() 运算符将使用波动性来计算和返回价格。这样做的原因是,它将允许我们稍后在数值计算隐含波动率时重用该类(引用Joshi 和Quantstart文章 - 参见尾注)。在这个例子中,我们假设没有股息($q$ = 0)。
compute_norm_args_(双倍卷)将计算$d_1$和$d_2$(d1_和d2_)值,这些值将用作标准正态CDF中的参数。
另一个需要注意的项是使用,以及将多个定义(或声明)放在一行上的选项,这在 C++11 中可用:
double d1_{ 0.0 }, d2_{ 0.0 };
由于标头中提供了类内成员初始化,构造函数初始值设定项列表只需负责依赖于输入参数的数据成员。当与用户定义的默认构造函数一起使用时,类内成员初始化提供了额外的好处,这将在本章后面讨论。
类实现
从构造函数开始,我们可以按如下方式实现它:
BlackScholes::BlackScholes(double strike, double spot, double rate, double time_to_exp, PayoffType pot) :strike_{ strike }, spot_{ spot }, rate_{ rate }, time_to_exp_{ time_to_exp }, pot_{ pot } {}
通常,最好确保在构造时初始化所有成员数据,并按声明顺序初始化每个成员变量。否则,它“可能很难看到与订单相关的错误”(核心指南)。
然后,期权的估值从圆括号运算符开始,将波动率作为输入。
double BlackScholes::operator()(double vol){ int phi = static_cast<int>(pot_); // (1) double opt_price = 0.0; if (time_to_exp_ > 0.0) // (2) { compute_norm_args_(vol); // (3) auto norm_cdf = <>(double x) -> double // (4) { return (1.0 + std::erf(x / std::numbers::sqrt2)) / 2.0; }; double nd_1 = norm_cdf(phi * d1_); // N(d1_) (5) double nd_2 = norm_cdf(phi * d2_); // N(d2_) (5) double disc_fctr = exp(-rate_ * time_to_exp_); // (6) opt_price = phi * (spot_ * nd_1 - disc_fctr * strike_ * nd_2); // (7) } else { opt_price = std::max(phi * (spot_ - strike_), 0.0); // <algorithm> // (8) } return opt_price;}
第一步 (1) 通过将 PayoffType::Call 或 PayoffType::P ut 分别强制转换为 1 或 -1 来确定 $\phi$ 的值。如果在到期前还有正时间($T - t > 0$),则布莱克-斯科尔斯价格的计算方法是首先 (2) 计算私有助手函数 compute_norm_args_.) 中的 $d_3$ 和 $d_1$ 值:
void BlackScholes::compute_norm_args_(double vol){ double numer = log(spot_ / strike_) + rate_ * time_to_exp_ + 0.5 * time_to_exp_ * vol * vol; double vol_sqrt_time = vol * sqrt(time_to_exp_); d1_ = numer / vol_sqrt_time; d2_ = d1_ - vol_sqrt_time;}
结果分别在d1_和d2_成员值上设置。$N(d_1)$ 和 $N(d_2)$ 可以方便地通过使用我们在 <cmath> 中可用的 erf(.) 来确定。
(沃尔夫勒姆数学世界)
我们可以通过蛮力实现这一点:
double nd_1 = 1.0 + std::erf( (phi * d1_) / std::numbers::sqrt2; // N(d1)double nd_2 = 1.0 + std::erf( (phi * d2_) / std::numbers::sqrt2; // N(d2)
但相反,我们可以通过将这个表达式分解成 (4) 来使代码更干净一些:
auto norm_cdf = <>(double x) -> double // (4){ return (1.0 + std::erf(x / std::numbers::sqrt2)) / 2.0;};
如上一章所述,lambda 表达式允许我们将本质上是一个函数的语句放在另一个函数中,这有助于消除重复的代码。它可以像任何其他函数 (5) 一样调用:
double nd_1 = norm_cdf(phi * d1_); // N(d1)double nd_2 = norm_cdf(phi * d2_); // N(d2)
最后,计算从 $T$ 回到时间 $t$ 的贴现因子 (6),然后计算布莱克-斯科尔斯期权价格 (7)。
如果期权到期,其价值只是原始收益 (8)。请注意,通过使用枚举类 PayoffType 并分配其指定的整数等价于 phi_ ,这使我们能够避免在 if / else 语句中多出几行。
例如,考虑到期时的价内 (ITM) 看涨期权,以及剩余时间的价外 (OTM) 看跌期权。
double strike = 75.0;auto corp = PayoffType::Call; // corp = "call or put"double spot = 100.0;double rate = 0.05;double vol = 0.25;double time_to_exp = 0.0;// ITM Call at expiration (time_to_exp = 0):BlackScholes bsc_itm_exp{ strike, spot, rate, time_to_exp, corp };double value = bsc_itm_exp(vol); // Result: 25 (payoff - intrinsic value only)// OTM put with time remaining:time_to_exp = 0.3;corp = PayoffType::Put;BlackScholes bsp_otm_tv{ strike, spot, rate, time_to_exp, corp };value = bsp_otm_tv(vol); // Result: 0.056 (time value only)
使用函子进行根查找:隐含波动率
函子变得非常方便的一种情况是在数值分析应用程序中,例如寻根。一个非常常见的情况是计算期权的隐含波动率,给定其市场价格,因为没有封闭式解决方案。这就是使BlackScholes中的函子成为期权波动率函数的动机。
例如,我们可以应用众所周知的割线方法来确定函数的根
$f = V(\sigma; \phi, S, r, q, t, T) - V_m$
其中 $V_m$ 是期权的观察市场价格。分号右侧的值可以被视为固定值(数学意义上的参数),同时允许 $\sigma$ 变化。这就是在构造BlackScholes对象时采用这些参数并将函子定义为依赖于波动性的动机。
割线方法表示,对于函数 $y = f(x)$,可以使用迭代找到 $f$ 的根
$x_{i+1} = x_i - f(x_i)\frac{x_i-x_{i-1}}{f(x_i) - f(x_{i-1})}$
给定对波动性的两个初步猜测($x_0$和$x_1$),前提是它收敛。
_
由于BlackScholes对象维护上述参数的状态,并根据波动率计算其期权值,这使得实现割线方法成为一项更简单、更清晰的任务,而不必将每个单独的期权估值参数带入计算隐含波动率的函数中。由于期权价值相对于波动率单调增加,因此只能有一个根。然后,我们可以实现割线方法来定位此根,如下所示:
double implied_volatility(BlackScholes bsc, double opt_mkt_price, double x0, double x1, double tol, unsigned max_iter) // 1{ // x: vol, y: BSc opt price - opt mkt price double y0 = bsc(x0) - opt_mkt_price; // 2 double y1 = bsc(x1) - opt_mkt_price; double impl_vol = 0.0; unsigned count_iter; for (count_iter = 0; count_iter <= max_iter; ++count_iter) // 3 { if (std::abs(x1 - x0) > tol) { impl_vol = x1 - (x1 - x0) * y1 / (y1 - y0); // Update x1 & x0: x0 = x1; x1 = impl_vol; y0 = y1; y1 = bsc(x1) - opt_mkt_price; } else { break; // 4 } } if (count_iter < max_iter) // 5 { return impl_vol; } else { return std::nan(" "); // std::nan(" ") in <cmath> // 6 }}
该函数首先接收一个 BlackScholes 对象,该对象包含上面讨论的 $V$ 参数,然后是我们想要计算隐含波动率的期权价格opt_mkt_price(通常在市场上观察到)。其余的输入值是两个初始猜测(x0和x1),然后是收敛容差值tol和最大迭代次数max_iter。(1)
初始函数值 y0 和 y1 可以通过首先在 x0 和 x1 中分别调用 bsc 对象上的函子,然后减去市场期权价格来获得。(2)
割线方法迭代将继续,直到达到最大迭代次数 (3),或者它收敛到每个给定公差 的隐含波动率并退出循环。(4)
退出循环后,程序会检查它是否发生在达到最大迭代次数之前,在这种情况下,算法已收敛到隐含波动率。然后,implied_volatility(.) 函数返回此值。(5)
如果循环在达到收敛之前达到最大迭代次数,则返回 std::nan(“”) (在 <cmath> 中),将“不是数字”表示为双精度类型,指示错误条件。(6)
我们可以进一步改进。请注意,我们在函数中有以下代码行:
double y0 = bsc(x0) - opt_mkt_price;double y1 = bsc(x1) - opt_mkt_price;// and then later inside the iteration...y0 = bsc(x0) - opt_mkt_price;y1 = bsc(x1) - opt_mkt_price;
虽然不是重复代码的可怕示例,但事故可能发生在更复杂的代码库中,例如类似于错误地修改这些语句之一,例如:
y0 = bsc(x0) + opt_mkt_price; // Wrong!
代码将编译并运行,但会导致不正确的结果。使用 lambda 表达式,我们可以将此代码重构到一个位置:
auto f = <&bsc, opt_mkt_price>(double x) -> double{ return bsc(x) - opt_mkt_price;};
然后,通过将上面的四行替换为,我们可以更好地确保结果是准确的:
double y0 = f(x0);double y1 = f(x1);// and then later inside the iteration...y0 = f(x0);y1 = f(x1);
移动语义和特殊类函数
移动语义是 C++11 中的另一个主要增强功能,它允许转移对象的所有权,而不是复制,这可能会导致不平凡的性能影响。这对于财务编程非常方便,因为在许多情况下,实际需要的是所有权的转让,而不是维护同一对象的两个副本。
随着移动语义的出现,出现了两个新的特殊类函数,即移动构造函数和移动赋值运算符,类似于复制和复制。此外,在 C++11 中还添加了禁用或声明特殊类函数作为其默认值的新方法和更直接的方法作为语言功能。
移动语义
在 C++11 之前的开发中最令人困惑的问题之一可能是非常简单的事情:将存储在对象上。例如,回测可能需要一组日内价格,然后在一组参数值上多次使用以识别优化值。包含价格数据的向量可能需要传递到封闭 Backtest 对象的构造函数中,然后作为子对象成员存储在封闭对象上。
一种方法是通过 const 引用传递向量并通过复制初始化prices_成员:
class Backtest{public: Backtest(const std::vector<double>& prices, . . .); void reset_parameters(. . .); . . .private std::vector<double> prices_; . . .};// Constructor implementation:Backtest::Backtest(const std::vector& prices, . . .) : prices_{prices}, . . . // object copy{. . .}
从好的方面来说,回测对象将拥有其prices_成员的独占。不利的一面是,这需要在初始化中复制对象。
在 C++11 之前,可以使用常用的替代方法避免此对象复制,通过 const 引用将子对象存储prices_成员:
class Backtest{public: Backtest(const std::vector& prices, . . .); void reset_parameters(. . .); . . .private const std::vector& prices_; . . .};// Constructor implementation:Backtest::Backtest(const std::vector& prices, . . .) : prices_{prices}, . . . // avoids object copy now{. . .}
虽然这种方法可能会防止由于对象复制而导致的性能下降,但它可能会引入其他问题,例如,从而有可能从 Backtest 对象外部修改prices_容器。在这种情况下,封闭的回测对象将被称为对象。
我们想要的是让 Backtest 对象保留其prices_成员的完全所有权,但没有对象副本的开销。现在,这可以通过 C++11 中引入来实现。
移动语义简介
在讨论应用于封闭对象上的子对象成员初始化的移动语义之前,第一个示例示例将介绍移动对象时含义的基础知识。假设我们重温上一章的 SimpleClass(现在使用单独的声明和实现编写):
class SimpleClass{ public: SimpleClass(int k); int get_val() const; void reset_val(int k); private: int k_;};// Implementation:SimpleClass::SimpleClass(int k):k_{k} {}int SimpleClass::get_val() const{ return k_; // Private member on SimpleClass}void SimpleClass::reset_val(int k){ k_ = k;}// Elsewhere, create an instance:SimpleClass sc{78};
如果我们需要一个相同的 SimpleClass,我们当然可以复制它:
SimpleClass another_sc{sc}; // Copy constructor
但是,这会带来对象复制的开销。相反,假设一旦创建了 sc,我们将不再需要 sc another_sc。在这种情况下,我们可以更有效地another_sc而不是复制。这是通过调用在 <utility> 标头中声明的标准库 std::move(.) 函数来实现的。
SimpleClass another_sc{ std::move(sc) }; // Move constructor
在这种情况下,如果我们访问 another_sc 上的 k_ 值,它将返回 78。
At a high level, the process for moving an object involves casting it to a temporary state called an rvalue reference first, which makes the object eligible to be moved. An rvalue reference is indicated by a double ampersand. The mechanics behind the scenes with rvalues and std::move(.) can be demonstrated again using a SimpleClass object:
SimpleClass sc2{ 15 }; // 1SimpleClass&& sc_rval = std::move(sc2); // 2SimpleClass sc2_move{sc_rval}; // 3
在步骤 (1) 中,构造另一个 SimpleClass 对象 sc2,k_初始化为 15。在步骤 (2) 中,将右值引用 sc_rval 分配给来自 std::move(.) 的结果。这意味着 sc2 现在处于可以移动的状态。所有权的实际转移发生在步骤 (3) 中,其中调用新 sc2_move 对象上的(默认)。与复制构造函数类似,编译器将提供默认的移动构造函数。此示例可以用少一行重述:
SimpleClass sc2{ 15 };SimpleClass sc2_move{std::move(sc2)};
在后台,一个右值引用由 std::move(.) 创建,并用作 sc_rval 的移动构造函数参数。
编译器还提供了默认的移动赋值运算符。因此,可以按如下方式完成上述相同的操作:
SimpleClass sc2_move = std::move(sc2);
请注意,尝试分配非 rvalue 对象(即未强制转换为临时可移动状态的对象)将不会编译:
SimpleClass sc3{ 33 };SimpleClass&& sc_err = sc3;
对象 sc3 被称为。引用是指我们在 C++11 之前知道和喜爱的相同引用,如下所示。这当然会编译:
SimpleClass& sc_lval = sc3;
右值和左值的主题可以很快进入冗长而复杂的讨论,因此这里介绍的内容保持简短,并且与手头的任务特别相关,即如何使用移动语义。如果你想更深入地阅读这个主题,建议在Lippman和Moo C++的入门文本中阅读第2.6.5节 - 理解。
最后,一个明显的问题可能是,上一个示例中的对象 sc2 会发生什么情况?
SimpleClass sc2_move = std::move(sc2);
答案是它仍然的状态,因为它不会自动销毁。在本例中,k_值可能会也可能不会保持 15,因为它的值不再得到保证,但可以重置。从技术上讲,该对象可以重复使用,例如:
sc2.reset_val(216);int sum = sc2.get_val() + 100; // sum = 316
与C++中的典型情况一样,灵活性和性能优先于安全性,编译器/标准库供应商之间的移动语义实现可能有所不同。因此,最终,由用户决定如何处理移自对象,例如将其重置为其默认状态,或者只是让它在超出范围时自行销毁。要遵循的一般规则是“只要您不对它们的价值做出任何假设,您仍然可以(重新)使用它们”。(Josuttis-Move),摘要,第2.6节,第33页)。但是,同一来源 (Josuttis-Move) 指出,建议的做法是通常不应重用移自对象。对于本书中的例子,我们将采用这种做法,并假设移动的物体将用于销毁。
使用移动语义传递函数参数
It is possible to pass function arguments by move. Recalling that std::move(.) returns an rvalue reference, the function parameter will also be an rvalue reference, again indicated by the double ampersand. As a first example, we could have a function that takes in an rvalue to a SimpleClass :
int square_k(SimpleClass&& sc){ return sc.get_val() * sc.get_val();}// Use the function:SimpleClass sc_to_square{ 2 };int squared_result = square_k(std::move(sc_to_square)); // Returns 4
这实际上对我们来说并没有多大作用,因为const SimpleClass&参数将达到相同的结果,并且会更简单。然而,正如我们之前在隐含波动率示例中所看到的那样,我们不能将BlackScholes参数作为常量参考,因为它的vol_成员必须是可修改的,以便迭代割线方法。在这种情况下,有一个优势,因为我们可以通过消除按值传递(和对象复制)并将其替换为移动语义来获得相同的结果:
double implied_volatility_with_move(BlackScholes&& bsc, double opt_mkt_price, double x0, double x1, double tol, unsigned max_iter){ auto f = <&bsc, opt_mkt_price>(double x) -> double { return bsc(x) - opt_mkt_price; }; double y0 = f(x0); double y1 = f(x1); . . . (Code exactly the same as in the previous implied_volatility(.) example... if (count_iter < max_iter) { return impl_vol; } else { return std::nan(" "); // std::nan(" ") in <cmath> }}
通过使用 std::move(.) 传递 BlackScholes 参数,结果将是相同的,但现在没有复制对象的开销。
使用 std::move(.) 初始化构造函数参数
现在,这让我们回到了开头提到的动机,即找到一种更有效的方法来初始化封闭对象上的子对象成员,而后者仍然保留前者的独占所有权,从而避免了与 const 引用成员相关的问题。
现在,我们只看一个简单的例子来了解它是如何工作的,其中有一个 SimpleClass 子对象成员。在下一章中,将介绍一个财务示例。
回到我们当前的示例,可以通过常量引用以及右值和 std::move(.) 来设计一个封闭类,通过包含两个构造函数,以“旧方式”接受构造函数参数:
class Enclose{public: Enclose(const SimpleClass& sc); // Constructor 1 Enclose(SimpleClass&& sc); // Constructor 2private: SimpleClass sc_;};// Constructor implementations:Enclose::Enclose(const SimpleClass& sc) :sc_{ sc } {} // Constructor 1Enclose::Enclose(SimpleClass&& sc) :sc_{ std::move(sc) } {} // Constructor 2
在第一个构造函数的情况下,这并不是什么新鲜事:
SimpleClass sc_enc{ 100 };Enclose ec{ sc_enc };
sc_enc参数通过 const 引用传递,然后在子对象成员sc_的初始化中创建一个完整的对象副本。
或者,可以通过使用 std::move(.) 将右值引用传递给sc_enc来避免对象复制,然后初始化sc_会导致 SimpleClass 上的移动构造函数被调用一次。
但是,已经出现了一种通常首选的方法,该方法仅使用单个构造函数即可产生基本等效的结果。在这种情况下,sc 参数被写入为按值接收其参数,但成员初始化是通过 move 执行的:
class EncloseSingleConstructor{public: EncloseSingleConstructor(SimpleClass sc);private: SimpleClass sc_;};// Constructor implementation:EncloseSingleConstructor::EncloseSingleConstructor(SimpleClass sc):sc_{ std::move(sc) }{}
天真地进行:
SimpleClass sc_enc_2{ 200 };EncloseSingleConstructor ec{ sc_enc_2 };
这会导致在绑定到构造函数参数时生成一个sc_enc_2副本,但sc_的初始化是通过 move 完成的。在性能方面,这与通过值(副本)初始化sc_成员的 const 引用构造函数参数相当。
然后,我们可以通过传递右值引用来做得更好:
EncloseSingleConstructor ec_move{ std::move(sc_enc_2) };
在这种情况下,有两个移动操作。首先是在 sc 构造函数参数上调用移动构造函数时,其次是在初始化 sc_ 成员时调用移动。由于移动操作非常便宜,因此与具有显式右值引用参数的原始 Enclose 类上的构造函数相比,这不会显著影响性能。
这里的结果是,使用移动语义初始化子对象成员将比使用与值语义关联的对象副本便宜得多。
考虑到移动语义可以获得的效率,人们可能会试图通过移动从函数中返回对象;例如:
// Don't do this:SimpleClass f(int n){ return std::move({n});}// or this:SimpleClass g(int n){ SimpleClass sc{n}; return std::move(sc);}
根据支持准则 (F.48) ,这不是好的做法,实际上可能导致次优结果。相反,应该像往常一样简单地返回对象,因为保证复制省略为我们处理优化。
这意味着如果我们改为按如下方式编写函数:
SimpleClass f(int n){ return {n};}SimpleClass g(int n){ SimpleClass sc{n}; return sc;}
然后调用函数调用
SimpleClass scf = f(2);SimpleClass scg = f(3);
本质上与写作相同:
SimpleClass scf{2};SimpleClass scg{3};
也就是说,从 f(.) 或 g(.) 返回对象时不会生成额外的 SimpleClass 副本。在 f(.) 的情况下,这被称为返回值优化,简称 ,在 g(.) 的情况下,它被称为命名或 因为命名对象 sc 是在之前首先创建的。
从 C++11 开始允许复制省略(在更具体的条件下),但不是强制性的,尽管许多编译器已经提供了它。该标准从 C++17 开始要求它,并在更一般的条件下。与C++中的几乎所有其他内容一样,也有微妙之处和例外,特别是那些涉及来自不同条件控制路径的回报。Jonathan Boccara的Fluent C++博客提供了一篇直截了当的文章,有助于填写这些细节。
特殊类函数
到目前为止,我们已经提到了C++六个特殊类函数中的四个:
- 复制构造函数
- 复制赋值运算符
- 移动构造函数
- 移动赋值运算符
此列表中的最后两个,即促进移动操作,已添加到 C++11 的语言中。前两个,以及下面尚未引用的另外两个,自 1998 年首次发布 ISO 以来一直在标准中:
- 默认构造函数
- 破坏者
这六个特殊函数中的每一个都有一个编译器提供的默认值,到目前为止,这些是我们使用的。因此,我们不必多说这些话。但是,在某些情况下,它们必须明确声明(实现?),此外,C++11 中还有一些相关的新功能,与前几年相比,这些功能可以使我们的生活更轻松。
复制构造函数和复制赋值
可以使用对象的复制构造函数或复制赋值运算符复制对象。这两者都有编译器提供的默认值,这些默认值通常完全足以满足我们的需求,并且在可能的情况下应该首选:
SimpleClass sc{ 300 };SimpleClass sc_copy{sc}; // Object sc copied to sc_copy with copy constructor. // sc is the copy constructor argument.SimpleClass sc_assgn = sc; // Object sc copied to sc_copy with copy assignment.
但是,在某些情况下,用户定义的复制操作变得必要,尤其是在类包含指针作为成员的情况下。这些情况将在下一章中讨论,但现在,请注意这些声明也需要包括在内:
class SimpleClass{ public: SimpleClass(int k); int get_val() const; void reset_val(int k); // Copy constructor and copy assignment operator: SimpleClass(const SimpleClass& rhs); // rhs = "right-hand-side" SimpleClass& operator =(const SimpleClass& rhs); private: int k_;};
在某些情况下,我们可能只想禁用对象复制,例如,指针成员可能存在,我们希望防止浅层复制,或者我们希望防止复制经常带来的潜在性能影响。在 C++11 之前,这意味着将两个复制操作声明为私有,但没有实现:
class SimpleClass{ public: SimpleClass(int k); int get_val() const; void reset_val(int k); private: int k_; // Copy constructor and copy assignment operator disabled by // declaring them private: SimpleClass(const SimpleClass& rhs); // rhs = "right-hand-side" SimpleClass& operator =(const SimpleClass& rhs);};
这可以防止外部复制,但仍可以实现可在内部调用的复制构造函数:
// Copy constructor can still be implemented even if declared private:SimpleClass::SimpleClass(const SimpleClass& copy): k_{ copy.k_ } {}// And then, there could be a member function that creates a copy internally:void SimpleClass::copy_mischief(int k){ SimpleClass sc{ k }; SimpleClass sc_copy{ sc }; . . .}
C++11 引入了用于类声明的 delete 关键字。这使得很明显,复制已被禁止,而且阻止,包括内部复制。另请注意,该声明现已公开:
class SimpleClass{ public: SimpleClass(int k); int get_val() const; void reset_val(int k); // Copy constructor and copy assignment operator are now disabled by // assigning them to the delete keyword: SimpleClass(const SimpleClass& rhs) = delete; SimpleClass& operator =(const SimpleClass& rhs) = delete; private: int k_;};
在这种情况下,前面的复制构造函数实现将导致编译器错误,这是一件好事,因为它有助于防止可能的运行时错误和/或意外行为。
最后,在某些情况下,我们可能需要将复制操作显式分配给编译器提供的默认值。其原因将在下一章中讨论,但现在,只需注意这可以使用默认关键字来完成:
class SimpleClass{ public: SimpleClass(int k); int get_val() const; void reset_val(int k); // Copy constructor and copy assignment operator are now // explicitly assigned to their compiler-provided defaults: SimpleClass(const SimpleClass& rhs) = default; SimpleClass& operator =(const SimpleClass& rhs) = default; private: int k_;};
这也很方便,而且不太容易出错,因为我们不必编写自己的复制构造函数和复制赋值运算符的实现。
移动构造函数和移动赋值运算符
移动构造函数和移动赋值运算符是复制构造函数和复制赋值运算符的移动类似物,默认值再次由编译器提供。对于本书要涵盖的主题,我们不需要定义自己的移动操作,因此默认值就足够了。然而,在某些情况下,移动构造函数和移动赋值运算符需要显式分配给默认值,如下一章所示。如有必要,也可以使用 delete 关键字禁用它们。
// Move operations disabled:SimpleClass(SimpleClass&& rhs) = delete;SimpleClass& operator =(SimpleClass&& rhs) = delete;// Explicit default move operations:SimpleClass(SimpleClass&& rhs) = default;SimpleClass& operator =(SimpleClass&& rhs) = default;
默认构造函数
如果类上未提供用户定义的构造函数,则可以使用编译器提供的默认构造函数。
class Minimal{public: int x() const; // return x_; void set_x(int x); // x_ = x; // No user-defined constructorprivate: int x_;};
然后,编译器提供的默认构造函数构造最小实例。
Minimal min{}; // Use uniform initializationmin.set_x(987);
如果添加了用户定义的构造函数,则会自动禁用编译器提供的默认构造函数。如果仍然需要默认构造函数,程序员将负责包含它。
在 C++11 之前,如果默认构造函数需要初始化成员数据,则初始化将在其实现中发生。例如,假设 x_ 需要在 Minimal 类中初始化为 0,它将更新为如下所示的内容:
class Minimal{public: Minimal(int x); // x_{x}; Minimal(); // x_{0}; int x() const; // return x_; void set_x(int x); // x_ = x;private: int x_;};// User-defined default constructorMinimal::Minimal() : x_(0) {}
但是,从 C++11 开始,可以在标头中将其设置为默认值,以及类内成员初始化,如前所述,从而产生更现代的样式:
class Minimal{public: Minimal(int x); // x_{x}; Minimal() = default; // Can use default keyword (2) int x() const; // return x_; void set_x(int x); // x_ = x;private: int x_{0}; // in-class member initialization (1)};
请注意,x_在其声明 (1) 中初始化,默认构造函数显式定义为编译器提供的默认构造函数 (2)。这也意味着需要实现的构造函数更少,因此出错的构造函数更少(特别是与这个最小的类示例相比,在更现实和更复杂的情况下):
Minimal::Minimal(int x) : x_{ x } {}int Minimal::x() const{ return x_;}// No longer need to write out an implementation for the default constructor...void Minimal::set_x(int x){ x_ = x;}
支持指南告诉我们,在需要使用文本值初始化一个或成员的情况下,首选类内初始化。
这可确保在可能有多个构造函数需要默认值的情况下保持一致的行为,从而获得更干净的代码,同时也是最有效的形式。此外,一个积极的连锁反应是,它有助于确保所有数据成员在构造时初始化,这是可以在支持指南中找到的另一种最佳实践。
析构函数
最后一个(但肯定不是最不重要的)特殊类函数是析构函数,当类的对象超出范围时(例如在功能块的末尾),或者在指向对象的指针上调用 delete 时,将调用析构函数。编译器提供的默认析构函数通常足以在类不包含(原始)指针成员的情况下使用。有一个重要的案例,析构函数应该显式定义为默认值,即作为基类上的虚拟默认析构函数,不需要内存释放(即基类上没有指针成员)。
class Base{ public: . . . virtual ~Base() = default; . . .};
这将与下一章中关于类继承的讨论相关联,但现在它作为特殊类函数概述的一部分呈现。
三向比较算子(宇宙飞船算子)
假设我们有一个 Fraction 类,它接受两个整数并将它们存储为成员 n_ 和 d_ ,表示分子和分母。(Josuttis OOP ,斯坦福课程幻灯片>)。为了简化事情,只需假设这些值是非负的,并且分母不为零。
class Fraction{public: Fraction(unsigned n, unsigned d); // initializes u_{u}, d_{d}private: unsigned n_, d_;};
假设我们创建两个实例,比如 a 和 b:
Fraction a{1, 2};Fraction b{3, 4};
然后,如果我们尝试比较它们,请说
if(a == b){ // Do something...}if(a > b){ // do something...}
编译器会抱怨,因为它不知道对于用户定义的类来说,相等或大于意味着什么。因此,有必要自己定义这些运算符。
在 C++11 之前,需要定义所有六个运算符 — == 、!= 、 < 、 <= 、 > 和 >= — 以涵盖所有意外情况:
bool operator == (const Fraction& rhs) const;bool operator != (const Fraction& rhs) const;bool operator < (const Fraction& rhs) const;bool operator > (const Fraction& rhs) const;bool operator <= (const Fraction& rhs) const;bool operator >= (const Fraction& rhs) const;
从逻辑的角度来看,如果定义了 == 和 <,那么剩下的实现可以用这两个运算符来表示。但是,代码中仍然需要所有六个的单独实现,即使其余四个基于前两个。在 C++20 中,现在通过引入(又名 <=>)来简化此操作。现在,我们需要做的就是提供 == 和 < ,我们基本上完成了。
回到 Fraction 类,通过将 == 定义为默认值,相等运算符很简单。在这种情况下,将每个成员与被比较对象上的相应值进行比较,即比较分子,然后比较分母。如果每对相等,则运算符返回 true。相反,这现在也会自动定义 != 运算符。
#include <compare>class Fraction{public: Fraction(unsigned n, unsigned d); bool operator == (const Fraction& rhs) const = default; // Defines both "==" and "!="private: unsigned n_, d_;};
其余的不等式运算符(包括独占和包含)可以由宇宙飞船算子(<=>)定义,为此需要标准库<比较>标头。但是,不是布尔返回类型,std::strong_ordering 用于整数值(稍后会详细介绍)。
编译器提供的默认值也为 <=> 提供了,但在这种情况下它对我们没有帮助,因为它定义了逐个成员的词典比较。也就是说,如果我们要写:
#include <compare>class Fraction{public: Fraction(unsigned n, unsigned d); bool operator == (const Fraction& rhs) const = default; std::strong_ordering operator <=> (const Fraction& rhs) const = default;private: unsigned n_, d_;};
然后我们会得到荒谬的结果,例如以下导致真实条件:
$\frac{2}{3} < \frac{4}{13}$,因为 4 > 2,并且
$\frac{1}{2} < \frac{1}{5}$,因为分子相等,5 > 2
出于这个原因,我们需要编写我们自己的实现,这可以通过首先定义和检查“小于”(<)条件来完成。如果不是真的,那么下一步将根据已经建立的 == 的定义检查相等性。如果这两个都不成立,则结果必须“大于”(>)。这些值分别由strong_ordering值 小于 、等价值和大于 表示:
std::strong_ordering Fraction::operator <=>(const Fraction& rhs) const{ if(n_ * rhs.d_ < rhs.n_ * d_) { return std::strong_ordering::less; } else if (*this == rhs) // Check if the active object is equal to rhs { return std::strong_ordering::equivalent; } else { return std::strong_ordering::greater; }}
有了 == 和 <=> 的定义,我们可以像往常一样应用六个比较运算符中的任何一个,例如:
Fraction f1{ 1, 2 };Fraction f2{ 3, 4 };if(f1 <= f2){ // <=> now properly evalauates the inequality 1/2 < 3/4}else{ . . .}
至于strong_ordering返回类型,这意味着可以比较任何两个值,就像C++中的整型一样。还有一个 std::p artial_order 类型,应该用于浮点类型,如 double 和 float 。这是因为浮点类型(例如双精度)可以容纳不可比较的赋值,例如无穷大和 NaN。还应该注意的是,在定义 == 时,永远不应该直接比较两种浮点类型。相反,在这种情况下,等价性,比如两个双精度值x和y,应该由y是否落在x的某个公差范围内来确定。
第三种返回类型 weak_ordering 可用于比较字符串(但不限于此示例)等情况,其中不区分大小写但在其他方面相似的字符被视为等效 (Grimm C++20) 。
尽管金融应用程序所需的大多数排序可能只是基于数字类型(例如双精度),因此已经在语言中定义了,但 == 和 <=> 的重载可以很方便的一个地方是日期类的设计,其中排序和日期算术基于自纪元以来的天数, 如 1970-01-01(UNIX 纪元)。用户定义的日期类将在第六章中介绍。
Lambda 表达式和用户定义的类成员
要介绍的最后一点是在 lambda 表达式中捕获类成员数据和成员函数。假设我们有一个计算二次函数值的类,其系数存储为成员变量。一个公共成员函数generate_values将接受实数向量,计算每个元素的函数值,并将结果存储在另一个向量中:
#include<vector>class QuadraticGenerator{public: QuadraticGenerator(double a, double b, double c); // a_{ a }, b_{ b }, c_{ c } std::vector<double> generate_values(const std::vector<double>& v);private: double a_, b_, c_; std::vector<double> y_;};
在 generate_values.) 函数中,我们可能希望将函数计算分离到一个 lambda 表达式中,该表达式每次都从基于范围的 for 循环迭代 v 中调用。为此,我们需要成员变量 a_ 、 b_ 和 c_ 。我们可以在捕获中单独列出这些,但也可以通过将 this 指针设置为指向 lambda 捕获中的活动对象来访问所有成员数据(和成员函数):
std::vector<double> QuadraticGenerator::generate_values(const std::vector<double>& v){ auto quadratic_value = (double x) -> double { return x * (a_ * x + b_) + c_; }; for (double x : v) { y_.push_back(quadratic_value(x)); } return y_;}
在某些基于线程的情况下,可能需要活动对象 *this 的副本作为 lambda 捕获参数,而不是 this 指针。在 C++17 中,添加了它,以便 quadratic_value(.) 可以改为写成:
auto quadratic_value = <*this>(double x) -> double{ return x * (a_ * x + b_) + c_;};
本书中的示例将主要使用捕获 this 指针的前一种情况,而不是 *this 。
总结
上面介绍的主题可能是一个“各种和杂七杂八”的列表,其中大部分是编写用户定义的类时可以使用的较新的功能,因此回顾所讨论的内容可能会有所帮助。
用户提供的运算符 () 重载定义了一个函子,它允许我们传递一个可以保存状态及其自己的成员函数的函数对象。这在C++11之前就已经存在,但它可以成为定量规划中的有用工具,例如用于计算期权隐含波动率的根查找。
lambda 表达式是函子的另一种形式,可以在另一个函数中“动态”定义,并且可以帮助重构多次调用的代码。Lambda 表达式还可以捕获指向活动对象的 this 指针(如果需要,也可以改为取消引用的 *this),为 lambda 提供对任何类数据成员或成员函数的访问权限。作为即将发生的事情的旁注,函数对象和lambda都可以作为参数传递给函数或STL算法,我们将在第4章中看到。
Move 语义允许将对象作为参数传递给函数,并在构造时初始化子对象成员,而不会产生对象复制的开销,同时避免旧方法(如传递指针或通过引用存储成员)可能导致的问题。我们将在后续章节中看到更多实际示例。
default 关键字预先明确表示,一个特殊的类函数将假定其编译器提供的默认值。作为一个具体示例,结合类内成员初始化,default 可以避免实现默认构造函数的需要。delete 关键字将禁用一个特殊函数,使其永远无法调用,即使在对象中也是如此。这些关键字将在下一章中变得更加相关。
类内成员初始化提供了几个有助于降低错误风险的好处,特别是作为对分配为默认值的构造函数的补充。它通过简单地允许在类声明中初始化成员来消除用户定义实现的必要性。它们还有助于确保在施工时初始化所有成员数据。
三向比较运算符,通常称为宇宙飞船运算符,允许根据 == 和 < 定义所有六个相等和比较运算符,因此不再需要在单独的实现中定义每个运算符。