一些零散的不同主题的 Cpp 基础笔记,单独拆开显得内容太少,干脆整理到一起。

类型别名

typedef

typedef是用于为类型定义别名的关键字,在C和C++中都可以使用。

最简单的用法是对基本类型起别名,例如

1
typedef int Length;

下面的是MSVC的stdint.h中的部分源码,对基本数据类型起了意义更明确的别名

1
2
3
4
5
6
7
8
typedef signed char        int8_t;
typedef short int16_t;
typedef int int32_t;
typedef long long int64_t;
typedef unsigned char uint8_t;
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
typedef unsigned long long uint64_t;

数组类型和指针类型可以通过typedef起别名达到简化语法和提升可读性的目的,例如

1
2
3
4
5
typedef int IntArray4[4];
typedef int Matrix3x3[3][3];

typedef int* IntPtr;
typedef int** IntPtrPtr;

使用typedef可以为结构体定义一个别名,以简化代码的书写(少写一个 struct

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// before
struct Point{
int x;
int y;
};

struct Point p;

// after
typedef struct Point{
int x;
int y;
} Point;

Point p;

对函数指针类型本身也过于复杂了,经常会使用typedef为其定义一个别名,以增强可读性

1
2
3
4
5
6
7
typedef int (*FuncType)(int, int);

int add(int a, int b) {
return a + b;
}

FuncType f = add;

using

using在C++中可以用于定义类型别名,相比于C语言提供的typedefusing具有更高的可读性和灵活性。

最基本的使用如下

1
using Length = unsigned int;

在C++中没有必要为结构体重命名,因为结构体的使用本身就很简洁了。

可以使用using为函数指针类型起一个别名

1
2
3
4
5
6
7
using FuncType = int(*)(int, int);

int add(int a, int b) {
return a + b;
}

FuncType f = add;

作为与typedef的对比,我们考虑一个比较复杂的函数指针类型:输入一个int和一个形如bool(*)(double)的函数指针,返回一个std::pair<int,double>数据,显然using的可读性更高

1
2
3
typedef std::pair<int, double> (*ComplexFuncType)(int, bool (*)(double));

using ComplexFuncType = std::pair<int, double> (*)(int, bool (*)(double));

using可以在模板类型中使用时,用于定义模板类型的别名(typedef不支持涉及模板类型的别名)

1
2
3
4
template<typename T>
using Vec = std::vector<T>;

Vec<int> v; // std::vector<int> v;

补充:

  • 现代C++建议使用using完全替代typedef,以提高代码的可读性。
  • 现代C++赋予using的功能除了这里的类型别名之外,还有很多,例如臭名昭著的using namepsace std等。

函数重载

重载(Overloading)是 C++ 为允许同名函数使用多种参数列表以及多种实现版本而提供的机制,C语言是不支持的。 具体来说,函数重载指在同一个作用域中,定义多个具有相同名称但参数列表不同(个数或类型不同)的函数,这一组函数会构成相互重载的关系, 它们之间可以具有相同或不同的返回值类型。

例如

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

int add(int a, int b) { return a + b; }

double add(double a, double b) { return a + b; }

int add(int a, int b, int c) { return a + b + c; }

int main() {
std::cout << add(1, 2) << "\n";
std::cout << add(1.5, 2.3) << "\n";
std::cout << add(1, 2, 3) << "\n";
return 0;
}

在实际使用重载函数时,C++ 编译器会根据传入参数的个数和类型等,查找匹配对应的实现版本,这个过程完全发生在编译期,可能有如下结果:

  • 如果只存在一个版本的函数使得调用过程合法,那么就会匹配这个函数;
  • 如果存在多个版本的函数都可以合法匹配到,那么编译器会进行重载决议,以挑选最合适的版本:(返回值类型完全不影响决议)
    1. 如果实参和形参的类型精确对应,那么就是最理想的情况,最优先选择这个版本;
    2. 如果实参的类型可以自动转换成形参的类型,那么优先选择这个版本;
    3. 如果实参的类型可以按照用户自定义转换方式转换成形参的类型,那么可以选择这个版本;
  • 如果不存在合适的版本,那么编译器在编译时会报错。

如果涉及到模板函数,对应的重载决议会更加复杂,还涉及到模板类型的匹配问题。

在面向对象的部分,同一个类中的不同方法也只是特殊的函数,因此也可以构成重载的关系。 尤其是 C++ 要求构造函数必须与类同名,因此提供多个构造函数时,必然构成重载的关系,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Person {
private:
std::string name;
int age;

public:
Person() : name("Unknown"), age(0) {}

explicit Person(std::string name)
: name(std::move(name)), age(0) {}

Person(std::string name, int age)
: name(std::move(name)), age(age) {}
};

函数标识符

C++为了实现函数重载,一个必要的准备就是让编译器可以区分那些同名但是参数列表不同的函数,这就自然产生了函数标识符的概念。

在C语言中,可以直接通过函数名称来区分不同函数,但是对于C++来说,必须要给函数标识符加上额外的前缀和后缀,以区分相互重载的不同函数。 例如下面这几个函数

1
2
3
void func(int, int);
void func(int, double);
void func(double, double);

g++为这些函数实际生成的函数标识符如下:

1
2
3
func(int, int) -> _Z4funcii
func(int, double) -> _Z4funcid
func(double, double) -> _Z4funcdd

而MSVC为这些函数实际生成的函数标识符如下

1
2
3
func(int, int) -> ?func@@YAXHH@Z
func(int, double) -> ?func@@YAXHD@Z
func(double, double) -> ?func@@YAXDD@Z

由此可见,C++的标识符生成规则大致为: 在函数名称的基础上,通过特定的前缀和后缀加入额外信息,分别对应参数数量、参数类型、参数顺序以及函数名称的长度等信息。

注意:

  • 函数标识符的信息是不包括参数默认值的,编译器不会将参数默认值视为函数签名的一部分。
  • 正如上面的例子,GCC/LLVM和MSVC编译器虽然都考虑到了要把参数信息用于标识符的生成,但是编译器具体使用的生成规则是不一样的,这导致了MSVC得到的库通常无法和另外两个混用!对于C++的库,考虑到兼容性,最稳妥的办法就是使用extern "C"包裹以导出C语言版本的函数接口,这会让C++编译器采用C语言的规则来生成函数标识符。(此时自然就不再支持函数重载,以及其它的部分C++语法了)

函数参数默认值

C语言不支持函数重载,也不支持函数参数的默认值,这既可以说体现了C语言的简陋,也可以说是避免了很多麻烦。C++的函数参数支持默认值的机制就比较烦人,因此需要整理一下。

为了提高代码的可读性,C++尽量也不要使用函数默认值,在讨论的最后提供了几种简单的方式可以替代。

基础

C++支持给函数参数提供默认值,例如

1
2
3
4
5
6
7
8
9
10
void func(int a, int b=1){
...
}

int main(){
func(1,2);
func(3); // == func(3,1), a=3,b=1

return 0;
}

C++要求参数的默认值必须从右向左连续地提供,保证无默认值的参数不能出现在有默认值的参数的右侧,否则编译器无法判断参数缺省时的对应关系,这是语法错误,例如

1
2
3
4
5
6
7
void func(int a, int b=1, int c){ // compile error
...
}

void func(int a, int b=1, int c=2){ // ok
...
}

声明 or 定义

C++的函数可以单独进行声明和定义,还可以重复声明,此时如何处理参数的默认值是我们关注的重点。

首先需要明确的是,如果我们同时在声明和定义中添加默认值,即使两处提供的默认值是一样的,放在一起也是编译错误。

1
2
3
void func(int a, int b = 0);

void func(int a, int b = 0) { std::cout << "a = " << a << " b = " << b; } // compile error

如果两处提供的默认值不一样,当然更是编译错误,其实这里还涉及到参数默认值何时求值的问题,见下文。

第一种语法上允许的做法是:在声明中提供默认值,在定义中不提供默认值。例如

1
2
3
4
5
void func(int a=10);

void func(int a){
...
}

第二种语法上允许的做法是:在声明中不提供默认值,在定义中提供默认值。例如

1
2
3
4
5
void func(int a);

void func(int a=10){
...
}

这两种做法虽然语法上都可以编译通过,但是在实际使用中并不等价,即使目前函数的声明和实现还是在同一个文件中,两者也是有区别的,例如

1
2
3
4
5
6
7
8
9
void func(int a);

int main(){
func(); // compile error
}

void func(int a=10){
...
}

这里虽然在函数定义中出现了默认值,但是在函数使用之前编译器只见过函数声明,在函数声明中并没有找到默认值,因此编译器会报错。

一个更复杂的情景是将它们拆分为头文件和多个源文件,我们在main.cpp中使用func这个函数

1
2
3
4
5
6
7
// main.cpp
#include "func.h"

int main(){
func(2);
func();
}

如果采用第一种做法,在声明中提供默认值

1
2
3
4
5
6
7
8
9
10
// func.h

void func(int a=10);

// func.cpp
#include "func.h"

void func(int a){
...
}

可以编译通过并顺利运行,即使将func.cpp单独编译为一个库,也是可以正常编译运行的,因为默认值在接口头文件里。

如果采用第二种做法,在定义中提供默认值

1
2
3
4
5
6
7
8
9
10
// func.h

void func(int a);

// func.cpp
#include "func.h"

void func(int a=10){
...
}

那么编译main.cpp时,func(2)语句可以正常编译,但是func()语句会报错,因为编译器在处理它的时候并不知道参数默认值是什么, 默认值只在另一个编译单元(库)中!

结合我们的实践需求来对比这两种做法:

  • 在单个源文件中使用函数前置声明的主要目的就是在定义函数之前就可以使用函数,如果将默认值藏在后面的函数定义里,就直接违背了这个意愿。
  • 对于库文件更是如此,如果仅仅将默认值放在具体实现的源文件的函数定义中,在对外提供的接口头文件的函数声明中不提供默认值,那么这个默认值实际上就不能被库的使用者所知晓和使用,这也违背了提供函数参数默认值的意愿。

因此普遍的做法是:在函数声明以及库的接口头文件中提供默认值,在函数定义中不提供默认值。

C++允许重复进行函数声明,如果考虑到函数声明中提供默认值,在语法上允许的做法是: 在一个函数声明中提供参数默认值,在其它的函数声明中不提供默认值,唯一提供默认值的函数声明的出现顺序是不重要的,例如

1
2
3
void func(int a, int b);
void func(int a, int b = 0);
void func(int a, int b);

下面的做法则由于在声明中多次出现默认值,会导致语法错误

1
2
void func(int a, int b = 0);
void func(int a, int b = 0); // compile error

如果多个函数参数都具有默认值,在提供多个函数声明时,甚至还支持将不同参数的默认值分散放置在不同的函数声明中,例如

1
2
3
4
5
void func(int a,int b,int c,int d);
void func(int a,int b,int c,int d=3);
void func(int a,int b,int c=2,int d);
void func(int a,int b=1,int c,int d);
void func(int a,int b,int c,int d);

这一组声明只相当于下面的一个声明

1
void func(int a,int b=1,int c=2,int d=3);

参数默认值在函数的多次声明中被累积。

小结

仅从语法的角度,虽然函数可能有若干个函数声明和一个函数定义,但是在使用参数默认值时, 同一个参数的默认值只允许在其中出现一次,即使我们在不同的位置提供了相同的默认值,在语法是也是不允许的

1
2
void func(int a=10);
void func(int a=10);

要么只在函数定义中提供,要么只在某一个函数声明中提供。考虑到实践中的需求,建议在某一个函数声明中提供参数默认值。

当然,在编程中最好不要提供参数默认值,可以使用函数重载进行替代,例如

1
2
3
4
5
6
7
int func(int a, int b){
...
}

int func(int a){
return func(a,1); // default b = 1
}

更推荐的方式是不使用函数重载,直接在函数名上体现默认值信息,提高代码的可读性,例如

1
2
3
4
5
6
7
int func(int a, int b){
...
}

int func_b1(int a){
return func(a,1); // default b = 1
}

不同的默认值

我们可以利用C++函数参数默认值的求值时机,以及函数签名不含默认值的特性,实现对同一个函数提供不同的默认值。

C++的函数参数默认值是在函数调用时才计算的,在使用自定义类型或者全局变量提供默认值时,我们无法保证不同时刻的调用会得到相同的默认值,例如

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

int s = 0;

void func(int a = s);

int main() {
func(); // func(0);
s = 100;
func(); // func(100);

return 0;
}

void func(int a) { std::cout << " a = " << a << "\n"; }

这里的函数参数默认值就是不一样的,对于自定义类型同理。

除了使用全局变量,甚至可以通过函数调用来生成默认值,例如

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

int create() {
static int m = 0;
return m++;
}

void test(int arg = create()) {
std::cout << "call test(" << arg << ")\n";
}

int main() {
for (int i = 0; i < 10; i++) { test(); }

return 0;
}

在函数调用过程中,除了参数默认值的求值时机,其实还有普通实参的求值时机,多个实参求值的相对顺序实际上是未定义行为,但是通常会从右向左计算。

由于函数参数默认值并不被包括在函数标识符中,这给我们留下了灵活的操作空间: 虽然在同一个编译单元中不允许默认值出现两次(无论默认值是否一致),但是不同的编译单元中使用不同的默认值却是允许的,因为编译器根本不知道在别的编译单元中函数的默认值信息,在最后链接阶段编译器只能拿到函数标识符。

例如下面的例子我们在不同的编译单元内对同一个函数进行了声明,但是在声明中提供了不同的默认值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// main.cpp
void func(int a = 10);
void test();

int main() {
func();
test();

return 0;
}

// func.cpp
#include <iostream>

void func(int a = 20);

void func(int a) { std::cout << "a = " << a << "\n"; }

void test() { func(); }

可以成功编译运行,执行的结果如下,两个编译单元内采用了不同的默认值。

1
2
a = 10
a = 20

命名空间

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

// 声明需要包裹在命名空间中
namespace MyNamespace {
extern int s;

int func();
}

// 定义也需要包裹在命名空间中
namespace MyNamespace {
int s = 20;

int func() { return 40; }
}

int func() { return 30; }

int main() {
int s = 10;
std::cout << "s = " << s << "\n"; // 10
std::cout << "s = " << MyNamespace::s << "\n"; // 20

std::cout << "func = " << func() << "\n"; // 30
std::cout << "func = " << MyNamespace::func() << "\n"; // 40
return 0;
}

注意,对命名空间中成员无论是定义还是声明的代码都需要包裹在命名空间中,在头文件中的声明也需要包裹在命名空间中。

命名空间是允许分段定义的,还可以在不同文件中定义,在编译链接时会将所有的同名命名空间组成一个整体,例如

1
2
3
4
5
6
7
8
9
10
11
namespace A {
int s1 = 10;
}

namespace B {
int s1 = 10;
}

namespace A {
int s2 = 10;
}

在外部使用命名空间中的内容时,默认都需要加上命名空间名称前缀,例如

1
2
3
4
5
6
7
8
9
10
11
namespace demo{
int s = 10;

void func(){}
}

int main(){
demo::func();

std::cout << demo::s;
}

每次都加上前缀比较繁琐,可以使用using导入命名空间中的某个符号或者整个命名空间,见下文。

嵌套命名空间

C++提供了命名空间的嵌套,例如

1
2
3
4
5
namespace Outer {
namespace Inner {
void foo() {}
}
}

对其中内容的访问加上两层命名空间的前缀即可,例如

1
Outer::Inner::foo();

在C++17之后,可以将嵌套的命名空间简写为

1
2
3
namespace Outer::Inner {
void foo() {}
}

匿名命名空间

我们可以不给命名空间起名字,这会生成一个匿名的命名空间,匿名空间的作用是将其中的内容限定在当前源文件中访问(与static的作用比较类似)。

访问匿名空间中的内容不需要加上任何前缀

1
2
3
4
5
6
7
8
namespace {
void internalFunction() {}
}

int main() {
internalFunction();
return 0;
}

标准库命名空间 std

标准库中的所有内容都位于std命名空间中,使用标准库中的内容时需要使用std::前缀,例如

1
2
3
4
5
6
#include <iostream>

int main() {
std::cout << "Hello, World!" << std::endl;
return 0;
}

我们甚至可以向std命名空间中加入自己写的内容,这在目前是可以编译通过的,但是显然这种做法是非常不推荐的。

命名空间的符号导入

在命名空间之外访问命名空间中的符号,都需要加上命名空间的前缀,例如

1
2
3
4
5
6
7
8
9
10
11
namespace demo{
int s = 10;

void func(){}
}

int main(){
demo::func();

std::cout << demo::s;
}

每次使用时都加上前缀实在过于繁琐,可以使用using导入命名空间中的符号,此后对这个符号的使用不再需要加上前缀

1
2
3
4
5
6
7
8
9
10
11
12
13
namespace demo{
int s = 10;

void func(){}
}

using demo::func; // 导入demo::func

int main(){
func(); // func == demo::func

std::cout << demo::s;
}

还可以一次性导入整个命名空间的所有符号,此后对这个命名空间中所有符号的使用都不需要加上前缀

1
2
3
4
5
6
7
8
9
10
11
12
13
namespace demo{
int s = 10;

void func(){}
}

using namespace demo; // 导入整个demo命名空间

int main(){
func(); // func == demo::func

std::cout << s; // s == demo::s
}

但是这种做法是不推荐的,因为比较危险,在头文件中导入整个命名空间则是更加的危险。

在很多C++基础教程中,采用了直接导入整个std命名空间的做法

1
using namespace std;

这是一个非常糟糕的语法习惯,它一次性导入了太多的符号,可能导致难以发现的错误。 例如标准库中提供了 std::bind 函数,如果本地也定义了 bind 函数,就可能触发编译错误,因为将这两个函数搞混了。 而且类似于 bind 这样的名称是太容易被误用了,在各种涉及网络的库中几乎都会出现,例如 winsock2.h 这个头文件中就定义了 bind 函数。

推荐的做法是在源文件中明确导入我们需要的几个符号,例如

1
2
3
using std::cout;
using std::cin;
using std::endl;

枚举类与强枚举类

概述

C和C++中提供了枚举类型,用于定义一组相关的命名离散常量, 通常直接使用非负整数实现,并且枚举可以很方便地和非负整数进行相互转换。 但是这不符合C++类型系统的设计要求,C++将之前的枚举称为弱枚举类型,并且提供了更加安全的强枚举类型, 强枚举相比弱枚举有如下优点:

  • 作用域限制:枚举成员不会污染所在作用域的命名空间。
  • 类型安全:强类型枚举不允许隐式转换为整数,必须显式进行转换。
  • 明确的基础类型:可以指定枚举成员的基础类型,默认是int

弱枚举类型

使用enum定义枚举类型和枚举变量,例如

1
2
3
4
5
enum DAY
{
MON, TUE, WED, THU, FRI, SAT, SUN
};
enum DAY day;

也可以将它们合在一起简写

1
2
3
4
enum DAY
{
MON, TUE, WED, THU, FRI, SAT, SUN
} day1, day2;

使用枚举类例如

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

enum DAY { MON, TUE, WED, THU, FRI, SAT, SUN } day1, day2;

int main() {
enum DAY day = WED;
printf("%d\n", day);

day1 = THU;
day2 = FRI;
printf("%d %d", day1, day2);
return 0;
}

输出

1
2
2
3 4

对于C语言,我们可以直接使用枚举成员(MON);对于C++,我们也可以加上枚举类型(DAY::MON),并没有什么区别。

枚举成员的实质就是某个自动对应的非负整数,我们也可以手动更改某个枚举成员对应的整数值,具体规则为:

  • 默认第一个成员的值为0
  • 默认当前成员为前一个成员的值+1
  • 显式指定某个成员的整数值的优先级最高

例如

1
2
3
4
5
6
7
enum season { spring, summer=3, autumn=10, winter };
/*
spring = 0
summer = 3
autumn = 10
winter = 11
*/

枚举类型可以和整数类型相互之间进行隐式转换,例如

1
2
3
4
5
6
7
8
enum DAY { MON, TUE, WED, THU, FRI, SAT, SUN };

enum DAY day = WED;
int d1 = day;
int d2 = day + 2;

int d3 = 2;
enum DAY day2 = d3;

虽然枚举类型的成员仅仅对应有限的几个整数值,但是编译器在把整数转换过去的过程中并不会进行范围检查,这很可能导致程序BUG, 尤其是程序中需要基于枚举类型的合法值进行判断并进入不同分支时,很可能出现意料之外的值,例如

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

enum TrafficLight { RED = 1, YELLOW, GREEN };

void func(enum TrafficLight light) {
switch (light) {
case RED: printf("Stop!\n"); break;
case YELLOW: printf("Caution!\n"); break;
case GREEN: printf("Go!\n"); break;
default: printf("Invalid light color!\n"); break;
}
}

int main() {
func(RED); // Stop!
func(2); // Caution!
func(5); // Invalid light color!
return 0;
}

编译器实际上就是将枚举成员自动替换为对应的整数,因此实际上还需要禁止不同枚举类拥有同样名称的枚举成员, 因为编译器无法区分它属于哪一个枚举类,它们可能对应不同的整数,例如

1
2
3
enum DAY { MON, TUE, WED, THU, FRI, SAT, SUN };

enum TMP { TUE, UNE }; // compile error

虽然C++尽量地兼容C语言的弱枚举类型,但是实际上它们在处理弱枚举时采用的语法规则并不一致, 有可能出现C语言可以成功编译,但是C++编译报错的语句,反之同理。

强枚举类型

使用enum class定义强枚举类型,例如

1
2
3
4
enum class DAY
{
MON, TUE, WED, THU, FRI, SAT, SUN
};

定义强枚举类型的变量和普通类型类似

1
2
DAY day1;
DAY day2 = DAY::MON;

在使用枚举成员时必须加上枚举类名前缀(DAY::MON),这也允许了不同的枚举类具有同名的枚举成员。

使用强枚举类例如

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

enum class DAY { MON, TUE, WED, THU, FRI, SAT, SUN };

int main() {
DAY day = DAY::WED;
std::cout << static_cast<int>(day);

return 0;
}

虽然强枚举类型仍然是基于整数实现的,但是C++禁止了强枚举类型和整数类型的自动转换, 我们必须使用强制类型转换实现,例如

1
2
3
4
5
6
7
8
enum class DAY { MON, TUE, WED, THU, FRI, SAT, SUN };

DAY day = DAY::WED;
int d1 = static_cast<int>(day);
int d2 = static_cast<int>(day) + 2;

int d3 = 2;
DAY day2 = static_cast<DAY>(d3);

即使是强枚举类型,在强制类型转换时编译期仍然不会进行合法范围检查

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

enum class TrafficLight { RED = 1, YELLOW, GREEN };

void func(enum TrafficLight light) {
switch (light) {
case TrafficLight::RED: printf("Stop!\n"); break;
case TrafficLight::YELLOW: printf("Caution!\n"); break;
case TrafficLight::GREEN: printf("Go!\n"); break;
default: printf("Invalid light color!\n"); break;
}
}

int main() {
func(TrafficLight::RED); // Stop!
func(static_cast<TrafficLight>(2)); // Caution!
func(static_cast<TrafficLight>(5)); // Invalid light color!
return 0;
}

强枚举类型的一个特点是可以明确指定枚举所基于的整数类型,默认是int,也可以改成其它的,例如

1
enum class Type : char { General, Light, Medium, Heavy };

这是非常有用的,尤其在某些成员对应的常数需要设置的特别大的时候。

输出语句

printf

这是C语言提供的输出语句,示例如下

1
2
3
4
5
6
7
#include <stdio.h>

int main() {
int number = 42;
printf("The answer is %d.\n", number);
return 0;
}

printf支持不定参数,占位符采用%d等,常见类型的占位符如下:

  • %c:字符
  • %s:字符串
  • %d%i:(十进制)int
  • %ld:(十进制)`long int``
  • %lld:(十进制)long long int
  • %u:(十进制)unsigned int(同理还有%lu%llu
  • %f:浮点数(包括floatdouble),可以控制输出的小数部分长度,例如%.12f
  • %e:(科学记数法)浮点数(包括floatdouble
  • %p:指针值,即内存地址(十六进制)

注:

  • %f%lfprintf中是完全等价的,因为它将浮点数自动提升为double统一处理,但是在scanf中它们并不等价,必须进行匹配(%f对应float%lf对应double),否则赋值很可能失败。
  • %d%ld%lld需要分别匹配intlong intlong long int,混用可能导致错误。

std::cout

这是C++20之前提供的基于面向对象和流输出的方式,示例如下

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

int main() {
int number = 42;
std::cout << "The answer is " << number << ".\n";
return 0;
}

流式输出在简单的情况下比较好用,但是一旦我们需要复杂的格式化输出,这就变得非常繁琐了。

std::cout的输出是带缓冲的,可以手动调用cout.flush()清空缓冲区,std::endl会在加上换行符之后清空缓冲区,事实上,如果检测到\n,通常也会自动清空缓冲,因此很多情况下不需要使用<<std::endl

值得注意的是,下面这两种写法可能存在细微的区别,因为调用了不同的函数

1
2
std::cout << "\n";
std::cout << '\n';

实际测试发现,两者的差异通常并不明显,而且很容易被编译器优化掉。

<iostream>中实际上提供了如下一组用于输出的全局对象:

  • std::cout:绑定到标准输出流stdout,带缓冲
  • std::cerr:绑定到标准错误流stderr,不带缓冲
  • std::clog:绑定到标准错误流stderr,带缓冲

最后的两个的区别仅仅是是否具有缓冲区,因此std::cerr适合立即输出警告和错误信息,std::clog适合输出日志信息。

std::cerr 并不是只能用于输出警告信息,尤其对于某些经常需要将标准输出重定向到文件中的命令行程序,将所有提示信息都通过std::cerr输出才能避免提示信息与输出信息混淆。

事实上,<iostream>这个库都是充满争议的:从设计的角度,它炫技式地采用了菱形继承的方式。在实际使用中,流式输入输出除了基本使用比较简单,在其它方面有非常多的缺点,比如:

  • std::cout在复杂格式的情况下使用非常繁琐;
  • std::cout不是线程安全的,多线程情况下的流式输出可能会乱序,而printf不会。

iostream的效率也是一直饱受诟病的,因为设计和兼容性原因,C++ 的标准输入输出(cincout)的效率会明显低于 C 的标准输入输出(scanfprintf):

  • cincout 是绑定在一起的。这意味着在调用 cin 进行输入时,cout 会被自动刷新,以确保任何未输出的数据在读取输入之前会被显示。这种自动刷新虽然保证了输出的时效性,但会降低效率,特别是在需要频繁进行输入输出时。
  • C++ 的标准输入输出(cincout)与 C 的标准输入输出(scanfprintf)是保持同步的。这意味着每次使用 cincout 时,都会检查 C 的流是否有输出或输入,以确保数据的一致性。

可以使用下面的语句来优化 C++ 的输入输出效率

1
2
3
4
5
// 关闭输入输出缓存同步
ios::sync_with_stdio(false);

// 解除cin和cout的默认绑定
cin.tie(NULL); cout.tie(NULL);

std::format

在C++20中参考开源库fmt提供了新的格式化方案:<format>std::format提供的字符串格式化可以弥补std::cout的不足,使用和Python类似的{}占位

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

int main() {
int number = 42;
std::string result = std::format("The answer is {}.", number);
std::cout << result << '\n';
return 0;
}

但这只是格式化的过程,最后还是需要使用std::cout进行输出。

在很多情况下,我们都需要使用不支持<format>的低版本编译器,可以参考下面的方式进行分类处理

1
2
3
4
5
6
#if __has_include(<format>)
#include <format>
#define USE_FORMAT
#else
#include <iomanip>
#endif

对于msvc和gcc来说,是否支持<format>与具体的版本有关,对于clang则更加麻烦,除了自身要支持,自己所使用的后端也要支持。 在Linux默认使用libstdc++作为后端,因此gcc的版本要支持,在Windows默认使用msvc作为后端,因此msvc的版本也要支持。

std::print

基于std::format设计新的输出函数是呼之欲出的,例如直接封装得到的std::print和自动回车的std::println。 目前各大编译器对std::print的支持各不相同了,正常使用应该是下面的效果

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

int main() {
int number = 42;
std::print("The answer is {}.\n", number);
return 0;
}

但是很多编译器的最新版的实现都不完整,可能需要开启C++23的语法标准,某些情况下还需要开启额外的编译选项。

可以用下面的封装作为std::print的简易实现

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

namespace my {

template <class... Args>
void print(std::iostream &stream, std::format_string<Args...> fmt,
Args &&...args) {
stream << std::format(fmt, std::forward<Args>(args)...);
}

template <class... Args>
void print(std::format_string<Args...> fmt, Args &&...args) {
std::cout << std::format(fmt, std::forward<Args>(args)...);
}

template <class... Args>
void print(std::FILE *stream, std::format_string<Args...> fmt, Args &&...args) {
std::fprintf(stream, std::format(fmt, std::forward<Args>(args)...));
}

} // namespace my

int main() {
int number = 42;
my::print("The answer is {}.\n", number);
return 0;
}

输入输出重定向

C++ 提供了方法可以将标准输出 std::cout 重定向到文件中,此时输出内容就不会在控制台显示,而是直接写入文件,示例代码如下

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 <fstream>

int main() {
// 保存 std::cout 的原始缓冲区
std::streambuf* coutBuf = std::cout.rdbuf();

// 打开输出文件
std::ofstream outFile("output.txt");
if (!outFile) {
std::cerr << "Failed to open file!\n";
return 1;
}

// 重定向 std::cout 到文件
std::cout.rdbuf(outFile.rdbuf());
std::cout << "This will be written to the file instead of the console.\n";

// 恢复 std::cout 的原始缓冲区
std::cout.rdbuf(coutBuf);
std::cout << "This will be printed to the console.\n";

outFile.close();
return 0;
}

这里的 rdbuf() 方法可以接收一个参数作为绑定的流缓冲区,或者直接无参数调用,返回值总是指向当前的(或者切换前的)缓冲区的指针。

1
2
streambuf* rdbuf() const; // get
streambuf* rdbuf(streambuf* sb); // set and get

注:

  • 同理可以对 std::cinstd::cerr 进行重定向。
  • 除了重定向到文件流,还可以重定向到字符串流,这里不再赘述。
  • 直接在命令行也可以进行输入输出重定向,用法更加简单。
  • C 语言中的 freopen 可以实现类似的重定向功能,但是它与 C++ 的重定向操作并不是完全等价的。测试发现,C 语言中的重定向操作会影响 C++ 的输出流,但是反过来没有生效。

结构化绑定

结构化绑定(structured binding)是 C++17 标准引入的一项特性, 允许开发者在解包元组 (std::tuple)、std::pair、数组或自定义结构体等数据结构时, 将其中各个元素直接绑定到多个变量上。实质就是一种语法糖,可以使代码更加简洁易读。

基本用法

最基本的语法形如

1
auto [a0,a1,a2] = data;

这一行语句可以对data进行结构化绑定,要求:

  • 左侧必须使用auto开头(其实还可以加上const&等修饰,这里暂不讨论,见下文)
  • 左侧具体需要的变量个数由右侧数据决定。
  • a0等是合法的C++标识符,并且不能是已经定义过的标识符

结构化绑定过程中会自动用a0等作为标识符定义变量,将其依次对应data中的元素值。 假设data是一个具有三个元素的自定义结构体

1
2
3
4
5
struct Person {
std::string name;
int age;
double height;
};

那么结构化绑定的语法基本等效于

1
2
3
4
5
6
// auto [a0,a1,a2] = data;

auto tmp_data = data;
auto &a0 = tmp_data.name;
auto &a1 = tmp_data.age;
auto &a2 = tmp_data.height;

这也表明结构化绑定中发生了一次拷贝,对元素a0的修改不会影响到原本的data,反之也一样。

下面是对几类数据结构进行结构化绑定的基本示例

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

int main() {
// 解包 std::tuple
std::tuple<int, double, std::string> data{42, 3.14, "Hello"};
auto [intValue, doubleValue, strValue] = data;
std::cout << std::format(
"std::tuple - intValue: {}, doubleValue: {}, strValue: {}\n", intValue,
doubleValue, strValue);

// 解包 std::pair
std::pair<int, std::string> pairData{100, "C++"};
auto [pairInt, pairStr] = pairData;
std::cout << std::format("std::pair - pairInt: {}, pairStr: {}\n", pairInt,
pairStr);

// 解包 数组
int array[] = {10, 20, 30};
auto [arrVal1, arrVal2, arrVal3] = array;
std::cout << std::format("Array - arrVal1: {}, arrVal2: {}, arrVal3: {}\n",
arrVal1, arrVal2, arrVal3);

struct Person {
std::string name;
int age;
double height;
};

// 解包 自定义结构体
Person person = {"Alice", 30, 1.68};
auto [name, age, height] = person;
std::cout << std::format("Struct - name: {}, age: {}, height: {:.2f}\n",
name, age, height);

return 0;
}

进阶用法

前面auto开头的结构化绑定中,隐含了一次对象的复制, 我们还可以加上const&修饰得到以下几类用法:(这里以自定义结构体为例,其它数据对象是一样的)

1
2
3
4
5
6
7
8
9
10
11
12
struct Person {
std::string name;
int age;
double height;
};

auto data = Person{"Alice", 30, 1.68};

auto [a0, a1, a2] = data;
auto &[b0, b1, b2] = data;
const auto [c0, c1, c2] = data;
const auto &[d0, d1, d2] = data;

通过cppinsights去掉语法糖,可以得到编译器的具体实现可能为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Person __data16 = Person(data);
auto &a0 = __data16.name;
int &a1 = __data16.age;
double &a2 = __data16.height;

Person &__data17 = data;
auto &b0 = __data17.name;
int &b1 = __data17.age;
double &b2 = __data17.height;

const Person __data18 = Person(data);
const auto &c0 = __data18.name;
const int &c1 = __data18.age;
const double &c2 = __data18.height;

const Person &__data19 = data;
const auto &d0 = __data19.name;
const int &d1 = __data19.age;
const double &d2 = __data19.height;

这里__data16等只是编译器生成的临时标识符,无法直接获取。 对比可以发现const&实际上是作用在绑定之前的临时变量之上:

  • auto:生成tmp_data作为data的拷贝
  • auto &:生成tmp_data作为data的引用
  • const:生成tmp_data作为data的常量拷贝
  • const auto &:生成tmp_data作为data的常量引用

注意:并不是每一种自定义类(结构体)都可以被结构化绑定,必须满足如下条件:

  • 所有的非静态数据成员在当前语境中均可访问,这并不要求全部成员都是public。
  • 所有的非静态数据成员都是它自己,或者同一个基类的直接成员。

第一个要求例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Demo {
int c1, c2;
public:
Demo() = default;

void foo(Demo c){
auto [a, b] = c; // C的成员函数内,可以访问它的私有成员
}
};

int main() {
Demo c{};

auto [a, b] = c; // 外部函数无法访问C的私有成员,编译报错

return 0;
}

第二个要求例如

1
2
3
4
5
6
7
8
9
10
11
12
13
struct A {
int a1, a2;
};
struct B : A {};
struct C : A { int c; };

int main() {
auto [x1,x2] = B{};

auto [y1,y2,y3] = C{}; // 编译报错

return 0;
}

此时对于自定义类型B可以进行结构化绑定,但是自定义类型C不行。