CppGuide社区 CppGuide社区
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥交易系统开发岗位求职与面试指南统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go系统接口编程
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥交易系统开发岗位求职与面试指南统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go系统接口编程
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
  • C++语言面试问题集锦 目录与说明
  • 第一章 auto与类型推导
  • 第二章 关键字static及其不同用法
  • 第三章 多态、继承和虚函数
  • 第四章 Lambda函数
  • 第五章 C++中如何使用 const 限定符
  • 第六章 Modern C++的一些最佳实践
    • 问题42:什么是聚合初始化?
      • 不易出现最令人头疼的解析问题
      • 不允许窄化转换
    • 问题43:什么是显式构造函数(explicit constructors),它们有什么优点?
    • 问题44:什么是用户定义字面量(user-defined literals)?
    • 问题45:为什么我们应该使用 nullptr 而不是 NULL 或 0?
    • 问题46:类型别名(alias)相较于类型定义(typedef)有哪些优势?
    • 问题47:作用域枚举(scoped enums)相较于无作用域枚举(unscoped enums)有哪些优势?
    • 问题48:应该显式删除未使用/不支持的特殊函数,还是将它们声明为私有成员?
    • 问题49:如何在C++中使用= delete说明符?
    • 问题50:C++中的平凡类(trivial class)是什么?
  • 第七章 智能指针
  • 第八章 引用、万能引用等
  • 第九章 C++20相关问题
  • 第十章 特殊函数及数量规则
  • 第十一章 C++面向对象设计
  • 第十二章 程序质量
  • 第十三章 标准模板库
  • 第十四章 杂项
  • cppinterviewmostaskedquestions
zhangxf
2025-03-27
目录

第六章 Modern C++的一些最佳实践

# 第六章 Modern C++的一些最佳实践

现在,让我们转换话题,讨论现代C++为我们带来的一些各式各样的实践方法。

# 问题42:什么是聚合初始化?

聚合初始化(aggregate initialization)或大括号初始化(brace-initialization,有时也写作{}初始化)是在C++11中加入到该语言的特性。

那么,什么是聚合体(aggregate)呢?

它可以是数组类型,或者是满足某些限制条件的类类型,你可以在此处 (opens new window)查看相关内容 。

其最简单的形式,看起来和使用括号的普通构造函数调用完全一样,但这次使用的是大括号。

std::string text{"abcefg"};
Point a{5,4,3}; // Point是一个接受3个整数作为参数的类
1
2

你也可以在auto类型推导中使用它:

auto text = std::string{"abcefg"};
auto a = Point{5,4,3}; // Point是一个接受3个整数作为参数的类
1
2

聚合初始化也可用于初始化容器:

std::vector<int> numbers{1, 2, 3, 4, 5};
auto otherNumbers = std::vector<int>{6, 7, 8, 9, 10};
1
2

这比之前的初始化方式要好得多。在以前,创建容器后,我们必须逐个添加元素。使用聚合初始化,如果我们知道容器不再需要更改,就可以轻松创建常量容器(const containers)。这在C++11之前是无法实现的。

// C++11之前
std::vector<int> myNums;
myNums.push_back(3);
myNums.push_back(42);
myNums.push_back(51);

// 从C++11开始
const std::vector<int> myConstNums{3, 42, 51};
1
2
3
4
5
6
7
8

但这还不是全部。{}初始化有两个优点。

# 不易出现最令人头疼的解析问题

根据C++标准,任何能被解释为声明的内容,都会被解释为声明。

因此,MyClass o();并不是创建一个名为o的MyClass实例并调用其默认构造函数的指令。相反,它是一个名为o的函数声明,该函数不接受参数,但返回MyClass类型。

如果使用大括号,在99%的情况下,它不会被解释为声明,而是像开发者预期的那样,被解释为变量声明。

# 不允许窄化转换

窄化(narrowing),更准确地说是窄化转换(narrowing conversion),是一种算术值的隐式转换,会导致精度损失。根据具体情况,这可能极其危险。

以下示例展示了经典数值类型初始化方式存在的问题。无论是使用直接初始化还是赋值操作,都会有这个问题。

#include <iostream>
int main() {
    int i1(3.14);
    int i2=3.14;
    std::cout << "i1: " << i1 << std::endl; 
    std::cout << "i2: " << i2 << std::endl; 
    int i3{3.14} 
    unsigned int i4{-41}; 
}
1
2
3
4
5
6
7
8
9

正如我们所见,使用大括号初始化时,编译器会给出一个明确的错误,告知我们发生了窄化转换。

所以,{}初始化的三个优点是:

  • 直接初始化容器
  • 不再出现最令人头疼的解析问题
  • 检测隐式窄化转换

# 问题43:什么是显式构造函数(explicit constructors),它们有什么优点?

explicit说明符指定构造函数不能用于隐式转换。

如果不使用explicit,编译器可以进行一次隐式转换,以便将参数解析为函数所需的类型。编译器可以使用接受单个参数的构造函数,将一种类型转换为另一种类型,从而获得函数参数所需的正确类型。

来看这个例子:

class WrappedInt {
public:
    // 单参数构造函数,可以用作隐式转换
    WrappedInt(int number) : m_number (number) {}

    // 使用explicit,不允许隐式转换
    // explicit WrappedInt(int number)  : m_number  (number) {}

    int GetNumber() { return m_number; }
private:
    int m_number;
};

void doSomething(WrappedInt aWrappedInt) {
    int i = aWrappedInt.GetNumber();
}

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

参数并非WrappedInt对象,只是一个普通的int类型。然而,WrappedInt存在一个接受int类型参数的构造函数,因此这个构造函数可以用于将参数转换为正确的类型。

编译器允许对每个参数进行一次这样的转换。

在构造函数前加上explicit关键字,可以防止编译器使用该构造函数进行隐式转换。在上述类中添加explicit关键字后,在调用doSomething(42)时会产生编译错误。此时,必须显式调用转换,即doSomething(WrappedInt(42))。

你可能想要这么做的原因是,避免意外构造对象,这种情况可能会隐藏程序中的错误。以字符串为例,假设有一个MySpecialString类,它有一个接受int类型参数的构造函数,该构造函数会创建一个长度为传入int值的字符串。还有一个函数print(const MySpecialString&),如果调用print(3)(而实际上你想调用的是print("3")),你期望它打印出3,但实际上它打印的是一个长度为3的空字符串。

再举一个关于字符串的例子,假设这是MySpecialString类:

class MySpecialString {
public:
    MySpecialString(int n); 		// 为MySpecialString对象分配n个字节
    MySpecialString(const char *p); // 用char *p初始化对象
};
1
2
3
4
5

对于这个构造函数调用会发生什么呢?

MySpecialString mystring = 'x';
1

字符x会被隐式转换为int,然后调用MySpecialString(int)构造函数。但这可能并非用户本意。所以,为了避免这种情况,我们应该将构造函数定义为explicit:

class MySpecialString {
public:
    explicit MySpecialString (int n); // 分配n个字节
    MySpecialString(const char *p);   // 用字符串p初始化对象
};
1
2
3
4
5

为了避免这类不易察觉的错误,一开始(差不多自动地)应该总是考虑将单参数构造函数定义为explicit,只有在设计上需要隐式转换时,才去掉explicit关键字。

# 问题44:什么是用户定义字面量(user-defined literals)?

用户定义字面量允许通过定义用户定义的后缀,让整数、浮点数、字符和字符串字面量生成用户定义类型的对象。

用户定义字面量可用于整数、浮点数、字符和字符串类型。

它们可用于转换,例如,你可以编写一个将角度转换为弧度的转换器:

constexpr long double operator"" _deg ( long double deg ) {
    return deg * 3.14159265358979323846264L / 180;
}
//...
std::cout << "50 degrees as radians: " << 50.0_deg << std::endl;
1
2
3
4
5

但更有趣的可能是,当使用强类型(strong types)时,它如何有助于提高代码的可读性和安全性。

让我们来看前面课程中的一个例子:

auto myCar{Car{Horsepower{98u}}, DoorsNumber{4u},
          Transmission::Automatic, Fuel::Gasoline};
1
2

把车门数量和发动机功率弄混的可能性或许不高,但可能存在其他衡量性能的系统,或者Car构造函数接受的其他数值,那就容易混淆了。

一种可读性强且安全的方式,是通过用户定义字面量,用硬编码值初始化Car对象(比如在单元测试中):

//...
Horsepower operator"" _hp(int performance) {
    return Horsepower{performance};
}

DoorsNumber operator"" _doors(int numberOfDoors) {
    return DoorsNumber{numberOfDoors};
}
//...
auto myCar{Car{98_hp, 4_doors,
              Transmission::Automatic, Fuel::Gasoline};
1
2
3
4
5
6
7
8
9
10
11

当然,使用用户定义字面量的方式仅受限于你的想象力,但数值转换和辅助强类型无疑是其中最有趣的两种应用。

# 问题45:为什么我们应该使用 nullptr 而不是 NULL 或 0?

字面值0是一个整数(int),不是指针。如果编译器在只能使用指针的地方看到0,它会将0解释为空指针,但这只是一种隐式转换,如果你愿意,这只是第二选择。NULL也是如此,尽管在实现中允许给NULL赋予除int之外的整数类型,比如long,不过这种情况并不常见。

这会产生以下影响。如果你有一个函数存在三个重载版本,参数包括整数类型和指针类型,可能会出现一些意外情况:

#include  <iostream> 

void  foo(int) {
    std::cout << "foo(int) is called" << std::endl;
}

void  foo(bool) {
    std::cout << "foo(bool) is called" << std::endl;
}

void  foo(void*) {
    std::cout << "foo(void*) is called" << std::endl;
}

int  main() {
    foo(0); // 调用foo(int),而不是foo(void*)
    foo(NULL); // 可能无法编译,但通常会调用foo(int),绝不会调用foo(void*)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

因此,出现了避免对指针和整数类型进行重载的指导原则。

另一方面,nullptr没有整数类型。它的类型是std::nullptr_t。这是一种独特的类型,它本身既不是指针类型,也不是指向成员类型的指针。同时,它可以隐式转换为所有原始指针类型,这使得nullptr表现得好像它是所有类型的指针。

因此,使用nullptr可以调用指针重载版本:

foo(nullptr); // 调用foo(void*)重载版本
1

要了解nullptr的更多优势,我们需要对模板元编程进行一些实验。想要了解更多详细信息,强烈建议你查看《Effective Modern C++》的第8条内容。

# 问题46:类型别名(alias)相较于类型定义(typedef)有哪些优势?

首先,让我们了解一下什么是typedef。如果你不想每次都书写复杂的类型名称,可以引入一个“快捷方式”,即typedef:

typedef     std::unique_ptr<std::unordered_map<std::string, std::string>> MyStringMap;
1

自C++11起,你可以使用别名声明(alias declarations)来替代:

using   MyStringMap   =   std::unique_ptr<std::unordered_map<std::string, std::string>>
1

它们的功能完全相同,但别名声明有一些优势。

在函数指针的情况下,别名声明的可读性更强:

typedef  void  (*MyFunctionPointer)(int , int);
using  MyFunctionPointerAlias = void(*)(int , int);
1
2

另一个优势是,typedef不支持模板化,而别名声明支持。

template<typename  T>  using  MyAllocList  =  std::list<T, MyAlloc<T>>;
1

使用typedef会复杂得多,在某些情况下(查看此处),你甚至必须使用::type后缀,并且在模板中,引用typedef时通常需要使用typename前缀。

C++14甚至为所有C++11类型特性转换提供了别名模板,比如std::remove_const_t<T>、std::remove_reference_t<T>或std::add_lvalue_reference_t<T>。

# 问题47:作用域枚举(scoped enums)相较于无作用域枚举(unscoped enums)有哪些优势?

C++98风格的枚举现在被称为无作用域枚举。你可以这样声明它们:

enum  Transmission  { manual, automatic }; 
// 编译错误,因为manual已经作为不同类型的实体被声明
auto  manual = false;
1
2
3

现代的作用域枚举使用class关键字,枚举成员(enumerators)仅在枚举内部可见。它们只有通过强制类型转换才能转换为其他类型。它们通常被称为作用域枚举或枚举类(enum classes)。

enum  class  Transmission  { manual, automatic }; 
// 现在可以编译,manual尚未被声明
auto  manual = false;

// 错误,没有名为manual的枚举成员,这里的manual只是一个bool类型变量
Transmission t = manual;

Transmission t = Transmission::manual; // 正确
auto  t = Transmission::manual; // 正确
1
2
3
4
5
6
7
8
9

作用域枚举和无作用域枚举都支持指定底层类型。作用域枚举的默认底层类型是int。无作用域枚举没有默认的底层类型。

指定底层类型的语法是相同的:

enum  Status : std::uint32_t  { /* . .*/  };
enum  class  Status : std::uint32_t  { /* . . .*/  };
1
2

无作用域枚举只有在声明中指定了底层类型时才能进行前向声明,否则枚举的大小是未知的。另一方面,由于作用域枚举默认有底层类型,所以它们总是可以进行前向声明。

# 问题48:应该显式删除未使用/不支持的特殊函数,还是将它们声明为私有成员?

首先要回答的问题是,为什么要选择这两种方式中的任何一种呢?你可能不希望某个类被复制或移动,所以希望让调用者无法访问相关的特殊函数。一种选择是将它们声明为私有(private)或受保护(protected)成员,另一种选择是显式删除它们。

class  NonCopyable  {
public:
    NonCopyable() {/* . . .*/}

    //  . . .
private:
    NonCopyable(const  NonCopyable&); 
    NonCopyable& operator=(const  NonCopyable&); 
};
1
2
3
4
5
6
7
8
9

在C++11之前,除了将不需要的特殊函数声明为私有成员且不实现它们之外,没有其他选择。这样可以禁止对象复制(当时还没有移动语义)。不实现这些函数有助于防止在成员函数、友元函数中意外使用,或者在你忽略访问说明符时的误用。在链接时你会遇到问题。

自C++11起,你可以通过将特殊函数声明为= delete;来简单地标记它们为已删除:

class  NonCopyable  {
public:
    NonCopyable() {/* . . .*/}

    NonCopyable(const  NonCopyable&) = delete ;
    NonCopyable& operator=(const  NonCopyable&) = delete ;

    //  . . .
private:
    //  . . .
};
1
2
3
4
5
6
7
8
9
10
11

C++11的方式更好,原因如下:

  • 它比将函数放在私有部分更明确,因为将函数放在私有部分可能只是个错误。
  • 如果你尝试进行复制,在编译时就会报错。

已删除的函数应该声明为公有成员,而不是私有成员。这不是编译器的强制要求,但有些编译器可能只会抱怨你调用了私有函数,而不会指出该函数已被删除。

# 问题49:如何在C++中使用= delete说明符?

这个问题也可以表述为:如何禁止函数调用时的隐式类型转换?

假设有一个接收整数的函数。这里指的是整数,比如该函数的参数表示一辆汽车能坐多少人。可能是2人,有些车比较特殊,是三座的,一些豪华车能坐4人,大多数车能坐5人。不会是4.9人,也不是5.1人,甚至不是5个半人,就是5人。我们不涉及人体部分(这种不合理的数值)。

那么如何确保函数只接收整数作为参数呢?

显然,你会使用一个整型参数。它可以是int,甚至unsigned,或者仅仅是short,有很多选择。你可能还会在文档中注明numberOfSeats参数应该是一个整数。

很好!

那么,如果客户端调用时仍然传入一个浮点数会怎样呢?

#include  <iostream> 

void  foo(int  numberOfSeats) {
    std::cout << "Number of seats: " << numberOfSeats << '\n';
    //  . . .
}

int  main() {
    foo(5.6f);
}
1
2
3
4
5
6
7
8
9
10
Number of seats:  5 
1

浮点型参数被接受并转换为整数。甚至不能说它是四舍五入,它是被隐式转换为整数的。

你可能会说,在某些情况下这没问题。但在其他情况下,这种行为是完全不可接受的。

在这种情况下,如何避免这个问题呢?显然,你可以在调用方进行处理,但:

  • 如果foo函数经常被使用,每次都进行检查会很繁琐;
  • 如果foo是外部使用的应用程序编程接口(API)的一部分,你无法控制调用方。

从C++11开始,我们可以使用delete说明符来限制某些类型的拷贝、移动,实际上甚至可以限制其多态使用。

但是= delete的用途不止于此。它可以应用于任何函数,无论是成员函数还是自由函数。

如果你不想允许从浮点数进行隐式转换,可以简单地删除接受float类型参数的foo重载版本:

#include  <iostream> 

void  foo(int  numberOfSeats) {
    std::cout << "Number of seats: " << numberOfSeats << '\n';
    //  . . .
}

void  foo(double) = delete ;

int  main() {
    foo(5.6f);
}
1
2
3
4
5
6
7
8
9
10
11
12
main.cpp:  In  function  'int main()':
main.cpp:11:6:  error: use of  deleted  function  'void  foo(d\
ouble)'
     11  |      foo(5.6f);
        |      ~~~^~~~~~
main.cpp:8:6: note:  declared here
      8  |  void  foo(double)  =  delete;
        |            ^~~
1
2
3
4
5
6
7
8

就是这样。通过删除函数的某些重载版本,你可以禁止从某些类型隐式转换为你期望的类型。现在,你完全可以控制用户可以向你的API传递什么样的参数了。

# 问题50:C++中的平凡类(trivial class)是什么?

在C++中,如果一个类或结构体(struct)只有编译器提供的或显式默认的特殊成员函数,那么它就是一个平凡类型。它占据连续的内存区域。它可以有不同访问说明符的成员。在C++中,在这种情况下编译器可以自由选择成员的排列顺序。因此,你可以使用memcpy函数处理这样的对象,但不能在C程序中可靠地使用它们。一个平凡类型T可以被复制到char或unsigned char数组中,然后再安全地复制回T类型的变量。请注意,由于对齐要求,类型成员之间可能存在填充字节。

平凡类型有平凡的默认构造函数、平凡的拷贝和移动构造函数、平凡的拷贝和移动赋值运算符,以及平凡的析构函数。在每种情况下,“平凡”意味着构造函数/运算符/析构函数不是用户提供的,并且属于一个满足以下条件的类:

  • 没有虚函数(virtual functions)或虚基类(virtual base classes);
  • 没有具有相应非平凡构造函数/运算符/析构函数的基类;
  • 没有具有相应非平凡构造函数/运算符/析构函数的类类型数据成员。

一个类是否为平凡类,可以通过std::is_trivial特性类(trait class)来验证。它会检查该类是否是平凡可拷贝的(std::is_trivially_copyable)以及是否是平凡可默认构造的(std::is_trivially_default_constructible)。

一些示例:

#include  <iostream>
#include  <type_traits>

class A  {
public:
    int  m;
};

class  B  {
public:
    B() {}
};

class  C  {
public:
    C() = default ;
};

class  D  : C {}; 

class  E  { virtual  void  foo() {} }; 

int  main() {
    std::cout << std::boolalpha;
    std::cout << std::is_trivial<A>::value << '\n';
    std::cout << std::is_trivial<B>::value << '\n';
    std::cout << std::is_trivial<C>::value << '\n';
    std::cout << std::is_trivial<D>::value << '\n';
    std::cout << std::is_trivial<E>::value << '\n';
}
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

程序输出结果:

true
false
true
false
1
2
3
4
上次更新: 2025/04/01, 13:21:34
第五章 C++中如何使用 const 限定符
第七章 智能指针

← 第五章 C++中如何使用 const 限定符 第七章 智能指针→

最近更新
01
第一章 auto与类型推导
03-27
02
第二章 关键字static及其不同用法
03-27
03
C++语言面试问题集锦 目录与说明
03-27
更多文章>
Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式