文章

C++ 补完

本文是对 C++ 中本人不太熟悉的部分知识点进行补完(但还缺少异常、STL、IO、运算符重载)

基础

初始化

1
2
3
4
5
6
int a = 1;   // 继承自 C 语言的复制初始化Copy initialization
int a {1};   // C++独有的直接列表初始化Direct List initialization
int a = {1}; // 复制列表初始化Copy list initialization
int a {1.1}; // 编译器可以检测初始化值的错误,而不是隐式类型转换,相比于构造函数初始化的特点
int a {};    // 未指定具体值,称为"值初始化"Value initialization,
// 此时int会被初始化为0,也称为零初始化zero initialization

基本数据类型

科学记数法

1
2
3
4
// 34.50 保留尾随的0(两位小数精度)
3.450e1 // e1表示乘10的一次方,就是10
// 0.004000 保留精度
4.000e-3 // e-3表示乘10的-3次方,也就是0.001

布尔型

使用列表初始化时,只接受 true(1) 和 false(0),这 4 个值:

1
2
3
4
5
bool b1 { true };
bool b2 { false };
bool bFalse { 0 }; // okay: initialized to false
bool bTrue  { 1 }; // okay: initialized to true
bool bNo    { 2 }; // error: narrowing conversions disallowed

使用复制初始化时,编译器会将值隐式转化为 bool 类型:

1
2
3
4
bool b1 = 4 ; // copy initialization allows implicit conversion from int to bool
bool b2 = 0 ; // copy initialization allows implicit conversion from int to bool

// b1:true , b2:false

显式类型转换

C-style casts

1
double d { (double)x / y }; // convert x to a double so we get floating point division

static_cast 静态类型转换

static_cast<new_type>(expression)
1
static_cast<int>(5.5)

static_cast 的主要优点是它提供编译时类型检查,从而更难犯无意的错误。static_cast 的功能也(故意)不如 C-style casts 强大,因此你不能无意中删除 const 或执行其他你可能不打算执行的操作。

reinterpret_cast

reinterpret_cast 是 C++ 中一种强大而危险的类型转换操作符,用于在完全不相关的类型之间进行强制类型转换。它基本上可以将任何指针类型转换成任何其他指针类型,甚至可以将指针类型转换为足够大的整型,反之亦然。但是,使用 reinterpret_cast 需要非常小心,因为错误的使用可能会导致不可预测的行为,包括破坏类型安全、引发运行时错误等。

1
2
char* char_ptr = new char[10];
int* int_ptr = reinterpret_cast<int*>(char_ptr);

进制表示

1
2
3
int x{ 012 }; // 八进制,在数字前加0
bin = 0b1010; // 二进制,C++14起
long value { 2'132'673'462 }; // 数字分隔符“'”,C++14起,仅方便阅读

常量和字符串

常量表达式和编译时优化

执行编译时评估(compile-time evaluation)的能力是现代 C++ 中最重要和不断发展的领域之一。

编译器对变量的优化的容易度(高到低):

  • 编译时常量变量 Compile-time constant variables (始终符合优化条件,一般为 constexpr 修饰,const 修饰的在满足条件时也可以)
  • 运行时常量变量 Runtime constant variables (一般为 const 修饰)
  • 非常量变量 Non-const variables (可能仅在简单情况下进行优化)

作用域、持续时间和联系

inline

现代 inline 含义的变化:

现代 C++ 编译器有能力自动判断一个函数是否应该启用内联扩展,所以现在程序员不应使用 inline 关键字来请求函数的内联扩展。

对 C 语言有了解的人应该知道不要在头文件实现函数,因为这样当被多个文件引用时会导致重复定义,违反 ODR(one-definition rule,单一定义原则)。在现代 C++ 中,术语 inline 已演变为“允许多个定义”的意思(将变量或函数变为外部链接)。因此,内联函数是一种允许在多个翻译单元中定义的函数(不违反 ODR)。

内联函数

  • 编译器需要能够在使用该函数的每个翻译单元中查看内联函数的完整定义(前向声明本身是不够的。不过可以使用先前向声明,然后使用函数,再定义这种传统做法)。
  • 内联函数的每个定义都必须相同,否则将导致未定义的行为。链接器会将标识符的所有内联函数定义合并为单个定义(因此仍然满足单一定义规则的要求),所以如果定义不相同,链接器的行为就不可控(不知道该合并成哪个)。为了避免定义不一致的情况,我们可以将带有 inline 的定义放在单一头文件中,这样每个引用该定义的翻译单元都有一致的定义。

以下函数定义是隐式内联的:

  • 在类、结构或联合类型定义中定义的函数(14.3——成员函数)。
  • Constexpr / consteval 函数(5.8——Constexpr 和 consteval 函数)。
  • 从函数模板隐式实例化的函数(11.7——函数模板实例化)。

避免使用 inline 关键字,除非你有特定的、令人信服的理由这样做(例如,你在头文件中定义这些函数或变量)。

consteval 修饰的函数可以在编译时求值,也可以在运行时评估,从 C++20 开始的 consteval:

C++20 引入了关键字 consteval,用于指示函数必须在编译时计算,否则将导致编译错误。此类函数称为即时函数。

内联变量

关于 C++17 后的内联变量

C++17 之后,变量也可以使用 inline 来表示允许多个定义了,但限制条件也和函数一样,必须完整定义,且定义必须相同。

与 constexpr 函数不同的是 constexpr 变量不是隐式内联的(静态 constexpr 数据成员例外)。

命名空间

可以跨文件使用同一个命名空间,它们视为同一命名空间:

circle.h:

1
2
3
4
5
6
7
8
9
#ifndef CIRCLE_H
#define CIRCLE_H

namespace BasicMath
{
    constexpr double pi{ 3.14 };
}

#endif

growth.h:

1
2
3
4
5
6
7
8
9
10
#ifndef GROWTH_H
#define GROWTH_H

namespace BasicMath
{
    // the constant e is also part of namespace BasicMath
    constexpr double e{ 2.7 };
}

#endif

main.cpp:

1
2
3
4
5
6
7
8
9
10
11
12
#include "circle.h" // for BasicMath::pi
#include "growth.h" // for BasicMath::e

#include <iostream>

int main()
{
    std::cout << BasicMath::pi << '\n';
    std::cout << BasicMath::e << '\n';

    return 0;
}

嵌套命名空间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>

namespace Foo
{
    namespace Goo // Goo is a namespace inside the Foo namespace
    {
        int add(int x, int y)
        {
            return x + y;
        }
    }
}

int main()
{
    std::cout << Foo::Goo::add(1, 2) << '\n';
    return 0;
}

从 C++17 开始,嵌套命名空间也可以这样声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

namespace Foo::Goo // Goo is a namespace inside the Foo namespace (C++17 style)
{
    int add(int x, int y)
    {
        return x + y;
    }
}

int main()
{
    std::cout << Foo::Goo::add(1, 2) << '\n';

    // 如果觉得嵌套命名空间太长,可以使用别名
    namespace Active = Foo::Goo; // active now refers to Foo::Goo
    std::cout << Active::add(1, 2) << '\n'; // This is really Foo::Goo::add()
    return 0;
}

全局常量的命名方法

为了根据变量名更好识别一个变量的属性,可以在常量变量的开头加上g_,如g_gravity

也可以将其放在一个表示常量的命名空间中:

1
2
3
4
5
6
7
8
9
namespace constants
{
    constexpr double gravity { 9.8 };
}

int main()
{
    return 0;
}

在多个文件共享全局常量

方法 1,使用 constexpr 常量变量:

constants.h:

1
2
3
4
5
6
7
8
9
10
11
12
13
#ifndef CONSTANTS_H
#define CONSTANTS_H

// define your own namespace to hold constants
namespace constants
{
    // constants have internal linkage by default
    constexpr double pi { 3.14159 };
    constexpr double avogadro { 6.0221413e23 };
    constexpr double myGravity { 9.2 }; // m/s^2 -- gravity is light on this planet
    // ... other related constants
}
#endif

main.cpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "constants.h" // include a copy of each constant in this file

#include <iostream>

int main()
{
    std::cout << "Enter a radius: ";
    double radius{};
    std::cin >> radius;

    std::cout << "The circumference is: " << 2 * radius * constants::pi << '\n';

    return 0;
}

这里要注意一个问题,使用 constexpr 修饰的常量变量的定义默认是内部链接(internal linkage)的(在 C++17 之前,无法指定 constexpr 变量为外部链接),所以每个引用了 constants.h 文件的 cpp 文件都有独立的 pi 实例,这就会浪费内存空间(rodata 段)。

方法二,使用声明与定义分离的 const 常量变量:

constants.h:

1
2
3
4
5
6
7
8
9
10
11
12
#ifndef CONSTANTS_H
#define CONSTANTS_H

namespace constants
{
    // since the actual variables are inside a namespace, the forward declarations need to be inside a namespace as well
    extern const double pi;
    extern const double avogadro;
    extern const double myGravity;
}

#endif

constants.cpp:

1
2
3
4
5
6
7
8
9
#include "constants.h"

namespace constants
{
    // actual global variables
    extern const double pi { 3.14159 };
    extern const double avogadro { 6.0221413e23 };
    extern const double myGravity { 9.2 }; // m/s^2 -- gravity is light on this planet
}

优势:指定了使用外部链接,即使被引用多次,也只产生一个实例,且修改 pi 的值不会导致引用 constants.h 的所有 cpp 重新编译。

方法三,指定 constexpr 常量变量为外部链接(仅 C++17 起支持):

1
2
3
4
5
6
7
8
9
10
11
12
#ifndef CONSTANTS_H
#define CONSTANTS_H

// define your own namespace to hold constants
namespace constants
{
    inline constexpr double pi { 3.14159 }; // note: now inline constexpr
    inline constexpr double avogadro { 6.0221413e23 };
    inline constexpr double myGravity { 9.2 }; // m/s^2 -- gravity is light on this planet
    // ... other related constants
}
#endif

在 C++17 之后,可以使用 inline 修饰符指定 constexpr 常量变量为外部链接,也就是无论被定义多少次,都只有一个唯一全局实例。但是要注意如果这些定义不同,会产生未定义行为,链接器无法判断该使用哪个定义来作为唯一实例,所以一般会将定义放在一个头文件中,这样保证每个引用该头文件的 cpp 内的定义都相同。

namespace 的现代用法

以下写法已经过时(在过大作用域内 using 过大范围的 namespace(这里是整个 std 命名空间),导致命名冲突或隐式覆盖):

1
2
3
4
5
6
7
8
9
10
#include <iostream>

using namespace std;

int main()
{
    cout << "Hello world!\n";

    return 0;
}

考虑如下情况,将会带来 Foo 命名空间的函数 someFcn 覆盖全局作用域的同名函数,很可能导致程序员的误判:

foolib.h:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#ifndef FOOLIB_H
#define FOOLIB_H

namespace Foo
{
    // newly introduced function
    int someFcn(int)
    {
        return 2;
    }

    // pretend there is some useful code that we use here
}
#endif

main.cpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include <foolib.h>

int someFcn(double)
{
    return 1;
}

int main()
{
    using namespace Foo; // Because we're lazy and want to access Foo:: qualified names without typing the Foo:: prefix
    std::cout << someFcn(0) << '\n'; // The literal 0 should be 0.0, but this is an easy mistake to make

    return 0;
}

现在更推荐限制 namespace 的范围以及 using 的作用域方式:

1
2
3
4
5
6
7
8
9
#include <iostream>

int main()
{
   using std::cout; // this using declaration tells the compiler that cout should resolve to std::cout
   cout << "Hello world!\n"; // so no std:: prefix is needed here!

   return 0;
} // the using declaration expires at the end of the current scope
1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main()
{
    {
        using namespace Foo; // using的作用域被限定在块内
        // calls to Foo:: stuff here
    } // using namespace Foo expires

    {
        using namespace Goo;
        // calls to Goo:: stuff here
    } // using namespace Goo expires

    return 0;
}

当然最推荐的还是不用使用 using ,而是直接使用 Foo::someFcn 的方式。

未命名命名空间(Unnamed namespaces )

未命名命名空间(也称为匿名命名空间)是没有名称定义的命名空间,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>

namespace // unnamed namespace
{
    void doSomething() // 具有内部连接性,can only be accessed in this file
    {
        std::cout << "v1\n";
    }
}

int main()
{
    doSomething(); // we can call doSomething() without a namespace prefix

    return 0;
}

在未命名命名空间中声明的所有内容都被视为父命名空间的一部分。因此,即使函数 doSomething() 是在未命名的命名空间中定义的,该函数本身也可以从父命名空间(在本例中是全局命名空间)访问,这就是为什么我们可以从 main() 调用 doSomething(),不带任何限定符。

由于其为内部连接性,所以其等价于 static 修饰符修饰的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>

static void doSomething() // can only be accessed in this file
{
    std::cout << "v1\n";
}

int main()
{
    doSomething(); // we can call doSomething() without a namespace prefix

    return 0;
}

在现代 C++ 中,使用 static 关键字来提供标识符内部链接已经不再受欢迎。未命名的命名空间可以为多种类型的标识符(例如类型标识符)提供内部链接,并且它们更适合同时为多个标识符(形成一个组)提供内部链接。

内联命名空间(Inline namespaces)

inline 关键字的主要作用是使得该命名空间中的所有名称都可以被视为在外层命名空间中直接可见,而不需要通过命名空间的名称进行限定访问。这种特性通常用于版本控制和接口管理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>

namespace V1 // declare a normal namespace named V1
{
    void doSomething()
    {
        std::cout << "V1\n";
    }
}

inline namespace V2 // declare an inline namespace named V2
{
    namespace // unnamed namespace,如果想要内部连接性,就套这个匿名namespace
    {
        void doSomething() // has internal linkage
        {
            std::cout << "V2\n";
        }

    }
}

int main()
{
    V1::doSomething(); // calls the V1 version of doSomething()
    V2::doSomething(); // calls the V2 version of doSomething()

    ::doSomething(); // calls the inline version of doSomething() (which is V2)

    return 0;
}

const 的默认作用域

与 C 语言不同的是,在 C++ 中,const 修饰的变量默认是 Internal linkage

1.c:

1
const int a = 5; // 在 C 中默认为 external linkage,在 C++ 中需要显式加上 extern

2.c:

1
2
3
4
5
6
#include <stdio.h>
extern int a;
int main()
{
    printf("Hello, World!%d\n",a);
}

以上情况在使用 g++ 时无法链接(undefined reference to `a’),在使用 gcc 时可以链接。

控制流

std::optional 返回值

std::optional<T> 是 C++17 引入的返回值模板。用于同时包含正常返回值(T 类型)和异常返回值(或者叫不包含值)

1
2
3
4
5
6
7
8
9
10
11
12
13
// 构造
std::optional<int> o1 { 5 };            // initialize with a value
std::optional<int> o2 {};               // initialize with no value
std::optional<int> o3 { std::nullopt }; // initialize with no value

// 判断值是否存在
if (o1.has_value()) // call has_value() to check if o1 has a value
if (o2)             // use implicit conversion to bool to check if o2 has a value

// 取值
std::cout << *o1;             // dereference to get value stored in o1 (undefined behavior if o1 does not have a value)
std::cout << o2.value();      // call value() to get value stored in o2 (throws std::bad_optional_access exception if o2 does not have a value)
std::cout << o3.value_or(42); // call value_or() to get value stored in o3 (or value `42` if o3 doesn't have a value)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include <optional> // for std::optional (C++17)

// Our function now optionally returns an int value
std::optional<int> doIntDivision(int x, int y)
{
    if (y == 0)
        return {}; // or return std::nullopt
    return x / y;
}

int main()
{
    std::optional<int> result1 { doIntDivision(20, 5) };
    if (result1) // if the function returned a value
        std::cout << "Result 1: " << *result1 << '\n'; // get the value
    else
        std::cout << "Result 1: failed\n";

    std::optional<int> result2 { doIntDivision(5, 0) };

    if (result2)
        std::cout << "Result 2: " << *result2 << '\n';
    else
        std::cout << "Result 2: failed\n";

    return 0;
}

类型转换、类型别名和类型推导

类型定义和类型别名

1
2
3
using Distance = double; // define Distance as an alias for type double

Distance milesToDestination{ 3.4 }; // defines a variable of type double

尽可能使用 using,而不是难以阅读的 typedef ,更不要使用预处理 #define(编译器无法看到预处理过程,难以做出诊断):

1
2
typedef int (*FcnType)(double, char); // FcnType hard to find
using FcnType = int(*)(double, char); // FcnType easier to find

类型别名有助于让代码更简洁:

1
2
using VectPairSI = std::vector<std::pair<std::string, int>>; // make VectPairSI an alias for this crazy type
VectPairSI pairlist; // instantiate a VectPairSI variable

有助于让类型的含义更清晰:

1
2
using TestScore = int;
TestScore gradeTest();

注意 using(和 typedef) 只是创建了一个类型别名,在编译阶段将别名替换为正常名称,而并没有创建一个类型。而某些语言支持的 强typedef(strong typedef) 将会创建一个新类型,虽然两个类型属性相同,但将这两个类型对应的值进行混合操作时编译器依然会报错。

类型推导

优点

  1. 代码更整齐

    1
    2
    3
    4
    5
    6
    7
    
    // harder to read
    int a { 5 };
    double b { 6.7 };
    
    // easier to read
    auto c { 5 };
    auto d { 6.7 };
    
  2. 由于使用 auto 时不允许未初始化的变量(编译会失败),可以避免意外失误

    1
    2
    
    int x; // oops, we forgot to initialize x, but the compiler may not complain
    auto y; // the compiler will error out because it can't deduce a type for y
    
  3. 保证不会出现意外影响性能的转换

    1
    2
    3
    4
    
    std::string_view getString();   // some function that returns a std::string_view
    
    std::string s1 { getString() }; // bad: expensive conversion from std::string_view to std::string (assuming you didn't want this)
    auto s2 { getString() };        // good: no conversion required
    

缺点

推导出的类型可能并不符合程序员的实际要求:

1
auto y { 5 }; // oops, we wanted a double here but we accidentally provided an int literal

函数类型推导

在 C++14 中,扩展了 auto 关键字来进行函数返回类型推导。

1
2
3
4
auto add(int x, int y)
{
    return x + y;
}

注意:尽量不要使用函数返回值类型的自动推导!

尾随类型用法

C++ 允许在函数声明或定义时使用 auto 替代类型,并在尾部添加类型说明,此时 auto 不执行任何类型推导。

1
2
3
4
5
6
7
auto add(int x, int y) -> int
{
  return (x + y);
}

auto add(int x, int y) -> int;
auto divide(double x, double y) -> double;

至于好处,应该只有声明的时候看上去对齐点。

函数重载和函数模板

函数重载

函数重载不考虑 const:

1
2
void print(int);
void print(const int); // not differentiated from print(int)

也不考虑返回值类型:

1
2
int getRandomValue();
double getRandomValue();

类型匹配与隐式转换(过于复杂,跳过):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void print(int)
{
}

void print(double)
{
}

int main()
{
    print('a'); // promoted to match print(int)
    print(true); // promoted to match print(int)
    print(4.5f); // promoted to match print(double)

    return 0;
}

这里的 'a' 实参由于找不到匹配形参类型的函数定义,所以使用类型提升,转换为 int 类型进行匹配。此时如果也没有 int 类型形参的函数定义,将会转换为 double 类型进行匹配。详见本文

删除函数

可以使用 delete 删除不想要的函数重载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

void printInt(int x)
{
    std::cout << x << '\n';
}

void printInt(char) = delete; // 禁用 char 类型实参的重载
void printInt(bool) = delete; // 禁用 bool 类型实参的重载

int main()
{
    printInt(97);   // okay

    printInt('a');  // compile error: function deleted
    printInt(true); // compile error: function deleted

    printInt(5.0);  // compile error: ambiguous match

    return 0;
}

使用类模板批量删除不匹配的函数重载,可以实现强制类型匹配:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

// This function will take precedence for arguments of type int
void printInt(int x)
{
    std::cout << x << '\n';
}

// This function template will take precedence for arguments of other types
// Since this function template is deleted, calls to it will halt compilation
template <typename T>
void printInt(T x) = delete;

int main()
{
    printInt(97);   // okay
    printInt('a');  // compile error
    printInt(true); // compile error

    return 0;
}

复合类型:引用和指针

左值和右值表达式

左值表达式计算结果为可识别对象。

右值表达式计算结果为一个值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int return5()
{
    return 5;
}

int main()
{
    int x{ 5 }; // 5 is an rvalue expression
    const double d{ 1.2 }; // 1.2 is an rvalue expression

    int y { x }; // x is a modifiable lvalue expression
    const double e { d }; // d is a non-modifiable lvalue expression
    int z { return5() }; // return5() is an rvalue expression (since the result is returned by value)

    int w { x + 1 }; // x + 1 is an rvalue expression
    int q { static_cast<int>(d) }; // the result of static casting d to an int is an rvalue expression

    return 0;
}

左值引用

可以使用左值引用符号&创建一个左值引用:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>

int main()
{
    int x { 5 };    // x is a normal integer variable
    int& ref { x }; // ref is an lvalue reference variable that can now be used as an alias for variable x

    std::cout << x << '\n';  // print the value of x (5)
    std::cout << ref << '\n'; // print the value of x via ref (5)

    return 0;
}

注意:这里的 & 符号并不是 C 语言中的取地址的意思,注意区分。

左值引用不能绑定到不可修改的左值或右值(否则你可以通过引用更改这些值,这将违反其常量性)。因此,左值引用有时也称为非常量左值引用(有时简称为非常量引用):

1
2
3
4
5
6
7
8
9
10
11
int main()
{
    int x { 5 };
    int& ref { x }; // valid: lvalue reference bound to a modifiable lvalue

    const int y { 5 };
    int& invalidRef { y };  // invalid: can't bind to a non-modifiable lvalue
    int& invalidRef2 { 0 }; // invalid: can't bind to an rvalue

    return 0;
}

可以使用常量引用方式来引用常量:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>

int main()
{
    const int x { 5 };    // x is a non-modifiable lvalue
    const int& ref { x }; // okay: ref is a an lvalue reference to a const value

    std::cout << ref << '\n'; // okay: we can access the const object
    ref = 6;                  // error: we can not modify an object through a const reference

    return 0;
}

同时常量引用可以绑定到可修改的左值、不可修改的左值和右值。这使得它们成为更加灵活的参考类型:

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>

int main()
{
    // 常量引用绑定可以绑定到右值,甚至不是对象
    const int& ref { 5 }; // The temporary object holding value 5 has its lifetime extended to match ref

    std::cout << ref << '\n'; // Therefore, we can safely use it here

    return 0;
} // Both ref and the temporary object die here

在大多数情况下,引用的类型必须与引用对象的类型匹配(此规则有一些例外,我们将在讨论继承时讨论):

1
2
3
4
5
6
7
8
9
10
11
int main()
{
    int x { 5 };
    int& ref { x }; // okay: reference to int is bound to int variable

    double y { 6.0 };
    int& invalidRef { y }; // invalid; reference to int cannot bind to double variable
    double& invalidRef2 { x }; // invalid: reference to double cannot bind to int variable

    return 0;
}

引用无法重新定位(更改为引用另一个对象):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>

int main()
{
    int x { 5 };
    int y { 6 };

    int& ref { x }; // ref is now an alias for x

    ref = y; // assigns 6 (the value of y) to x (the object being referenced by ref)
    // The above line does NOT change ref into a reference to variable y!

    std::cout << x << '\n'; // user is expecting this to print 5

    return 0;
}

上例中,将会打印 6 ,也就是 x 被修改为了 6,这是因为上述操作其实并没有修改 ref 的引用为 y,而是让 ref 作为了 x 的别名直接为 x 赋值了 y 的值x=y

悬空引用(Dangling references ):当被引用的对象在对其的引用之前被销毁时,该引用将继续引用不再存在的对象。这样的引用称为悬空引用。访问悬空引用会导致未定义的行为。

“引用”本身就是一个已经存在的变量的别名(编译器优化时可能会将其视为被引用的实际对象),而不是一个可以独立存在的对象,所以一些只能对”对象”进行的操作将无法对”引用”生效,如你不能创建一个”引用”的”引用”(但可以创建被引用对象的另一个引用),一个”引用”的数组,或者一个指向”引用”的指针:

1
2
3
4
5
6
int a = 10;
int& ref = a;
int& ref1 = ref; // 正确:创建被引用对象的另一个引用
int&& refRef = ref; // 错误:不能创建引用的引用(而且这个语法专用于右值引用)
int& arrOfRefs[10] = {/* ... */}; // 错误:不能创建引用的数组
int&* ptrToRef = &ref; // 错误:不能创建指向引用的指针

按值传递和按引用传递参数

C++ 提供了按值传递(call-by-value)和按引用传递(call-by-reference)两种参数传递方式

按值传递

当按值传递参数时,原则上所有的参数都会被拷贝。因此每一个参数都会是被传递实参的一份拷贝(深拷贝)。对于 class 的对象,参数会通过 class 的拷贝构造函数来做初始化。

调用拷贝构造函数的成本可能很高。但是有多种方法可以避免按值传递的高昂成本:事实上编译器可以通过移动语义(move semantics)来优化掉对象的拷贝,这样即使是对复杂类型的拷贝,其成本也不会很高。

1
2
3
4
5
6
std::string returnString();
std::string s = "hi";
printV(s); // 参数为左值,会调用构造函数
printV(std::string("hi")); // 参数为右值,使用移动语义
printV(returnString()); // 参数为右值,使用移动语义
printV(std::move(s)); // 参数为右值,无需重新构造

不过由于编译器的优化能力较强,可以有效避免传递成本,还是建议在函数模板中应该优先使用按值传递,除非遇到以下情况:

  • 对象不允许被 copy。
  • 参数被用于返回数据。
  • 参数以及其所有属性需要被模板转发到别的地方。
  • 可以获得明显的性能提升

另外按值传递会导致类型退化(decay),也就是裸数组类型会退化为指针,const 和 volatile 等限制符会被删除,这是从 C 语言中继承下来的特性:

1
2
3
4
5
6
7
8
template <typename T> void printV(T arg) {
  // ...
}
std::string const c = "hi";
printV(c);    // 退化,const被删除
printV("hi"); // 退化为指针,T 被推断为char const*,而不是string
int arr[4];
printV(arr);  // 退化为指针,丢失数组大小信息
按引用传递

C++11 引入了移动语义(move semantics)后,共有三种按引用传递方式:

  1. X const &(const 左值引用) 参数引用了被传递的对象,并且参数不能被更改。
  2. X &(非 const 左值引用) 参数引用了被传递的对象,并且参数可以被更改。
  3. X &&(右值引用) 参数通过移动语义引用了被传递的对象,并且参数值可以被更改或者被“窃取”。一般不会为右值引用增加 const 修饰符,因为右值引用的用途就是为了修改

以下模板永远不会拷贝被传递对象(不管拷贝成本是高还是低):

1
2
3
4
5
6
7
8
9
10
template <typename T> void printR(T const &arg) {
  // ...
}

std::string returnString();
std::string s = "hi";
printR(s);                 // no copy
printR(std::string("hi")); // no copy
printR(returnString());    // no copy
printR(std::move(s));      // no copy

按引用传递参数时,其类型不会退化(decay)。也就是说不会把裸数组转换为指针,也不会移除 const 和 volatile 等限制符。而且由于调用参数被声明为 T const &,被推断出来的模板参数 T 的类型将不包含 const。比如:

1
2
3
4
5
6
7
8
9
template<typename T>
void printR (T const& arg) {
  ...
}
std::string const c = "hi";
printR(c); // T deduced as std::string, arg is std::string const&
printR("hi"); // T deduced as char[3], arg is char const(&)[3]
int arr[4];
printR(arr); // T deduced as int[4], arg

按引用传递参数可以减少对象复制的消耗。而且可以在函数中修改实参的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <string>

void printValue(std::string& y) // type changed to std::string&
{
    std::cout << y << '\n';
} // y is destroyed here

int main()
{
    std::string x { "Hello, world!" };

    printValue(x); // x is now passed by reference into reference parameter y (inexpensive)

    return 0;
}

对于复制成本较低的对象,首选按值传递;对于复制成本较高的对象,首选按常量引用传递。如果你不确定复制一个对象是否便宜或昂贵,请通过常量引用传递。

优先使用 std::string_view (按值)而不是 const std::string& 传递字符串,除非你的函数调用一个需要 C 样式字符串或 std::string 作为参数的其他函数。因为从其他类型的实参转为 std::string_view 的代价较低。

和传统的 C 语言中的按地址传递的方式相比,在现代 C++ 中,大多数可以按地址传递完成的事情都可以通过其他方法更好地完成。

本质都是按值传递

对于按引用传递,一般编译器会采用传递指针的方式来实现,而传递指针(称为按地址传递)本质就是把指针复制为函数使用的参数(只拷贝 4 个字节),所以本质还是按”值”传递(这个值表示指针值)。所以所有的参数传递方式本质都是按值传递。

对指针的引用

可以这么用,但一般用的比较少。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

// 形参是对int指针类型对象的引用
void nullify(int*& refptr) // refptr is now a reference to a pointer
{
    refptr = nullptr; // Make the function parameter a null pointer
}

// 注意 int&* refptr 的写法是错误的,因为“引用类型的refptr”不是对象,不能作为指针

int main()
{
    int x{ 5 };
    int* ptr{ &x }; // ptr points to x,int指针类型的对象(在32位机器中可以理解为一个4字节的对象)

    std::cout << "ptr is " << (ptr ? "non-null\n" : "null\n");

    nullify(ptr);

    std::cout << "ptr is " << (ptr ? "non-null\n" : "null\n");
    return 0;
}

nullptr

建议空指针使用专门的 nullptr 而不是 0NULL

按引用返回的问题

如果对象的生命周期仅限制在函数块内(局部变量),那么它会在函数返回时被销毁,该引用就变为悬空引用,导致未定义行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <string>

const std::string& getProgramName() // will return a const reference
{
    const std::string programName{ "Calculator" }; // 非static,为局部变量,在函数返回时销毁

    // const std::string& programName{ "Calculator" }; // 这么写的话运行时不会报错,但本质还是未定义行为,programName引用的原始对象会在函数返回时析构,后面name构造完其实是个空值。

    return programName; // 返回值引用后对象被销毁,变为悬空引用
}

int main()
{
    std::string name { getProgramName() }; // makes a copy of a dangling reference
    std::cout << "This program is named " << name << '\n'; // undefined behavior

    return 0;
}

临时变量情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>

const int& returnByConstReference()
{
    return 5; // 这里实际是创建了一个int类型的临时(常量)变量,生命周期和局部变量相同
    // 局部变量或临时变量无法适用“生命周期延长”
}

int main()
{
    const int& ref { returnByConstReference() }; // 使用返回的引用时已经是悬空引用

    std::cout << ref; // undefined behavior

    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
#include <iostream>
#include <string>

const std::string& foo(const std::string& s)
{
    return s; // 此时的s并不是属于该函数的局部变量,其生命周期不会因本函数返回结束
}

std::string getHello()
{
    return std::string{"Hello"};
}

int main()
{
    // foo返回后返回值(引用)依然有效,而不是悬空指针。
    // getHello() 返回值的生命周期被延长,所以可以维持到对s的赋值结束。
    const std::string s{ foo(getHello()) };

    std::cout << s;

    return 0;
}

生命周期延长(lifetime extension)可以让右值在被const 引用时延长其原有的生命周期,上例中 getHello() 的返回值本来应该在返回后立即释放,但由于 foo() 函数通过 const 引用的方式使用了其返回值,导致该返回值的生命周期延长为该引用的生命周期,直到 s 赋值完毕。

但要注意生命周期延长不能跨函数边界工作。也就是说通过返回值引用函数内的局部变量无法延长该局部变量的生命周期,它依然会在函数返回后被销毁,导致返回值的悬空引用,见上上个例子。

从 C++11 开始,通过右值引用也可以实现生命周期延长。

auto 类型自动推导

自动推导会删除顶级 const , constexpr 和引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <string>

std::string& getRef(); // some function that returns a reference
const double foo()
{
    return 5.6;
}

int main()
{
    const double cd{ 7.8 };

    // 自动推导删除了 const
    auto x{ cd };    // double (const dropped)
    auto y{ foo() }; // double (const dropped)

    constexpr double ced{ 7.8 };

    // 手动保留 const 和 constexpr
    const auto x{ foo() };  // const double (const dropped, const reapplied)
    constexpr auto y{ ced }; // constexpr double (constexpr dropped, constexpr reapplied)

    // 删除了引用,auto被推断为 std::string
    auto ref { getRef() }; // type deduced as std::string (not std::string&)

    return 0;
}

关于顶级(Top-level)和低级(low-level) const:

顶级 const 是应用于对象本身的 const 限定符。例如:

1
2
const int x;    // this const applies to x, so it is top-level
int* const ptr; // this const applies to ptr(一个指针), so it is top-level

相反,低级 const 是适用于被引用或指向的对象的 const 限定符:

1
2
const int& ref; // this const applies to the object being referenced, so it is low-level
const int* ptr; // this const applies to the object being pointed to, so it is low-level

auto 真的好复杂

复合类型:枚举和结构

枚举类型

无作用域枚举(unscoped enumerations)

无作用域枚举就是从 C 语言继承过来的传统枚举。

最好将枚举放在命名范围区域(例如命名空间或类)中,这样枚举器就不会污染全局命名空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
namespace Color
{
    // The names Color, red, blue, and green are defined inside namespace Color
    enum Color
    {
        red,
        green,
        blue,
    };
}

namespace Feeling
{
    enum Feeling
    {
        happy,
        tired,
        blue, // Feeling::blue doesn't collide with Color::blue
    };
}

int main()
{
    Color::Color paint{ Color::blue };
    Feeling::Feeling me{ Feeling::blue };

    return 0;
}

可以显式指定枚举的基础类型。例如,如果你正在某些带宽敏感的上下文中工作(例如通过网络发送数据),你可能需要为枚举指定较小的类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <cstdint>  // for std::int8_t
#include <iostream>

// Use an 8-bit integer as the enum underlying type
enum Color : std::int8_t
{
    black,
    red,
    blue,
};

int main()
{
    Color c{ black };
    std::cout << sizeof(c) << '\n'; // prints 1 (byte)

    return 0;
}

作用域枚举(Scoped enumerations)

作用域枚举就是在 enum 后加了个 class,包含了命名空间的作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>

int main()
{
    enum class Color // "enum class" defines this as a scoped enum rather than an unscoped enum
    {
        red, // red is considered part of Color's scope region
        blue,
    };

    std::cout << red << '\n';        // compile error: red not defined in this scope region
    std::cout << Color::red << '\n'; // compile error: std::cout doesn't know how to print this (will not implicitly convert to int)

    Color color { Color::blue }; // okay

    return 0;
}

从 C++20 起可以使用 using 来避免前缀输入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>
#include <string_view>

enum class Color
{
    black,
    red,
    blue,
};

constexpr std::string_view getColor(Color color)
{
    using enum Color; // bring all Color enumerators into current scope (C++20)
    // We can now access the enumerators of Color without using a Color:: prefix

    switch (color)
    {
    case black: return "black"; // note: black instead of Color::black
    case red:   return "red";
    case blue:  return "blue";
    default:    return "???";
    }
}

int main()
{
    Color shirt{ Color::blue };

    std::cout << "Your shirt is " << getColor(shirt) << '\n';

    return 0;
}

委托构造函数(Delegating constructors)

通过委托构造函数复用函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>
#include <string>
#include <string_view>

class Employee
{
private:
    std::string m_name{};
    int m_id{ 0 };

public:
    // 复用了两个参数的构造函数
    Employee(std::string_view name)
        : Employee{ name, 0 } // delegate initialization to Employee(std::string_view, int) constructor
    {
    }

    Employee(std::string_view name, int id)
        : m_name{ name }, m_id{ id } // actually initializes the members
    {
        std::cout << "Employee " << m_name << " created\n";
    }

};

int main()
{
    Employee e1{ "James" };
    Employee e2{ "Dave", 42 };
}

转换构造函数(Converting constructors)

可用于执行隐式转换的构造函数称为转换构造函数。

下例中 printFoo() 函数要求的是一个 Foo 类型的值,但实参却是 int 类型的,此时就会产生 int -> Foo 的隐式转换,调用了 Foo 类的 int 类型构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>

class Foo
{
private:
    int m_x{};
public:
    Foo(int x)
        : m_x{ x }
    {
    }

    int getX() const { return m_x; }
};

void printFoo(Foo f) // has a Foo parameter
{
    std::cout << f.getX();
}

int main()
{
    printFoo(5); // we're supplying an int argument
    // 通过转换构造函数,该表达式变为 printFoo(Foo{5});
    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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <iostream>
#include <string>
#include <string_view>

class Employee
{
private:
    std::string m_name{};

public:
    Employee(std::string_view name)
        : m_name{ name }
    {
    }

    const std::string& getName() const { return m_name; }
};

void printEmployee(Employee e) // has an Employee parameter
{
    std::cout << e.getName();
}

int main()
{
    // 尝试使用转换构造函数,因为实参是 C 风格的字符字面量,尝试转为构造函数支持的string_view,
    // 然后将无法使用该转换构造函数,因为转换只能发生一次!如果使用了隐式类型转换,将无法使用转换构造函数
    printEmployee("Joe"); // we're supplying an string literal argument


    // 解决方案1,显式调用构造函数创建匿名临时对象,不使用转换构造函数:
    printEmployee(Employee{"Joe"});

    // 解决方案2,使用std::string_view类型字面量:
    using namespace std::literals;
    printEmployee( "Joe"sv); // now a std::string_view literal

    return 0;
}

The explicit keyword 显式关键字,用于阻止转换构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>

class Dollars
{
private:
    int m_dollars{};

public:
    explicit Dollars(int d) // now explicit
        : m_dollars{ d }
    {
    }

    int getDollars() const { return m_dollars; }
};

void print(Dollars d)
{
    std::cout << "$" << d.getDollars();
}

int main()
{
    print(5); // compilation error because Dollars(int) is explicit

    return 0;
}

在实际项目中应该为所有的构造函数默认添加 explicit 关键字,除非真的需要用到转换构造函数。

this 指针

将类重置回默认状态

this 指针的一种用法是将类重置回默认状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>

class Calc
{
private:
    int m_value{};

public:
    Calc& add(int value) { m_value += value; return *this; }
    Calc& sub(int value) { m_value -= value; return *this; }
    Calc& mult(int value) { m_value *= value; return *this; }

    int getValue() const { return m_value; }

    void reset() { *this = {}; } // 将类重置回默认状态
};


int main()
{
    Calc calc{};
    calc.add(5).sub(3).mult(4);

    std::cout << calc.getValue() << '\n'; // prints 8

    calc.reset();

    std::cout << calc.getValue() << '\n'; // prints 0

    return 0;
}

类成员函数隐式内联

正因如此,所以才能在头文件中实现成员函数定义而不违反 ODR 原则。

将成员函数实现放在头文件中的好处是文件结构更加简洁,坏处是对其修改会重新编译所有依赖它的文件,现代 C++ 库越来越多使用了仅头文件模式。

嵌套类型

class 内支持三类成员:

  • 数据成员(data members)
  • 成员函数(member functions)
  • 嵌套类型(Nested types, 也称 member types)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <iostream>

class Fruit
{
public:
    // 下面就是三种嵌套类型的定义方式
	// FruitType has been moved inside the class, under the public access specifier
    // We've also renamed it Type and made it an enum rather than an enum class
	// 通过enum定义
    enum Type
	{
		apple,
		banana,
		cherry
	};
    // 通过using方式定义
    using IDType = int;
    // 通过定义一个完整的类来定义,用的比较少
    class Printer
    {
    public:
        void print(const Employee& e) const
        {
            // Printer can't access Employee's `this` pointer
            // so we can't print m_name and m_id directly
            // Instead, we have to pass in an Employee object to use
            // Because Printer is a member of Employee,
            // we can access private members e.m_name and e.m_id directly
            std::cout << e.m_name << " has id: " << e.m_id << '\n';
        }
    };

private:
	Type m_type {};
    IDType m_id{};
	int m_percentageEaten { 0 };

public:
	Fruit(Type type) :
		m_type { type }
	{
	}

	Type getType() { return m_type;  }
	int getPercentageEaten() { return m_percentageEaten;  }

	bool isCherry() { return m_type == cherry; } // Inside members of Fruit, we no longer need to prefix enumerators with FruitType::
};

int main()
{
	// Note: Outside the class, we access the enumerators via the Fruit:: prefix now
	Fruit apple { Fruit::apple };

	if (apple.getType() == Fruit::apple)
		std::cout << "I am an apple";
	else
		std::cout << "I am not an apple";

	return 0;
}

静态成员变量

不要将静态成员定义放在头文件中(就像全局变量一样,如果多次包含该头文件,最终将得到多个定义,这将导致编译错误)。该错误可以通过为静态成员添加 inline 修饰符解决(C++17 开始)。

1
2
3
4
5
class Whatever
{
public:
    static inline int s_value{ 4 }; // a static inline variable can be defined and initialized directly
};

友元函数

在类的主体内部,可以使用友元声明(使用 friend 关键字)来告诉编译器某些其他类或函数现在是友元。在 C++ 中,友元是已被授予对另一个类的私有和受保护成员的完全访问权限的类或函数(成员或非成员)。通过这种方式,一个类可以有选择地授予其他类或函数对其成员的完全访问权限,而不会影响其他任何内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <iostream>

class Humidity; // forward declaration of Humidity

class Temperature
{
private:
    int m_temp { 0 };
public:
    explicit Temperature(int temp) : m_temp { temp } { }

    // 虽然在类中声明,但友元函数并不属于类作用域
    friend void printWeather(const Temperature& temperature, const Humidity& humidity); // forward declaration needed for this line
};

class Humidity
{
private:
    int m_humidity { 0 };
public:
    explicit Humidity(int humidity) : m_humidity { humidity } {  }

    friend void printWeather(const Temperature& temperature, const Humidity& humidity);
};

// 一个函数可以同时成为多个类的友元
void printWeather(const Temperature& temperature, const Humidity& humidity)
{
    std::cout << "The temperature is " << temperature.m_temp <<
       " and the humidity is " << humidity.m_humidity << '\n';
}

int main()
{
    Humidity hum { 10 };
    Temperature temp { 12 };

    printWeather(temp, hum);

    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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <iostream>

class Storage
{
private:
    int m_nValue {};
    double m_dValue {};
public:
    Storage(int nValue, double dValue)
       : m_nValue { nValue }, m_dValue { dValue }
    { }

    // Make the Display class a friend of Storage
    friend class Display;
};

class Display
{
private:
    bool m_displayIntFirst {};

public:
    Display(bool displayIntFirst)
         : m_displayIntFirst { displayIntFirst }
    {
    }

    // Because Display is a friend of Storage, Display members can access the private members of Storage
    void displayStorage(const Storage& storage)
    {
        if (m_displayIntFirst)
            std::cout << storage.m_nValue << ' ' << storage.m_dValue << '\n';
        else // display double first
            std::cout << storage.m_dValue << ' ' << storage.m_nValue << '\n';
    }

    void setDisplayIntFirst(bool b)
    {
         m_displayIntFirst = b;
    }
};

int main()
{
    Storage storage { 5, 6.7 };
    Display display { false };

    display.displayStorage(storage);

    display.setDisplayIntFirst(true);
    display.displayStorage(storage);

    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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <iostream>

class Display
{
private:
	bool m_displayIntFirst {};

public:
	Display(bool displayIntFirst)
		: m_displayIntFirst { displayIntFirst }
	{
	}

	void displayStorage(const Storage& storage) // compile error: compiler doesn't know what a Storage is
	{
		if (m_displayIntFirst)
			std::cout << storage.m_nValue << ' ' << storage.m_dValue << '\n';
		else // display double first
			std::cout << storage.m_dValue << ' ' << storage.m_nValue << '\n';
	}
};

class Storage
{
private:
	int m_nValue {};
	double m_dValue {};
public:
	Storage(int nValue, double dValue)
		: m_nValue { nValue }, m_dValue { dValue }
	{
	}

	// Make the Display::displayStorage member function a friend of the Storage class
	friend void Display::displayStorage(const Storage& storage); // okay now
};

int main()
{
    Storage storage { 5, 6.7 };
    Display display { false };
    display.displayStorage(storage);

    return 0;
}

引用限定符(Ref qualifiers,很少用)

对象为左值还是右值会影响到成员函数的行为,但 C++默认并不会区分这两种情况,可以使用引用限定符来分别实现两套不同的函数:

1
2
3
4
// 只允许对象为左值情况下使用,返回值的引用
const auto& getName() const &  { return m_name; } //  & qualifier overloads function to match only lvalue implicit objects, returns by reference
// 只允许对象为右值情况下使用,返回值的复制。因为为右值的情况下返回后对象会被销毁
auto        getName() const && { return m_name; } // && qualifier overloads function to match only rvalue implicit objects, returns by value

函数指针

C 语言方式:

1
bool (*ptr)(int, int); // definition of function pointer ptr

C++中使用 using 方式:

1
using ValidateFunction = bool(*)(int, int);

C++中使用 function 库方式(比上一种更清晰):

1
2
#include <functional>
using ValidateFunction = std::function<bool(int, int)>; // type alias to std::function

lambda 表达式

lambda 表达式是一种可以内嵌的匿名函数,用以解决 C++ 不支持函数嵌套的问题。

1
2
3
4
[ captureClause(捕获子句) ] ( parameters(参数) ) -> returnType(返回类型)
{
    statements;
}

其中绝大部分内容允许省略,所以最简的表达式应该如下:

1
2
3
4
5
6
7
8
#include <iostream>

int main()
{
  [] {}; // a lambda with an omitted return type, no captures, and omitted parameters.

  return 0;
}

实例,这里用一个 lambda 表达式作为 std::find_if 函数需要的对比函数指针参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <algorithm>
#include <array>
#include <iostream>
#include <string_view>

int main()
{
  constexpr std::array<std::string_view, 4> arr{ "apple", "banana", "walnut", "lemon" };

  // Define the function right where we use it.
  auto found{ std::find_if(arr.begin(), arr.end(),
                           [](std::string_view str) // here's our lambda, no capture clause
                           {
                             return str.find("nut") != std::string_view::npos;
                           }) };

  if (found == arr.end())
  {
    std::cout << "No nuts\n";
  }
  else
  {
    std::cout << "Found " << *found << '\n';
  }

  return 0;
}

遵循在最小范围内定义事物并尽可能靠近首次使用(这里的意思应该是使用的位置要靠近定义)的最佳实践,当我们需要一个简单的一次性函数作为参数传递给其他函数时,lambda 比普通函数更受青睐。

lambda 表达式在 C++ 中是一种特殊对象,只能用 auto 类型对象存储,这给了 lambda 表达式能够被多次使用的能力:

1
2
3
4
5
6
7
8
9
// Good: Instead, we can store the lambda in a named variable and pass it to the function.
auto isEven{
  [](int i)
  {
    return (i % 2) == 0;
  }
};

return std::all_of(array.begin(), array.end(), isEven);

lambda 作为函数参数的四种方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <functional>
#include <iostream>

// Case 1: use a `std::function` parameter
void repeat1(int repetitions, const std::function<void(int)>& fn)
{
    for (int i{ 0 }; i < repetitions; ++i)
        fn(i);
}

// Case 2: use a function template with a type template parameter
template <typename T>
void repeat2(int repetitions, const T& fn)
{
    for (int i{ 0 }; i < repetitions; ++i)
        fn(i);
}

// Case 3: use the abbreviated function template syntax (C++20),推荐使用
void repeat3(int repetitions, const auto& fn)
{
    for (int i{ 0 }; i < repetitions; ++i)
        fn(i);
}

// Case 4: use function pointer (only for lambda with no captures)
void repeat4(int repetitions, void (*fn)(int))
{
    for (int i{ 0 }; i < repetitions; ++i)
        fn(i);
}

int main()
{
    auto lambda = [](int i)
    {
        std::cout << i << '\n';
    };

    repeat1(3, lambda);
    repeat2(3, lambda);
    repeat3(3, lambda);
    repeat4(3, lambda);

    return 0;
}

从 C++17 开始,如果结果满足常量表达式的要求,则 lambda 隐式为 constexpr。这通常需要两件事:

  • lambda 必须没有捕获(就是方括号里的内容),或者所有捕获都必须是 constexpr。
  • lambda 调用的函数必须是 constexpr。请注意,许多标准库算法和数学函数直到 C++20 或 C++23 才被制作为 constexpr。

捕获子句

正常情况下 lambda 表达式不能访问函数体周围的变量,不过通过捕获子句显式指定就能访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <algorithm>
#include <array>
#include <iostream>
#include <string_view>
#include <string>

int main()
{
  std::array<std::string_view, 4> arr{ "apple", "banana", "walnut", "lemon" };

  std::cout << "search for: ";

  std::string search{};
  std::cin >> search;

  // Capture @search                                vvvvvv
  auto found{ std::find_if(arr.begin(), arr.end(), [search](std::string_view str) {
    return str.find(search) != std::string_view::npos;
  }) };

  if (found == arr.end())
  {
    std::cout << "Not found\n";
  }
  else
  {
    std::cout << "Found " << *found << '\n';
  }

  return 0;
}

默认访问形式是拷贝,如果想在 lambda 表达式内对捕获内容产生影响,就需要用引用的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

int main()
{
  int ammo{ 10 };

  auto shoot{
    // We don't need mutable anymore
    [&ammo]() { // &ammo means ammo is captured by reference
      // Changes to ammo will affect main's ammo
      --ammo;

      std::cout << "Pew! " << ammo << " shot(s) left.\n";
    }
  };

  shoot();

  std::cout << ammo << " shot(s) left\n";

  return 0;
}

C++ 也提供了简单的方式捕获所有变量:

1
2
3
4
5
6
// 要按值捕获所有使用的变量,请使用捕获值 =
[=](){};
// 要通过引用捕获所有使用的变量,请使用捕获值 &
[&](){};
// 多种方式组合
[=, &enemies](){};

Lambda 表达式的捕获子句并不能自动延长被引用变量的生命周期,所以要注意悬空引用问题。

立即 lambda 函数

在 lambda 表达式后增加一个 () 表示定义时立即执行,而不是保留下来等下一次使用:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>

int main() {
    auto result = []() -> int {
        // 这里可以执行一些计算
        return 5 + 3;
    }(); // 注意这里的(),这会立即调用这个lambda表达式

    std::cout << "The result is " << result << std::endl;
    // 输出将会是:The result is 8
    return 0;
}

上例中 result 可以直接计算出来,而不需要保存这个 lambda 表达式,所以可以加上 () 让其变为立即表达式。而作为 stl 库的比较函数时就不能使用立即表达式,因为该函数需要在对比时多次调用。

另一种用法是将 lambda 表达式作为构造函数的初始化列表的参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class B {
public:
    B(int i){}
}

class A {
public:
    B b;
    // 捕获构造函数的参数i,返回B对象
    A(int i):b{[i]() -> B {
        switch (i)
        {
        case 1:
            return B(1);
        case 2:
            return B(2)
        default:
            break;
        }
    }()}{};
}

我们可以认为这个初始化列表操作其实并没有任何拷贝操作,也就是没有消耗。

运算符重载(Operator Overloading)

重载括号运算符

括号运算符 (operator()) 的重载相较于其他的重载运算符来说更加灵活,因为它允许您改变它所采用的参数的类型和数量,而类似 ==,!等运算符的参数类型和数量都是有要求且固定的。

括号运算符必须作为成员函数实现

假设一个表示 4x4 矩阵的类:

1
2
3
4
5
class Matrix
{
private:
    double data[4][4]{};
};

我们可以重载 operator[] 以提供对私有一维数组的直接访问。然而,在这种情况下,我们想要访问私有二维数组。因为 operator[] 仅限于单个参数,所以让我们索引二维数组是不够的。由于 operator() 可以接受我们想要的任意多个参数,因此我们可以声明一个接受两个整数索引参数的 operator() 版本,并使用它来访问我们的二维数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <cassert> // for assert()

class Matrix
{
private:
    double m_data[4][4]{};

public:
    double& operator()(int row, int col);
    double operator()(int row, int col) const; // for const objects
};

double& Matrix::operator()(int row, int col)
{
    assert(row >= 0 && row < 4);
    assert(col >= 0 && col < 4);

    return m_data[row][col];
}

double Matrix::operator()(int row, int col) const
{
    assert(row >= 0 && row < 4);
    assert(col >= 0 && col < 4);

    return m_data[row][col];
}

#include <iostream>

int main()
{
    Matrix matrix;
    matrix(1, 2) = 4.5;
    std::cout << matrix(1, 2) << '\n';

    return 0;
}

仿函数(functor)

Operator() 通常也被重载以实现仿函数(或函数对象(function object)),就是让一个类能像函数一样操作。仿函数相对于普通函数的优点是仿函数可以将数据存储在成员变量中(因为它们是类)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>

class Accumulator
{
private:
    int m_counter{ 0 }; // 仿函数可以保存状态,这里是一个累加器

public:
    int operator() (int i) { return (m_counter += i); }

    void reset() { m_counter = 0; } // optional,还可以定义一个reset清空状态
};

int main()
{
    Accumulator acc{};
    std::cout << acc(1) << '\n'; // prints 1
    std::cout << acc(3) << '\n'; // prints 4

    Accumulator acc2{};
    std::cout << acc2(10) << '\n'; // prints 10
    std::cout << acc2(20) << '\n'; // prints 30

    return 0;
}

移动语义和智能指针

1
2
3
4
5
6
7
8
9
void someFunction(int x)
{
    Resource* ptr = new Resource(); // Resource is a struct or class

    if (x == 0)
        return; // the function returns early, and ptr won’t be deleted!

    delete ptr;
}

C++ 没有 GC(统计对象的引用计数并自动清理对象) 能力,所以当我们 new 一个对象时必须要在对其取消引用前进行 delete 操作回收内存,否则会导致内存泄漏。

在栈上分配的对象会在函数退出时自动调用其析构函数,利用这个特性,我们可以将指针封装为一个对象,该对象的析构函数就是做 delete 该指针管理的对象的操作,这就是智能指针的概念:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template <typename T>
class Auto_ptr1
{
	T* m_ptr {};
public:
	// Pass in a pointer to "own" via the constructor
	Auto_ptr1(T* ptr=nullptr)
		:m_ptr(ptr)
	{
	}

	// The destructor will make sure it gets deallocated
	~Auto_ptr1()
	{
		delete m_ptr;
	}

	// Overload dereference and operator-> so we can use Auto_ptr1 like m_ptr.
	T& operator*() const { return *m_ptr; }
	T* operator->() const { return m_ptr; }
};

可以注意到这个实现的一个缺陷,就是该指针必须是所指向的对象的唯一管理者,如果对这个智能指针对象进行浅拷贝,相当于多了一个管理者,此时其中任意一个的析构函数执行后,指向的对象被删除,另一个再执行析构函数准备删除指向的对象时就会执行一个删除已被删除的对象的未定义操作。

解决方案:

  • 不允许对智能指针对象进行浅拷贝:std::unique_ptr
  • 指向同一个被管理对象的智能指针对象之间共享状态(引用计数),直到引用计数清零时删除被管理对象:std::shared_ptr
  • 拷贝时执行深拷贝
  • 拷贝构造函数不执行拷贝而是转移所有权,转移所有权后原指针直接失效,新指针成为唯一管理者。 但是将拷贝构造函数定义为转移所有权操作不是一件好事情,比如在作为函数参数传递时就会无意识的进行了所有权转移(因为按值传递会进行拷贝构造),此时实参(智能指针对象)就变成了空指针,无法继续使用,而且当调用的这个函数退出后,这个智能指针对象管理的对象就会被清理。

移动语义意味着类将转移对象的所有权而不是制作副本。这个过程没有消耗巨大的深拷贝。

右值引用(R-value references)

C++11 添加了一种新的引用类型,称为右值引用:

1
2
3
int x{ 5 };
int& lref{ x }; // l-value reference initialized with l-value x
int&& rref{ 5 }; // r-value reference initialized with r-value 5

右值引用不能用左值初始化,只能用右值初始化,且有两个重要特性:

  • 非常量右值引用允许修改右值
  • 右值引用将初始化它们的对象的生命周期延长到右值引用的生命周期
1
2
3
4
5
6
7
8
9
10
#include <iostream>

int main()
{
    int&& rref{ 5 }; // because we're initializing an r-value reference with a literal, a temporary with value 5 is created here
    rref = 10; // 允许修改右值
    std::cout << rref << '\n';

    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
25
26
27
28
29
30
31
#include <iostream>

class Fraction
{
private:
	int m_numerator { 0 };
	int m_denominator { 1 };

public:
	Fraction(int numerator = 0, int denominator = 1) :
		m_numerator{ numerator }, m_denominator{ denominator }
	{
	}

	friend std::ostream& operator<<(std::ostream& out, const Fraction& f1)
	{
		out << f1.m_numerator << '/' << f1.m_denominator;
		return out;
	}
};

int main()
{
    // 将临时对象的生命周期延长到rref的生命周期
	auto&& rref{ Fraction{ 3, 5 } }; // r-value reference to temporary Fraction

	// f1 of operator<< binds to the temporary, no copies are created.
	std::cout << rref << '\n';

	return 0;
} // rref (and the temporary Fraction) goes out of scope here

知道对象的值类型(左值还是右值)能够带来优化:

如果我们构造一个对象或在参数是左值的情况下进行赋值,那么我们唯一可以合理做的就是复制左值。我们不能假设更改左值是安全的,因为它可能会在程序稍后再次使用。如果我们有一个表达式“a = b”(其中 b 是左值),我们不会合理地期望 b 以任何方式改变。

但是,如果我们构造一个对象或在参数是右值的情况下进行赋值,那么我们就知道右值只是某种临时对象。我们可以简单地将其资源(很便宜)转移到我们正在构造或分配的对象(移动语义),而不是复制它(这可能很昂贵)。这样做是安全的,因为临时变量无论如何都会在表达式末尾被销毁,所以我们知道它永远不会再被使用!

用法 1:作为函数形参

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

void fun(const int& lref) // l-value arguments will select this function
{
	std::cout << "l-value reference to const: " << lref << '\n';
}

// 为左值或右值实参分别定义函数行为
void fun(int&& rref) // r-value arguments will select this function
{
	std::cout << "r-value reference: " << rref << '\n';
}

int main()
{
	int x{ 5 };
	fun(x); // l-value argument calls l-value version of function
	fun(5); // r-value argument calls r-value version of function

	return 0;
}

以下面代码为例,右值引用 ref 属于左值,它的类型是 int&&对象的类型(int&&int&)和它的值类别(左值或右值)是独立的:

1
2
int&& ref{ 5 };
fun(ref); // 该函数的定义会是上例中的 void fun(const int& lref)

std::move

C++11 提供了 std::move 语法将左值转换为右值,通过移动语义可以避免部分复制行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include <string>
#include <utility> // for std::move

template<class T>
void myswapMove(T& a, T& b)
{
    // 整个过程并没有任何拷贝,用到了移动语义
	T tmp { std::move(a) }; // 将a转为右值,让tmp窃取其内容
	a = std::move(b); // 让a窃取b内容
	b = std::move(tmp); // 让b窃取tmp内容
}

int main()
{
	std::string x{ "abc" };
	std::string y{ "de" };

	std::cout << "x: " << x << '\n';
	std::cout << "y: " << y << '\n';

	myswapMove(x, y);

	std::cout << "x: " << x << '\n';
	std::cout << "y: " << y << '\n';

	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
25
26
27
28
#include <iostream>
#include <string>
#include <utility> // for std::move
#include <vector>

int main()
{
	std::vector<std::string> v;

	// We use std::string because it is movable (std::string_view is not)
	std::string str { "Knock" };

	std::cout << "Copying str\n";
	v.push_back(str); // calls l-value version of push_back, which copies str into the array element

	std::cout << "str: " << str << '\n';
	std::cout << "vector: " << v[0] << '\n';

	std::cout << "\nMoving str\n";

    // 因为push_back的是值而不是指针,所以使用移动语义让vector窃取对象避免复制。
	v.push_back(std::move(str)); // calls r-value version of push_back, which moves str into the array element

	std::cout << "str: " << str << '\n'; // The result of this is indeterminate
	std::cout << "vector:" << v[0] << ' ' << v[1] << '\n';

	return 0;
}

(待求证)需要注意的是,对于基本类型(如 int),std::move 几乎没有作用,实际执行的动作依旧是值拷贝。仅当使用复杂类型(类似 std::string、std::vector 之类的对象或容器,其部分内存分配在堆空间上),std::move 仅拷贝必要信息(如指向堆的指针),避免了堆的拷贝。

std::move 还有什么好处?

可以想到在排序算法中,使用移动语义避免处于不同位置的对象的交换时的性能消耗。

std::unique_ptr

C++11 标准库附带 4 个智能指针类:std::auto_ptr(在 C++17 中删除)、std::unique_ptrstd::shared_ptrstd::weak_ptr

std::unique_ptr 仅实现了移动语义,所以复制初始化(copy initialization)和拷贝赋值(Copy Assignment)被禁用。当 std::unique_ptr 分配在栈上,当它超出作用域时,它将删除它正在管理的资源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>
#include <memory> // for std::unique_ptr
#include <utility> // for std::move

class Resource
{
public:
	Resource() { std::cout << "Resource acquired\n"; }
	~Resource() { std::cout << "Resource destroyed\n"; }
};

int main()
{
	std::unique_ptr<Resource> res1{ new Resource{} }; // Resource created here
	std::unique_ptr<Resource> res2{}; // Start as nullptr

	std::cout << "res1 is " << (res1 ? "not null\n" : "null\n");
	std::cout << "res2 is " << (res2 ? "not null\n" : "null\n");

	// res2 = res1; // Won't compile: copy assignment is disabled
	res2 = std::move(res1); // res2 assumes ownership, res1 is set to null

	std::cout << "Ownership transferred\n";

	std::cout << "res1 is " << (res1 ? "not null\n" : "null\n");
	std::cout << "res2 is " << (res2 ? "not null\n" : "null\n");

	return 0;
} // Resource destroyed here when res2 goes out of scope

std::unique_ptr 有一个重载的 operator*operator->,可用于返回正在管理的资源。 operator* 返回对托管资源的引用operator-> 返回指针

std::unique_ptr 可以强制转换为 bool,如果 std::unique_ptr 正在管理资源,则返回 true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
#include <memory> // for std::unique_ptr

class Resource
{
public:
	Resource() { std::cout << "Resource acquired\n"; }
	~Resource() { std::cout << "Resource destroyed\n"; }
	friend std::ostream& operator<<(std::ostream& out, const Resource &res)
	{
		out << "I am a resource";
		return out;
	}
};

int main()
{
	std::unique_ptr<Resource> res{ new Resource{} };

	if (res) // use implicit cast to bool to ensure res contains a Resource
		std::cout << *res << '\n'; // print the Resource that res is owning

	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
25
26
27
28
29
30
31
32
33
34
35
36
#include <memory> // for std::unique_ptr and std::make_unique
#include <iostream>

class Fraction
{
private:
	int m_numerator{ 0 };
	int m_denominator{ 1 };

public:
	Fraction(int numerator = 0, int denominator = 1) :
		m_numerator{ numerator }, m_denominator{ denominator }
	{
	}

	friend std::ostream& operator<<(std::ostream& out, const Fraction &f1)
	{
		out << f1.m_numerator << '/' << f1.m_denominator;
		return out;
	}
};


int main()
{
	// Create a single dynamically allocated Fraction with numerator 3 and denominator 5
	// We can also use automatic type deduction to good effect here
	auto f1{ std::make_unique<Fraction>(3, 5) };
	std::cout << *f1 << '\n';

	// Create a dynamically allocated array of Fractions of length 4
	auto f2{ std::make_unique<Fraction[]>(4) };
	std::cout << f2[0] << '\n';

	return 0;
}

最佳实践:使用 std::make_unique() 生成 std::unique_ptr 而不是自己创建 std::unique_ptr 并自己使用 new

注意:不要通过指针或引用返回 std::unique_ptr,也不要将其作为参数传递给其他函数 (除非你有特定的令人信服的理由),因为 std::unique_ptr 通常用来实现 RAII ,所以不应该试图改变其生命周期,让它在当前函数返回时自动销毁其管理的对象才是正确用法。

std::shared_ptr

与旨在单独拥有和管理资源的 std::unique_ptr 不同,std::shared_ptr 旨在解决需要多个智能指针共同拥有资源的情况。

这意味着可以有多个 std::shared_ptr 指向同一资源。在内部,std::shared_ptr 跟踪有多少个 std::shared_ptr 正在共享资源。只要至少有一个 std::shared_ptr 指向该资源,即使单个 std::shared_ptr 被销毁,该资源也不会被释放。一旦管理资源的最后一个 std::shared_ptr 超出作用域(或被重新分配以指向其他内容),资源将被释放。

建议使用 std::make_shared 创建 std::shared_ptr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
#include <memory> // for std::shared_ptr

class Resource
{
public:
	Resource() { std::cout << "Resource acquired\n"; }
	~Resource() { std::cout << "Resource destroyed\n"; }
};

int main()
{
	// allocate a Resource object and have it owned by std::shared_ptr
	auto ptr1 { std::make_shared<Resource>() };
	{
        // 初始化时必须依赖第一个 std::shared_ptr
		auto ptr2 { ptr1 }; // create ptr2 using copy of ptr1

		std::cout << "Killing one shared pointer\n";
	} // ptr2 goes out of scope here, but nothing happens

	std::cout << "Killing another shared pointer\n";

	return 0;
} // ptr1 goes out of scope here, and the allocated Resource is destroyed

注意第二个 std::shared_ptr 初始化时应该依赖于第一个 std::shared_ptr,而不是依赖原对象,否则它们无法感知彼此。

对象关系

std::initializer_list

我们一般会通过初始化列表(initializer list)的方式初始化一个 std::array 数组:

1
2
3
4
5
6
7
8
9
10
#include <iostream>

int main()
{
	int array[] { 5, 4, 3, 2, 1 }; // initializer list初始化列表
	for (auto i : array)
		std::cout << i << ' ';

	return 0;
}

实际上,当编译器看到初始化列表时,它会自动将其转换为 std::initializer_list 类型的对象。

一个构造函数实例:

1
2
3
4
5
6
7
8
9
IntArray(std::initializer_list<int> list) // allow IntArray to be initialized via list initialization
	: IntArray(static_cast<int>(list.size())) // use delegating constructor to set up initial array
{
	// Now initialize our array from the list
	for (std::size_t count{}; count < list.size(); ++count)
	{
		m_data[count] = list.begin()[count];
	}
}

注意 std::initializer_list 应该通过 <> 明确指示列表内元素的类型,而且其没有通过下标 [] 获取元素的方式,要先使用 .begin() 获取一个随机访问迭代器。

与匹配非列表构造函数相比,列表初始化更倾向于匹配列表构造函数。所以向一个已有的类添加一个使用 std::initializer_list 类型作为参数的构造函数是很危险的行为,因为这将导致现有的对象初始化时调用的构造函数行为因为优先级发生变化,将会变为配置列表构造函数而不是原来的非列表构造函数:

1
IntArray a2{ 5 }; // uses IntArray<std::initializer_list<int>, allocates array of size 1。而不是IntArray<int>

使用直接初始化时不受该优先级影响,因为直接调用了对应构造函数,不会发生类型的隐式转换:

1
IntArray a1(5);   // uses IntArray(int), allocates an array of size 5

继承 Inheritance

继承说明符

base classpublic 继承private 继承protected 继承
Public 成员PublicPrivateProtected
Protected 成员ProtectedPrivateProtected
Private 成员InaccessibleInaccessibleInaccessible

删除派生类中的函数

可以在派生类中将成员函数标记为已删除(delete 关键字),这确保它们根本无法通过派生对象调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>
class Base
{
private:
	int m_value {};

public:
	Base(int value)
		: m_value { value }
	{
	}

	int getValue() const { return m_value; }
};

class Derived : public Base
{
public:
	Derived(int value)
		: Base { value }
	{
	}


	int getValue() const = delete; // mark this function as inaccessible
};

int main()
{
	Derived derived { 7 };

	// The following won't work because getValue() has been deleted!
	std::cout << derived.getValue();

	return 0;
}

隐藏继承的函数

默认情况下,派生类继承基类中定义的所有行为。

当在派生类对象上调用成员函数时,编译器首先查看派生类中是否存在具有该名称的任何函数。如果是,则考虑具有该名称的所有重载函数,并使用函数重载解析过程来确定是否存在最佳匹配(只要派生类有同名的函数,将不再查找父类的同名函数!即使无法找到最佳匹配)。如果没有,编译器将沿着继承链向上走,以相同的方式依次检查每个父类。

派生类中的重载解析

编译器将从至少有一个具有该名称的函数的最派生类中选择最佳匹配函数。也就是说只要派生类有同名的函数,就会隐藏所有基类的同名函数,无论参数类型和数量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

class Base
{
public:
    void print(int)    { std::cout << "Base::print(int)\n"; }
    void print(double) { std::cout << "Base::print(double)\n"; }
};

class Derived: public Base
{
public:
};


int main()
{
    Derived d{};
    d.print(5); // calls Base::print(int)

    return 0;
}

上例中,对于调用 d.print(5) ,编译器在 Derived 中找不到名为 print()的函数,因此它检查 Base ,在其中找到两个具有该名称的函数。它使用函数重载解析过程来确定 Base::print(int)是比 Base::print(double)更好的匹配。因此,正如我们所期望的, Base::print(int)被调用。

现在让我们看一个行为与我们预期不同的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>

class Base
{
public:
    void print()       { std::cout << "Base::print()\n"; }
    void print(int)    { std::cout << "Base::print(int)\n"; }
    void print(double) { std::cout << "Base::print(double)\n"; }
};

class Derived: public Base
{
public:
    void print(double) { std::cout << "Derived::print(double)"; } // this function added
};

int main()
{
    Derived d{};
    d.print(5); // calls Derived::print(double), not Base::print(int)
    d.print(); // error: no matching function for call to ‘Derived::print()’

    return 0;
}

对于调用 d.print(5) ,编译器在 Derived 中找到一个名为 print()的函数,因此在尝试确定要解析为哪个函数时,它只会考虑 Derived 中的函数。这个函数也是 Derived 中对于这个函数调用最匹配的函数。因此,这会调用 Derived::print(double) 。所以 Base 中的更适合的 print(int) 函数甚至不被考虑。

对于调用 d.print(),甚至根本不会去找 Base 中的无参数版本的 print(),导致错误。

解决办法是在 Derived 中使用 using 声明,使具有特定名称的所有 Base 函数在 Derived 中可见:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>

class Base
{
public:
    void print()       { std::cout << "Base::print()\n"; }
    void print(int)    { std::cout << "Base::print(int)\n"; }
    void print(double) { std::cout << "Base::print(double)\n"; }
};

class Derived: public Base
{
public:
    using Base::print; // make all Base::print() functions eligible for overload resolution
    void print(double) { std::cout << "Derived::print(double)"; }
};

int main()
{
    Derived d{};
    d.print(5); // calls Base::print(int), which is the best matching function visible in Derived
    d.print(); // calls Base::print()

    return 0;
}

虚函数

虚函数是一种特殊类型的成员函数,在调用时,它会解析为所引用或指向的对象的实际类型的函数的最派生版本。

如果函数被标记为 virtual,则派生类中的所有匹配重写隐式被视为 virtual,即使它们没有显式标记为 virtual(包括析构函数)。

  • 虚函数的返回类型和它的重写必须匹配:

    如果派生函数具有与函数的基本版本相同的签名(名称参数类型以及是否为 const)和返回类型,则该派生函数被视为匹配。此类函数称为重载(override)。

    可以通过显式添加 override 关键字来强制启用匹配,此时当匹配不成功,编译器将会报错:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    
    #include <string_view>
    
    class A
    {
    public:
        virtual std::string_view getName1(int x) { return "A"; }
        virtual std::string_view getName2(int x) { return "A"; }
        virtual std::string_view getName3(int x) { return "A"; }
    };
    
    class B : public A
    {
    public:
        std::string_view getName1(short int x) override { return "B"; } // compile error, function is not an override
        std::string_view getName2(int x) const override { return "B"; } // compile error, function is not an override
        std::string_view getName3(int x) override { return "B"; } // okay, function is an override of A::getName3(int)
    
    };
    
    int main()
    {
        return 0;
    }
    

    final 说明符可用于告诉编译器不希望某人能够重载该虚拟函数或从类继承:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    
    #include <string_view>
    
    class A
    {
    public:
        virtual std::string_view getName() const { return "A"; }
    };
    
    class B : public A
    {
    public:
        // note use of final specifier on following line -- that makes this function not able to be overridden in derived classes
        std::string_view getName() const override final { return "B"; } // okay, overrides A::getName()
    };
    
    class C : public B
    {
    public:
        std::string_view getName() const override { return "C"; } // compile error: overrides B::getName(), which is final
    };
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    
    #include <string_view>
    
    class A
    {
    public:
        virtual std::string_view getName() const { return "A"; }
    };
    
    class B final : public A // note use of final specifier here
    {
    public:
        std::string_view getName() const override { return "B"; }
    };
    
    class C : public B // compile error: cannot inherit from final class
    {
    public:
        std::string_view getName() const override { return "C"; }
    };
    
  • 切勿从构造函数或析构函数中调用虚函数:

    创建派生类时,首先构造基类部分。如果你要从 Base 构造函数调用虚拟函数,并且该类的 Derived 部分尚未创建,则它将无法调用该函数的 Derived 版本,因为没有 Derived 对象可供 Derived 函数工作在。在 C++ 中,它将调用 Base 版本。

  • 虚函数性能较低,需要额外的内存实现虚表。

virtual 析构函数

如果基类析构函数不是 virtual,则无法实现析构时的多态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>
class Base
{
public:
    ~Base() // note: not virtual
    {
        std::cout << "Calling ~Base()\n";
    }
};

class Derived: public Base
{
private:
    int* m_array {};

public:
    Derived(int length)
      : m_array{ new int[length] }
    {
    }

    ~Derived() // note: not virtual (your compiler may warn you about this)
    {
        std::cout << "Calling ~Derived()\n";
        delete[] m_array;
    }
};

int main()
{
    Derived* derived { new Derived(5) };
    Base* base { derived };

    delete base; // 仅调用Base的析构函数

    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
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>
class Base
{
public:
    virtual ~Base() // note: virtual
    {
        std::cout << "Calling ~Base()\n";
    }
};

class Derived: public Base
{
private:
    int* m_array {};

public:
    Derived(int length)
      : m_array{ new int[length] }
    {
    }

    virtual ~Derived() // note: virtual
    {
        std::cout << "Calling ~Derived()\n";
        delete[] m_array;
    }
};

int main()
{
    Derived* derived { new Derived(5) };
    Base* base { derived };

    delete base; // 先调用Derived析构函数,在调用Base析构函数

    return 0;
}

请注意,如果你希望基类具有一个空的 virtual 析构函数,你可以这样定义析构函数:

1
virtual ~Base() = default; // generate a virtual default destructor
  • 如果你打算继承你的类,请确保你的析构函数是 virtual 的。
  • 如果你不希望继承你的类,请将你的类标记为 final。这将首先防止其他类继承它,而不会对类本身施加任何其他使用限制。

后绑定和虚表

后绑定

在运行时才决定要调用的函数,一般通过函数指针实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>

int add(int x, int y)
{
    return x + y;
}

int main()
{
    // Create a function pointer and make it point to the add function
    int (*pFcn)(int, int) { add };
    std::cout << pFcn(5, 3) << '\n'; // add 5 + 3

    return 0;
}

虚表

C++ 使用虚表实现多态。

假设如下类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Base
{
public:
    virtual void function1() {};
    virtual void function2() {};
};

class D1: public Base
{
public:
    void function1() override {};
};

class D2: public Base
{
public:
    void function2() override {};
};

编译器在实现多态时会为基类添加一个虚表指针成员(__vptr):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Base
{
public:
    VirtualTable* __vptr;
    virtual void function1() {};
    virtual void function2() {};
};

class D1: public Base
{
public:
    void function1() override {};
};

class D2: public Base
{
public:
    void function2() override {};
};

Base 的子类都继承了 __vptr 成员,每个类都有一个专属的虚表,由 __vptr 指向。创建一个类对应的对象时就会将该对象的 __vptr 指向该类对应的虚表,虚表内存放着该类的虚函数。当使用 Base 指针指向该对象时,__vptr 的值不变,依然指向原类的虚表,当调用函数时就会查找该虚表来调用该类对应的虚函数,实现多态。

所以每个基于该基类的对象都会多出一个指针的内存的损耗。

对象切片(Object slicing)

将子类对象直接赋值给基类对象时就会发生切片,此时所有派生内容将会丢失,包括虚表指针,也就是说该基类对象无法调用子类的虚函数,也就无法实现多态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <iostream>
#include <string_view>

class Base {
protected:
  int m_value{};

public:
  Base(int value) : m_value{value} {}

  virtual ~Base() = default;

  virtual std::string_view getName() const { return "Base"; }
  int getValue() const { return m_value; }
};

class Derived : public Base {
public:
  Derived(int value) : Base{value} {}

  std::string_view getName() const override { return "Derived"; }
};

int main() {
  Derived derived{5};
  std::cout << "derived is a " << derived.getName() << " and has value "
            << derived.getValue() << '\n';

  Base &ref{derived};
  std::cout << "ref is a " << ref.getName() << " and has value "
            << ref.getValue() << '\n';

  Base *ptr{&derived};
  std::cout << "ptr is a " << ptr->getName() << " and has value "
            << ptr->getValue() << '\n';

  const Base base{derived}; // 发生对象切片,不能用此方式实现多态
  // 由于虚表指针不继承,所以 base.getName() 将调用Base::getName(),而不是Derived::getName()
  std::cout << "base is a " << base.getName() << " and has value "
            << base.getValue() << '\n';

    // output:
    // derived is a Derived and has value 5
    // ref is a Derived and has value 5
    // ptr is a Derived and has value 5
    // base is a Base and has value 5

  return 0;
}

一般人在实现多态时都会发现这个问题,并避免对象切片,但这个问题更常发生在函数形参:

1
2
3
4
void printName(const Base base) // note: base passed by value, not reference
{
    std::cout << "I am a " << base.getName() << '\n';
}

编程时应该尽量避免切片,确保您的函数参数是引用(或指针),并在涉及派生类时尽量避免任何类型的值传递。

动态转换(Dynamic casting)

C++ 提供了一个名为 dynamic_cast 的转换运算符,尽管动态转换具有一些不同的功能,但到目前为止,动态转换最常见的用途是将基类指针转换为派生类指针:

1
2
3
4
5
6
7
8
9
10
11
12
int main()
{
	Base* b{ getObject(true) }; // 这里getObject(true)返回一个Derived对象

	Derived* d{ dynamic_cast<Derived*>(b) }; // use dynamic cast to convert Base pointer into Derived pointer

	std::cout << "The name of the Derived is: " << d->getName() << '\n';

	delete b;

	return 0;
}

由于基类指针的信息有限无法通过它调用子类的部分独有函数,所以可以通过将其转为指向子类的指针来实现该功能。

转换可以通过静态转换(static_cast)和动态转换(dynamic_cast)实现,其中静态转换不会在运行时检查转换安全性,当进行非法转换(转换成非子类的指针)时,就会出现未定义错误。而动态转换会进行安全检查,当进行非法转换时指针将返回空,可以通过判空确定是否出现异常,防止未定义行为导致的崩溃:

1
2
3
4
5
6
7
8
9
10
11
12
13
int main()
{
	Base* b{ getObject(true) };

	Derived* d{ dynamic_cast<Derived*>(b) }; // use dynamic cast to convert Base pointer into Derived pointer

	if (d) // make sure d is non-null
		std::cout << "The name of the Derived is: " << d->getName() << '\n';

	delete b;

	return 0;
}

参考

本文由作者按照 CC BY 4.0 进行授权

© Kai. 保留部分权利。

浙ICP备20006745号-2,本站由 Jekyll 生成,采用 Chirpy 主题。