第 4 章:深入了解 C++ 对象

在本章中,我们将特别关注 C++ 语言中的对象。但是,为什么 C++ 中的对象如此特别,值得我们如此关注呢?考虑到 C++ 支持面向对象编程范式,我们可以假定对象本身在语言结构中占据了中心位置。你会看到 C++ 中的对象有很多特殊性。

在本章中,我们将深入探讨 C++ 对象的基本方面。我们将从探讨 C++ 标准如何定义对象开始。接着,我们将更仔细地看看不同类型的对象初始化,如聚合初始化、直接初始化和拷贝初始化,以及它们的使用场景。

我们还将探索对象的存储期限概念。此外,我们将看看 C++ 中对象的作用域和生命周期。我们还将了解什么是引用以及它们如何与对象相关。

随着我们的深入学习,我们将了解临时对象以及小心处理它们的重要性,以及 C++ 中函数对象和 lambda 的概念。我们将探索如何使用 lambda 与 标准模板库STL)算法的一个示例,这将帮助我们全面理解如何利用这些强大的特性来创建更高效、优化的代码。

到本章结束时,你将清楚地了解 C++ 对象的基本概念,并且你将熟悉一些创建更健壮、高效代码的技巧。

本章将涵盖以下主题:

  • C++ 对象模型
  • 作用域、存储期限和生命周期
  • C++ 中的函数对象和 lambda

好的,让我们开始吧!

技术要求

本章中的所有示例都在以下配置的环境中进行了测试:

  • Linux Mint 21
  • GCC 12.2,编译器标志 - -std=c++20
  • 稳定的互联网连接
  • 请确保你的环境使用这些版本或更新版本。对于所有示例,你也可以使用 https://godbolt.org/。

理解 C++ 对象模型

C++ 程序涉及创建、操作和销毁各种称为对象的实体。C++ 中的对象具有多种属性,如类型大小存储期限生命周期对齐要求。对象的名称可选的

命名对象的生命周期受其存储期限的限制,如果对象没有名称,则被视为临时对象。然而,并非 C++ 中的所有实体都被视为对象。例如,引用就是这样一个非对象实体。

首先,让我们简要看一下术语,因为了解它对于我们日常使用 C++ 语言很重要。

声明与定义

在 C++ 中,声明定义这两个术语通常用来指不同方面的变量、函数或类。以下是每个术语的含义:

  • 声明:声明在程序中引入一个名称,并指定变量、函数或类的类型,例如以下代码:

    extern int x;
    void foo(int arg);
    struct Point; 
    

    在上述示例中,xfooPoint 都是声明的,但未定义。变量声明中的 extern 关键字表明 x 在程序的其他地方定义。在声明中,不会分配内存。

  • 定义:定义为已声明的名称提供实际实现。它为变量预留内存,为函数分配代码空间,并定义类的布局,例如以下代码:

    int x;
    void foo(int arg) {
       // function body
    }
    struct Point {
       // struct members and methods
    };
    

    在上述示例中,xfooPoint 都是定义的。

因此,声明引入了一个名称并指定了其类型,而定义提供了实际的实现并为对象分配了内存。

现在我们熟悉了术语,让我们深入了解 C++ 中对象的细节。

作用域、存储期限和生命周期

C++ 程序中的每个对象或引用都有一个特定的区域,在该区域中它是可见和可访问的,一个特定的生命周期,以及它占用的特定类型的内存。让我们来仔细看看它们各自的特点。

作用域

在 C++ 中,变量、函数或类的作用域指的是实体名称可见并可以在程序的哪个区域中无需限定就能访问的范围。作用域规则决定了在程序的不同部分哪些标识符是可见和可访问的。标准定义了 C++ 中的几种作用域类型。其中一些包括:

  • 全局:在任何函数或类外部声明的变量、函数和类具有全局作用域。它们可以从程序的任何部分访问,例如以下代码:

    int x = 1; // global variable
    void foo() {
        std::cout << x << std::endl; // access global variable
    }
    
  • 函数:在函数内部声明的变量具有函数作用域。它们只能在声明它们的函数内部访问,例如以下代码:

    void foo() {
        int x = 1; // local variable
        std::cout << x << std::endl; // access local variable
    }
    
  • :在块内部声明的变量,即在大括号 ({}) 中封闭的语句序列中声明的变量,具有块作用域。它们只能在声明它们的块内部或任何内部块(如果有的话)访问,例如以下代码:

    void foo() {
        int x = 2; // local variable with function scope
        {
            int y = 4; // local variable with block scope
        }
    }
    

这些是我们在 C++ 中使用的一些作用域。现在,让我们看看 C++ 中的存储期限意味着什么。

存储期限

在 C++ 中,存储期限 指的是对象存在于内存中的时间长度,或者说对象的生命周期。有四种类型的存储期限:

  • 自动:这些对象在程序进入声明它们的代码块时创建,在退出该代码块时销毁。例子包括没有 static 关键字声明的局部变量和函数参数。
  • 静态:这些对象在程序开始时创建,或当程序执行到此阶段时首次创建,并在程序终止时销毁。它们存储在全局内存区域,并在程序的整个生命周期内持续存在。例子包括全局变量和在函数内用 static 关键字声明的变量。
  • 动态:这些对象通过 new 操作符创建,通过 delete 操作符销毁。它们存在于堆上,并可以被程序的多个部分访问。
  • 线程局部:这些对象在线程创建时创建,并在线程终止时销毁。它们类似于具有静态存储期限的对象,但是特定于某个线程。

下面是一个展示不同存储期限类型的示例:

#include <iostream>
int global_var = 1; // Static storage duration
void foo() {
    int automatic_var = 2;
    static int static_var = 3;
    int* dynamic_var = new int(4);
    std::cout << "Automatic var: " << automatic_var <<
      '\n';
    std::cout << "Static var: " << static_var << '\n';
    std::cout << "Dynamic var: " << *dynamic_var << '\n';
    delete dynamic_var;
}
int main() {
    foo();
    std::cout << "Global var: " << global_var << '\n';
    return 0;
}

在这个例子中,global_var 有静态存储期限,因为它是一个全局变量。automatic_var 有自动存储期限,因为它在 foo 函数内声明。static_var 也有静态存储期限,但由于 static 关键字的使用,它在 foo 的多次调用之间保持其值。dynamic_var 本身具有自动存储期限,但它指向的已分配内存具有动态存储期限,因为它是通过 new 操作符分配的。当 foo 返回时,automatic_var 自动销毁,dynamic_var 通过 delete 操作符销毁,而 static_varglobal_var 在程序的整个生命周期内持续存在。

生命周期

术语 生命周期 指的是对象或引用在程序中存在的持续时间。C++ 中的每个对象和引用都有一个特定的生命周期。对象的生命周期从为其分配内存并初始化开始。如果对象类型有构造函数,则生命周期从构造函数成功完成时开始。对象的生命周期在其析构函数被调用时结束,或者如果没有析构函数,当它被销毁时结束。因此,对象的生命周期等于或小于其存储的持续时间。同样,引用的生命周期从其初始化完成时开始,结束时与标量对象相似。

对象

每个对象都是由定义语句创建的,该语句引入、创建并可选地初始化一个 变量。变量是对象引用,不是非静态数据成员,并且由声明引入(对象 - cppreference.com)。

让我们定义一个简单的变量并从中创建一个对象:

void foo() {
    int x;
} 

我们已经定义并同时实例化了一个整数类型的对象在 foo() 函数的栈上。C++ 中的每个对象在特定的内存区域占据一定量的内存。处于栈上,这个对象具有自动存储期限。在我们的示例中,这意味着该对象将在函数开始时创建,并在函数结束时自动销毁。当它被实例化时,它使用了一定量的内存。这个量是一个编译时已知的值,可以使用 sizeof 运算符获取。请记住,某些类型的大小可能会根据您的程序运行的底层硬件而变化,因此如果您需要确保大小,请始终使用运算符来计算。这样的一个例子是基本的 int 类型。标准规定 int 类型的大小不能小于 16 位。对于本章示例中运行的 Linux Mint 21 与 GCC 12.2,使用的底层数据模型是 LP64。这意味着 int 是 4 字节,long 和指针是 8 字节。在下一个示例中,我们演示了前面提到的类型的大小。为了编译和运行此代码,您必须在函数中传递它:

int i;
long l;
char* p;
std::cout << "sizeof(int) = " << sizeof(int) << "; sizeof(i) = " << sizeof(i) << '\n';
std::cout << "sizeof(long) = " << sizeof(long) << "; sizeof(l) = " << sizeof(l) << '\n';
std::cout << "sizeof(char*) = " << sizeof(char*) << "; sizeof(p) = " << sizeof(p) << '\n'; 

这是示例的输出:

sizeof(int) = 4; sizeof(i) = 4
sizeof(long) = 8; sizeof(l) = 8
sizeof(char*) = 8; sizeof(p) = 8 

到目前为止没什么意外。int 是 4 字节,但无论它指向哪种类型,指针都是 8 字节。

现在,让我们定义几个结构并检查它们的内存占用:

struct Empty {};
struct Padding {
    long test;
    char m;
};
struct Virt {
    virtual char GetChar() const { return ch; }
    char ch;
};
void foo() {
    std::cout << "Empty: " << sizeof(Empty) << '\n';
    std::cout << "Padding: " << sizeof(Padding) << '\n';
    std::cout << "Virt: " << sizeof(Virt) << '\n';
} 

我们定义了三个结构 - EmptyPaddingVirtEmpty 结构,顾名思义,是没有任何成员的空结构。Padding 结构包含两个成员 - longchar。从前面的示例中,我们看到在我的测试环境中,long 是 8 字节,char 是 1 字节。最后,Virt 结构只有一个 char 类型的成员和一个虚方法。结构和类方法不是对象本身的一部分。它们驻留在文本段中,而不是对象所占用的内存中。让我们执行前面的代码并查看结果:

Empty: 1
Padding: 16
Virt: 16 

我们可以看到所有对象都占用内存,即使是空对象!这是标准所保证的,因为系统中的任何对象都必须有一个它所驻留的地址。如果它不占用任何内存,那么就无法分配地址给它。因此,程序中的每个对象至少保留 1 字节。

Padding 结构占用的内存超过了其成员内存的总和。这是因为编译器可以自由地将对象放置在一个地址上,这个地址需要更少的指令算术运算来实现更快的访问。因此,如果需要,它们会向类型的大小中添加填充字节。

最后,Virt 结构只包含一个 char 类型的成员。然而,该结构占用的内存与 Padding 结构相同。这是 C++ 中多态机制实现方式的结果。该结构包含一个虚方法,通知编译器这个用户定义类型将被多态地使用。结果是,编译器在这种类型的每个实例化对象中注入了一个指针,指向包含该类所有虚方法地址的表。

通过这些示例,我们可以得出结论:每个对象一旦实例化,就会占用内存,且内存大小可能因底层系统和类型定义的不同而变化。

接下来,我们将了解 C++ 中的引用以及它们与对象的不同之处。

引用

在上一节中,我们发现我们不仅可以从对象中声明变量,还可以从引用中声明。但在 C++ 中,什么是引用?根据标准,引用变量是对已存在对象或函数的别名。这意味着我们可以使用别名来操作对象,而不会在语法上有所不同,这与使用指向对象的指针(其语法完全不同)相比。让我们看看下面的示例。为了编译和运行它,你需要从一个函数中调用它:

char c;
char& r_c{c};
char* p_c;
std::cout << "sizeof(char) = " << sizeof(char) << "; sizeof(c) = " << sizeof(c) << '\n';
std::cout << "sizeof(char&) = " << sizeof(char&) << "; sizeof(r_c) = " << sizeof(r_c) << '\n';
std::cout << "sizeof(char*) = " << sizeof(char*) << "; sizeof(p_c) = " << sizeof(p_c) << '\n'; 

在这个例子中,我们声明了三个变量 - 一个字符、一个字符的引用和一个字符的指针。在处理引用变量时的一个重要细节是,在声明它时,我们还必须用它将要引用的对象初始化它。从这一刻起,对引用变量调用的每个操作实际上都是在被引用的对象上调用。但别名究竟是什么呢?它像指针那样占用内存吗?嗯,这是一个灰色地带。标准指出,与对象不同,引用并不总是占用存储空间。然而,如果实现预期的语义需要,编译器可能会分配存储空间。因此,你不能使用 sizeof 运算符来获取引用的大小:

sizeof(char) = 1; sizeof(c) = 1
sizeof(char&) = 1; sizeof(r_c) = 1
sizeof(char*) = 8; sizeof(p_c) = 8 

你可以看到,指针的大小与预期相符,而引用类型的大小则与其别名的类型大小相符。

了解初始化为何重要

初始化 是在构造过程中为对象设置初始值的过程。在 C++ 中,有几种类型的初始化,主要取决于以下因素:

  • 对象所属的存储期限
  • 对象的定义

了解不同类型的初始化以及它们何时发生,肯定会让你在编写可预测代码时更有信心。

让我们看几个 C++ 语言支持的各种类型初始化的例子。这将使初始化发生的时间更加清晰。

默认初始化

在下一个示例中,你可以看到默认初始化。为了运行和测试这段代码,你必须调用 foo() 方法:

struct Point {
    double x;
    double y;
};
void foo() {
    long a; // {1}
    Point p1; // {2}
    std::cout << "{1}: " << a << '\n';
    std::cout << "{2}: " << p1.x << ", " << p1.y << '\n';
} 

在标记 {1} 处,我们声明了一个 long 类型的栈变量。应用于对象的初始化类型主要取决于以下因素:

  • 它所占用的存储期限:这意味着不同的初始化策略可能适用,具体取决于对象是生活在栈上、全局空间等
  • 声明的类型:这意味着不同的初始化策略可能适用,取决于我们在语法上如何声明变量 - 是否我们指定了 init 值,如何准确地传递了该 init 值等

我们示例中的 long a; 变量具有自动存储期限,意味着它存在于函数的栈上。在其声明中,我们没有指定任何初始化值。对于此类对象,我们将应用默认初始化。当对象默认初始化时,C++ 编译器将生成代码,调用对象类型的默认构造函数(如果存在)。然而,由于 long 是一个缺乏默认构造函数的基本 C++ 类型,C++ 运行时不会对其进行任何初始化,导致一个不可预测的值。这意味着用于初始化的值没有指定,可能是任何值。Point p1; 对象也是如此,它是一个用户定义类型,但我们没有为它指定默认构造函数。Point 结构是所谓的纯旧数据POD)类型,因为它与 C 语言的结构完全兼容。对于这些类型,编译器将为你生成一个平凡的默认构造函数,当调用时实际上不做任何事情。

早先示例的输出将如下所示:

{1}: 1
{2}: 4.19164e-318, 4.3211e-320 

在我的环境中,ap1 对象都有不确定的值。如果你自己运行这个示例,可能会得到不同的值。

直接初始化

在我们的下一个示例中,我们将了解 C++ 的直接初始化。为了运行和测试这段代码,你必须再次调用 foo() 方法。请记住,这个示例中的 int c_warn{2.2};`` // {4.2} 语句应该被注释掉以成功编译:

void foo() {
    int b(1);         // {3.1}
    int b_trunc(1.2); // {3.2}
    int c{2};         // {4.1}
    int c_warn{2.2};  // {4.2}
    std::cout << "{3.1}: " << b << '\n';
    std::cout << "{3.2}: " << b_trunc << '\n';
    std::cout << "{4.1}: " << c << '\n';
} 

在示例的第一个语句中,int b(1);,我们定义了一个类型为 int 的变量,并且用值 1 显式地初始化它。这就是我们从 C++ 语言诞生以来就已知的直接初始化。要调用它,你必须在括号中指定初始化值,且该值必须与对象类型的某些转换构造函数匹配。这些转换构造函数可以是编译器生成的。在我们的示例中,我们使用的是基本 C++ 类型 int,它支持用整数值进行直接初始化。因此,b 对象将用值 1 进行初始化,到目前为止没有什么新鲜事。

在下一个语句中,我们声明了一个变量 int b_trunc(1.2);,但这次我们用浮点值 1.2 初始化它。这个语句可以正常工作,声明了一个类型为 int 的变量,并用值……1 初始化它!是的,根据 C++ 标准,该标准尽可能与 C 语言兼容,因此值被截断为其尾数。在某些情况下,用浮点值初始化整数对象可能很有用,但在其他情况下,这可能是一个无意的错误。在这种情况下,我们希望编译器警告我们可能做错了什么。因此,C++11 引入了所谓的统一初始化

在示例的下一个语句中,int c{2};,我们再次声明了一个类型为 int 的变量,但我们使用大括号而不是小括号进行初始化。这通知编译器调用直接列表初始化,这是一种统一初始化。它是命名列表初始化,因为它可以用作初始化列表的值,这些值不同类型,用于初始化复杂对象。

优先使用统一初始化的一个原因是在下一个示例语句中可见:

int c_warn{2.2};  // {4.2}

正如我们刚才看到的,使用直接初始化用较宽类型的值初始化特定类型的对象会导致默默截断的初始化值。在某些情况下,这可能导致错误。避免这种潜在副作用的一种方法是改用统一初始化。在我们的示例中,我们定义了一个 int 类型的变量,并再次用浮点值初始化它。然而,这次,编译器不会默默地用 2 的值初始化 c_warn,而是会生成类似这样的错误:

error: narrowing conversion of '2.2000000000000002e+0' from 'double' to 'int' [-Wnarrowing]

这个错误是因为我们试图在用 double 值初始化 int 变量时进行缩小转换。因此,在初始化期间使用统一初始化比直接初始化更安全,因为它可以保护你免受缩小转换的影响。

零初始化和聚合初始化

让我们看另一个初始化示例。我们将初始化一个保存 Person 的个人数据和几个整数对象的对象:

struct Person {
    std::string name;
    int age;
};
void init() {
    int zero1{}; // {1}
    int zero2 = int(); // {2}
    int zero3 = int{}; // {3}
    Person nick{"Nick L.", 42}; // {4}
    Person john{.name{"John M."}, .age{24}}; // {5}
} 

正如我们已经解释过的,没有显式初始化且具有自动存储期限的对象会获得随机初始化值。在这个示例中,从标记 {1}{3},我们使用零初始化初始化了对象,有效地将它们的值设置为零。零初始化适用于非类的内置类型以及用户定义类型的成员,这些成员没有构造函数。当你需要对对象进行零初始化时,最好使用大括号表示法和统一初始化,如标记 {1},而不是复制零初始化,如标记 {2}{3}

语句 {4} 展示了另一种称为聚合初始化的初始化方法。它允许我们使用统一初始化表示法初始化聚合对象。聚合被认为是任何数组或类类型的对象,它没有用户声明的或继承的构造函数;其所有非静态成员都是公开可见的,并且没有虚基类和虚方法。语句 {5} 使用指示符执行另一种聚合初始化。指示符明确指定了被初始化的成员,初始化中的指示符顺序应遵循结构中成员声明的顺序。

拷贝初始化

拷贝初始化发生在特定类型的对象由同一类型的另一个对象初始化时。让我们看看触发拷贝初始化的以下几种语法示例。为了运行和测试这段代码,你必须调用 foo() 方法:

void foo() {
    int c{2};
    int d(c);     // {1}
    int e{d};     // {2}
    int f = e;    // {3}
    int f1 = {d}; // {4}
} 

这个示例中的标记 {1}{3} 展示了语言中即使在 C++11 之前就存在的众所周知的拷贝初始化。一个 int 类型的对象由同一类型的另一个对象初始化。正如我们已经看到的,这种初始化不提供任何类型缩小的保护。这意味着我们的 int 对象可以被 double 对象默默地初始化,导致缩小。幸运的是,标记 {2}{4} 并非如此。它们使用统一拷贝初始化,迫使编译器验证初始化对象是否与被初始化对象的类型相同。

现在,让我们看看用户定义类型的几种拷贝初始化场景。我们定义了两个类 - PersonEmployeePerson 类有一个用户定义的构造函数,接收一个 std::string 参数的引用,用于初始化人的姓名。构造函数标记为 explicit。这意味着它将仅用作非转换构造函数。转换构造函数 是一种从其参数类型到其类类型进行隐式转换的构造函数。

另一个类 Employee 有两个构造函数,一个接收 Person 对象的引用,另一个是拷贝构造函数。拷贝构造函数也被标记为 explicit

class Person {
public:
    explicit Person(const std::string&  the_name) : name{
      the_name} {}
private:
    std::string name;
};
class Employee {
public:
    Employee(const Person& p) : p{p} {}
    explicit Employee(const Employee& e) : p{e.p} {}
private:
    Person p;
}; 

让我们在不同的初始化场景中使用这两个类。为了运行和测试这段代码,你必须重新编写并调用 foo() 方法:

void foo() {
    Person john{"John M."};
    Employee staff1{john};          // {1}
    // Employee staff2{std::string{"George"}};   // {2}
    Employee staff3{staff1};        // {3}
    // Employee staff4 = staff1;    // {4}
    // Employee staff5 = {staff1};  // {5}
} 

我们首先定义了一个名为 johnPerson 对象,并在标记 {1} 处使用 john 初始化了一个 Employee 对象。这是有效的,因为 Employee 类有一个接受 Person 对象的构造函数。接下来的语句,标记 {2}(已被注释掉),以 std::string 类型的对象作为参数,但编译器会生成错误。这是因为 Employee 类没有接受字符串对象的构造函数。它有一个从 Person 对象转换的构造函数。然而,Person 的构造函数被标记为 explicit,不允许在隐式类型转换中使用,所以编译会失败。

接下来的语句,标记 {3},将成功编译,因为 Employee 是通过拷贝构造并用另一个 Employee 对象初始化的,没有任何隐式类型转换。

示例中的最后两个语句 - 标记 {4}{5} - 也被注释掉了,以避免编译错误。编译器错误的原因是 Employee 类的拷贝构造函数也被标记为 explicit。这意味着对于显式拷贝构造函数,不允许使用等号 = 进行拷贝构造和初始化。只允许直接拷贝初始化。

现在我们已经熟悉了对象的作用域、存储期限和生命周期,我们可以看一些略有不同的对象类型,这些对象更像是函数而不是对象 - 函数对象和 lambda 表达式。

函数对象和 lambda 表达式

这一部分将深入探讨函数对象 - 它们的定义、用途和正确使用方法。我们将首先通过查看一个与 STL 算法一起使用的函数对象示例来开始,并讨论潜在的问题,例如临时对象的创建和悬空引用。之后,我们将探索 lambda 表达式 - 它们是什么,如何使用它们,以及在特定情况下它们可能特别有用的情形。

探索函数对象

作用域、存储期限和生命周期一节中,我们查看了 C++ 中各种类型对象的初始化,但我们的重点主要是代表数据的对象,如整数或坐标。在这一节中,我们将注意力转向另一种类型的对象 - 那些被设计为可调用的对象,如函数,但有一个关键区别:它们可以在不同函数调用之间维持状态。这些对象被称为函数对象functors。我们将首先定义一个 functor,然后用它来计算包含浮点数的向量的平均值:

#include <iostream>
#include <vector>
#include <algorithm>
#include <cmath>
#include <source_location>
struct Mean {
    Mean() = default;
    void operator()(const double& val) {
        std::cout <<  std::source_location::current()
          .function_name() << " of " << this << '\n';
        sum += val;
        ++count;
    }
private:
    double sum{};
    int count{};
    friend std::ostream& operator<<(std::ostream& os, const
      Mean& a);
};
std::ostream& operator<<(std::ostream& os, const Mean& a) {
    double mean{std::nan("")};
    if (a.count > 0) {
        mean = a.sum / a.count;
    }
    os << mean;
    return os;
}
int main() {
    Mean calc_mean;
    std::vector v1{1.0, 2.5, 4.0, 5.5};
    std::for_each(v1.begin(), v1.end(), calc_mean);
    std::cout << "The mean value is: " << calc_mean <<
      '\n';
    return 0;
} 

函数对象就像任何其他对象一样。它有类型、存储期限和作用域。为了定义一个函数对象,你必须定义一个结构或用户定义类型的类,并且这个类型必须实现了函数调用运算符

operator()

在我们的示例中,我们定义了 struct Mean,其中包含两个成员,这两个成员被零初始化。第一个成员 sum 将用于累积这个对象在函数调用运算符调用期间接收到的输入数据,保留在不同调用之间。另一个成员 count 将用于计算函数调用运算符的调用次数。

函数调用运算符的定义接受一个 double 类型的参数,然后该方法打印其名称,并将输入值添加到之前调用中累积的值中。最后,它递增调用计数器。

函数调用运算符不返回任何类型,并且没有定义为 const 方法,因为它改变了 Mean 对象的状态。我们还重载了流提取运算符,将用于向标准输出报告计算出的平均值。如果没有累积值,那么将打印 nan(“非数字”):

std::ostream& operator<<(std::ostream& os, const Mean& a)

请记住,运算符是在 Mean 结构之外重载的,并且被声明为它的友元方法。这是因为它需要将 std::ostream 作为左手参数,将 Mean 参数作为右手参数,因此不能作为成员方法实现。它被定义为友元,因为它需要访问 Mean 结构的私有成员。

为了计算平均值,我们的算法使用 std::for_each STL 算法迭代向量中的所有值。std::for_each 需要接收要操作的容器和函数,该函数将被调用容器中的每个元素;因此,这个函数必须接受一个参数作为输入参数。

在 main 方法中,我们定义了一个类型为 Mean calc_mean; 的对象,它将用于计算 std::vector v1{1.0, 2.5, 4.0, 5.5}; 的平均值。你可以看到,我们不需要显式指定 std::vector 类的模板参数类型,因为它是根据其初始化列表值的类型自动推断出来的。在我们的例子中,这些是 double 值。

重要提示

请注意,自 C++17 起,已支持基于其初始化器的类型自动进行类模板参数推导。

我们期望程序将为向量中的每个元素调用 Mean 对象的函数运算符。函数运算符将累积所有值,当结果被打印出来时,它将是 3.25。让我们看看程序的输出:

void Mean::operator()(const double&) of 0x7ffc571a64e0
void Mean::operator()(const double&) of 0x7ffc571a64e0
void Mean::operator()(const double&) of 0x7ffc571a64e0
void Mean::operator()(const double&) of 0x7ffc571a64e0
The mean value is: nan 

正如我们所期待的,向量中的每个元素都调用了运算符函数,但令人惊讶的是,没有计算出平均值。为了更好地理解计算中出了什么问题,我们需要看看 std::for_each 算法使用的 calc_mean 对象发生了什么。

注意临时对象

为了进行调查,在Mean结构中,我们需要定义拷贝构造函数移动构造函数移动操作符以及一个析构函数,其唯一的目的是打印出它们被调用时以及它们所属对象的地址。我们还需要添加标记,以便于知道计算何时开始和结束。让我们看看重新处理过的示例:

struct Mean {
    Mean() noexcept {
        std::cout <<  std::source_location::current()
         .function_name() << " of " << this << '\n';
    }
    Mean(Mean&& a) noexcept : sum{a.sum}, count{a.count} {
        std::cout <<  std::source_location::current()
          .function_name() << " from: " << &a << " to: " <<
             this << '\n';
        a.sum = 0;
        a.count = -1;
    }
    Mean& operator=(Mean&& a) noexcept {
        std::cout <<  std::source_location::current()
          .function_name() << " from: " << &a << " to: " <<
            this << '\n';
        sum = a.sum;
        count = a.count;
        return *this;
    }
    Mean(const Mean& a) noexcept : sum{a.sum},
      count{a.count} {
        std::cout <<  std::source_location::current()
          .function_name() << " from: " << &a << " to: " <<
            this << '\n';
    }
    ~Mean() noexcept {
        std::cout <<  std::source_location::current()
          .function_name() << " of " << this << '\n';
    }
    void operator()(const double& val) {
        std::cout <<  std::source_location::current()
          .function_name() << " of " << this << '\n';
        sum += val;
        ++count;
    }
private:
    double sum{};
    int count{};
    friend std::ostream& operator<<(std::ostream& os, const
      Mean& a);
}; 

我们还需要稍微修改main()方法的实现:

int main() {
    Mean calc_mean;
    std::vector v1{1.0, 2.5, 4.0, 5.5};
    std::cout << "Start calculation\n";
    std::for_each(v1.begin(), v1.end(), calc_mean);
    std::cout << "Finish calculation\n";
    std::cout << "The mean value is: " << calc_mean <<
      '\n';
    return 0;
}

当我们重新执行已经修改过的程序时,我们会得到以下输出:

Mean::Mean() of 0x7ffef7956c50
Start calculation
Mean::Mean(const Mean&) from: 0x7ffef7956c50 to: 0x7ffef7956ca0
void Mean::operator()(const double&) of 0x7ffef7956ca0
void Mean::operator()(const double&) of 0x7ffef7956ca0
void Mean::operator()(const double&) of 0x7ffef7956ca0
void Mean::operator()(const double&) of 0x7ffef7956ca0
Mean::Mean(Mean&&) from: 0x7ffef7956ca0 to: 0x7ffef7956c90
Mean::~Mean() of 0x7ffef7956c90
Mean::~Mean() of 0x7ffef7956ca0
Finish calculation
The mean value is: nan
Mean::~Mean() of 0x7ffef7956c50 

正如我们所预期的,程序以构造地址为0x7ffef7956c50的对象开始,然后开始计算,我们可以看到调用了拷贝构造函数。这是因为std::for_each和标准库中的许多其他算法一样,是一个通过值获取其函数对象的模板方法。标准库对其原型的描述如下:

template< class InputIt, class UnaryFunction >
constexpr UnaryFunction for_each( InputIt first, InputIt
  last, UnaryFunction f ); 

这意味着无论它进行什么计算,所有累积的值都将存储在复制的对象中,而不是原始对象中。实际上,由这个拷贝构造函数创建的对象只是一个临时对象。临时对象是编译器自动创建和销毁的无名对象。它们常常导致开发者难以直观识别的副作用。临时对象通常是由于参数和函数返回值的隐式转换而创建的。它们通常有一个有限的生命周期,直到创建它们的语句结束,除非它们被绑定到某个命名的引用。因此,要小心使用它们,因为它们可能会影响程序的性能,更重要的是,它们可能导致意外行为,就像我们的例子中那样。

从前面的代码中,我们可以看到所有的累积都在新创建的临时对象中完成。一旦std::for_each方法执行完毕,就会调用一个新的临时对象的移动构造函数。这是因为根据std::for_each的定义,作为值传递的输入函数对象作为操作的结果被返回。因此,如果我们需要将累积的值返回到原始对象,我们需要将std::for_each的返回值分配给原始对象 - calc_mean

calc_mean = std::for_each(v1.begin(), v1.end(), calc_mean);

最终,结果正如我们所期望的,但代价是创建了几个临时对象:

Finish calculation
The mean value is: 3.25

在我们的例子中,这不是问题,但对于真正复杂的对象,临时对象的创建涉及昂贵且可能缓慢的操作,如资源获取,这可能是有问题的。

接下来,让我们看看如何通过避免不必要的拷贝操作来改进我们的例子。

通过引用传递

改进早期示例的一种方法是通过引用而不是通过值传递函数对象。这将避免创建不必要的临时对象:

using VecCIter = std::vector<double>::const_iterator;
std::for_each<VecCIter, Mean&>(v1.begin(), v1.end(),
  calc_mean); 

为了通过引用传递Mean对象,你必须向编译器明确表达你的意图,通过明确指定Mean模板参数是一个引用。否则,自动模板参数推断会推断出你是通过值传递的。因此,这迫使你避免使用自动类模板参数推断,并使你的代码更难阅读。幸运的是,标准库为此提供了解决方案:

std::for_each(v1.begin(), v1.end(), std::ref(calc_mean));

我们需要使用工厂方法std::ref来创建std::reference_wrapper对象。std::reference_wrapper是一个类模板,它将引用包装在一个可赋值、可复制的对象中。它通常用于存储标准容器内的引用,这些容器通常无法保存它们。在我们的例子中,使用std::ref的好处是,它消除了需要明确指定std::for_each的函数对象模板参数是引用类型而不是值类型的需要。以下是我们重构的结果:

Mean::Mean() of 0x7ffe7415a180
Start calculation
void Mean::operator()(const double&) of 0x7ffe7415a180
void Mean::operator()(const double&) of 0x7ffe7415a180
void Mean::operator()(const double&) of 0x7ffe7415a180
void Mean::operator()(const double&) of 0x7ffe7415a180
Finish calculation
The mean value is: 3.25
Mean::~Mean() of 0x7ffe7415a180

正如你所看到的,因为算法直接使用calc_mean对象的引用,所以没有额外的临时对象的创建和销毁。

小心悬空引用

始终确保你在程序中传递的引用将引用到活动对象,直到它们被使用!

函数对象只是我们在示例中可以使用的一个选项。还有另一种方法可以使我们的代码更具表现力。这就是lambda表达式。让我们来看看它们。

Lambda 表达式

C++中的lambda表达式,或简称lambda,是一种定义匿名函数函数对象的简洁方式,可以立即使用或分配给变量以供以后使用。它允许程序员在飞行中编写小型、一次性函数,而无需定义命名函数或函数对象类。Lambda通常与标准库中的算法和容器一起使用,允许编写更简洁和富有表现力的代码。

让我们定义一个简单的lambda,只是打印到标准输出:

auto min_lambda = [](const auto& name) -> void {
    std::cout << name << " lambda.\n";
};
min_lambda("Simple"); 

每个lambda表达式都是一个对象,这意味着它有生命周期并占用内存。每个定义的lambda都是一个事实上的函数对象类定义,因此,它具有独特的类型。程序中不能有两个或多个具有相同类型的lambda。这个类型名是平台特定的,因此,如果你需要将lambda分配给变量,你必须使用auto说明符定义这个变量。

Lambda的语法包括[ ]符号,其后是可选的捕获列表、可选的参数列表、可选的返回类型、可选的可变说明符和函数体。Lambda可以通过值或引用捕获外部作用域中的变量,它们还可以有返回类型推断或显式返回类型,接下来我们将看到。

Lambda表达式可以通过使用捕获列表来访问其定义所在作用域中的其他对象。如果捕获列表为空,则不捕获任何对象。全局对象在lambda中总是可见的,无需显式捕获。定义捕获列表时,你可以选择通过引用捕获对象,或者两者的混合。

在lambda表达式中通过值捕获变量时,这些变量会在定义时刻复制到lambda对象中。在定义lambda之后对原始变量所做的任何修改都不会影响其中存储的副本。所有捕获的对象默认情况下都是只读的,要修改它们,必须显式指定lambda为可变的

另一种捕获变量的方式是通过引用,这会在lambda内部为每个捕获的对象创建一个引用。这允许lambda与外部作用域通信,但至关重要的是,要确保所有通过引用捕获的对象的生命周期超过lambda的生命周期,以防止悬空引用

现在,让我们用lambda而不是函数对象来重构前一节的示例,以计算浮点数向量的平均值。为了运行以下代码,你需要从程序中调用foo()方法:

void foo() {
    double mean{};
    std::vector v1{1.0, 2.5, 4.0, 5.5};
    std::string_view text{"calculating ..."};
    std::for_each(v1.begin(), v1.end(),
                  [&mean, sum{0.0}, count{0}, text](const
                     double& val) mutable {
        std::cout << text << '\n';
        sum += val;
        ++count;
        mean = sum / count;
    });
    std::cout << mean << '\n';
} 

与命名函数和函数对象相比,lambda的一个关键优势是它们可以在调用它们的地方内联。在我们的示例中,我们直接在std::for_each调用语句中定义了lambda。这种方法明确指出,这个lambda除了服务于前面的情况外没有其他存在的理由。

让我们仔细看看lambda原型:

[&mean, sum{0.0}, count{0}, text](const double& val)
  mutable { … } 

在捕获列表中,我们捕获了四个对象。第一个,mean,是通过引用捕获的。在变量名前加上&表示它是通过引用捕获的。我们将使用mean来报告lambda外部计算的平均值。捕获列表中的接下来两个变量,sumcount,是通过值捕获的。如果变量名前没有&,则表示它是通过值捕获的。唯一的例外是捕获类的this指针,它将通过值捕获,但对类成员的访问将通过引用。如你所见,捕获的sumcount并没有在外部作用域中定义;它们只在lambda的作用域中为我们的示例定义。就像函数对象示例一样,它们被用来存储累积的总和和迭代次数。这是一种方便的方式,可以在你的lambda中明确添加状态以用于进一步的计算。当然,你需要通过向捕获传递初始化器来初始化它们,原因有两个 - 一是为了让编译器推断出它们的类型,二是为了在计算中得到预期结果。实现逻辑将在其执行过程中更新sumcount的值,但正如前面所述,这些捕获在lambda的上下文中是只读的。因此,我们不能在不明确声明意图的情况下改变它们。这是通过在参数列表之后和lambda主体之前附加mutable关键字来实现的。

最后捕获的对象是text。它也是通过值捕获的,但这次是从foo()方法的外部作用域捕获的。

一旦程序执行,我们得到以下输出:

calculating ...
calculating ...
calculating ...
calculating ...
3.25 

正如我们所预期的,我们的lambda被调用了四次,计算出的平均值与前一节中函数对象计算的值完全相同。

捕获列表中捕获对象的方式有很多种。以下列表展示了一些适用的规则:

Figure 4.1 – Ways to capture objects in a capture list

第4.1图 - 在捕获列表中捕获对象的方式

现在我们知道了如何正确捕获外部作用域,让我们来了解lambda的参数列表。

参数列表

Lambda的参数列表就像任何其他函数的参数列表一样。这是因为lambda的参数列表实际上是函数对象类中函数调用操作符的参数列表。你可以根据你的使用场景,定义你的lambda来接受任意参数列表。

在lambda参数列表中将一个或多个参数的类型指定为auto,将使其成为泛型lambda。泛型lambda充当模板函数调用操作符:

auto sum = [](auto a, auto b) {
    return a*b;
} 

这实际上相当于如下操作:

class platform_specific_name {
public:
    template<typename T1, typename T2>
    auto operator()(T1 a, T2 b) const {
        return a*b;
    }
}; 

在C++20版本中,如果你愿意,可以显式指定lambda可以获取的模板参数。前面的例子可以重写如下:

auto sum = []<typename T1, typename T2>(T1 a, T2 b) {
    return a*b;
} 

另一个lambda的重要特性是返回类型。让我们看看它的具体内容。

返回类型

指定lambda的返回类型是可选的。如果你不显式指定,编译器将尝试为你推断它。如果编译器未能成功推断,则将生成类型推断的编译器错误。然后,你必须更改你的代码以允许自动返回类型推断或显式指定lambda的返回类型。

以下是返回类型推断的编译器错误:

auto div = [](double x, double y) {
    if (y < 0) { return 0; }
    return x / y;
}; 

这段代码无法编译,因为编译器无法自动推断出lambda的返回类型。它的实现逻辑有两个执行分支。第一个分支返回一个整数字面量0,但另一个分支返回除法的结果,商是一个double数。

为了解决这个问题,我们需要显式指定lambda的返回类型为double

以下是显式指定的返回类型:

auto div = [](double x, double y) -> double {
    if (y < 0) { return 0; }
    return x / y;
}; 

现在,对于编译器来说,返回结果始终转换为double是清晰的。

总结

在本章中,我们探讨了C++中对象的各种方面,包括存储持续时间、作用域和生命周期。我们区分了对象和引用,并讨论了初始化对象的不同方式以及这些初始化发生的时机。此外,我们深入了解了函数对象,了解了它们是什么以及如何有效使用它们。在此基础上,我们还学习了关于lambda表达式及其相对于函数对象的优势。我们讨论了如何正确地使用lambda和函数对象与STL算法一起使用。掌握了这些对象特性的知识后,我们现在可以在下一章中继续讨论C++中的错误处理。

  • 49
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值