当前位置:   article > 正文

C++6万字详解_scanf输出指针

scanf输出指针

   

语言基础简介
    C++ 基础
        Hello, World!
        C++ 语法基础
        变量
        运算
        流程控制语句
        函数

C++ 进阶

    类
    命名空间
    值类别
    重载运算符
    引用
    常值
    编译优化

C++ 与其他常用语言的区别

 Pascal 转 C++ 急救

语言基础简介

本章将会介绍编程相关的知识,包括 C++ 从入门到进阶教程和一些其它语言的简介。

程序是算法与数据结构的载体,是解决 OI 问题的工具。

在 OI 中,最常用的编程语言是 C++。

学习编程是学习 OI 最基础的部分。

目录

语言基础简介

Hello, World!

环境配置

集成开发环境

编译器

Windows

macOS

Linux

在命令行中编译代码

第一份代码

C++ 语法基础

代码框架

注释

输入与输出

cin 与 cout

scanf 与 printf

一些扩展内容

C++ 中的空白字符

#define 命令

变量

数据类型

布尔类型

整数类型

字符类型

浮点类型

无类型

空指针类型

定宽整数类型

类型转换

数值提升

整数提升

浮点提升

数值转换

整数转换

浮点转换

浮点整数转换

布尔转换

定义变量

变量作用域

常量

运算

算术运算符

算术运算中的类型转换

位运算符

自增/自减 运算符

复合赋值运算符

条件运算符

比较运算符

逻辑运算符

逗号运算符

成员访问运算符

C++ 运算符优先级总表

分支

if 语句

基本 if 语句

if...else 语句

else if 语句

switch 语句

循环

for 语句

while 语句

do...while 语句

三种语句的联系

break 与 continue 语句

函数

函数的声明

实现函数:编写函数的定义

函数的调用

main 函数

定义类

访问说明符

访问与修改成员元素的值

成员函数

重载运算符

在实例化变量时设定初始值

销毁

为类变量赋值

参考资料

命名空间

概述

声明

using 指令

应用

值类别

从 CPL 语言的定义说起

C 和 C++11 以前

C++11 开始

C++17 带来的新变化

重载运算符

限制

实现

函数调用运算符

自增自减运算符

比较运算符

引用

左值引用

右值引用 (C++ 11)

一些例子

++i 和 i++

移动语义和 std::move(C++11)

函数返回引用

参考内容

常值

常类型

常量

常引用、常指针

常参数

常成员

常表达式 constexpr(C++11)

参考资料

编译优化

编译器优化简介

什么是优化 (Optimization)

常见的编译器优化

常量折叠 (Constant Folding)

死代码消除 (Deadcode Elimination)

循环旋转 (Loop Rotate)

循环不变量外提 (Loop Invariant Code Motion)

循环展开 (Loop Unroll)

循环判断外提 (Loop Unswitching)

代码布局优化 (Code Layout Optimizations)

基本块放置 (Basic Block Placement)

冷热代码分离 (Hot Cold Splitting)

函数内联 (Function Inlining)

always_inline,__force_inline

尾调用优化 (Tail Call Optimization)

用跳转指令代替函数调用

自动尾递归改写

尾递归消除 -Rpass=tailcallelim

强度削减 (Strength Reduction)

标量运算符变换

位运算代替乘法

乘法代替除法

索引变量强度削减 (IndVars)

自动向量化 (Auto-Vectorization)

__restrict type specifier (GNU, MSVC)

和编译优化相关的常见语言误用

inline - 内联

register - 虚假的寄存器建议

Sanitizer

Address Sanitizer -fsanitize=address

Undefined Behavior Sanitizer -fsanitize=undefined

杂项

Compiler Explorer

参考资料与注释

C++ 与其他常用语言的区别

C 与 C++ 的区别

宏与模板

指针与引用

bool

struct

const

内存分配

变量声明

可变长数组

结构体初始化

注释语法

Python 与 C++ 的区别

Java 与 C++ 的区别

Pascal 转 C++ 急救

C++ 快速安装与环境配置

方式一:使用 IDE

方式二:使用 代码编辑器 + 编译器 + 调试器

C++ 语法快速提要 Start Here

Hello World:第一个 C++ 程序

简要解释

简单练习

A+B Problem:第二个 C++ 程序

简要解释

简单练习

结束语与下一步

对应语法 Syntax

变量 Variable

基本数据类型 Fundamental types

常量声明 Constant

运算符 Operator

条件

if 语句

case 与 switch

循环 Loop

while 循环

for 循环

repeat until 与 do while 循环

循环控制 Loop Control

数组与字符串 Array and String

不定长数组:标准库类型 Vector

字符串:标准库类型 String

C 风格数组 Array

重要不同之处 Differences

变量作用域 Scope:全局变量与局部变量

C++ 可以自动转换类型

C++ 很多语句有返回值:以如何实现读取数量不定数据为例

函数 Function:C++ 只有函数没有过程但有 void ,没有函数值变量但有 return 。

在函数中传递参数 Passing Parameters to Functions

C++ 标准库与参考资料 Reference

C++ 标准库

错误排查与技巧

C++ 语言资料

后记

本文 Pascal 语言的参考文献

附 A:Pascal 与 C++ 运算符与数学函数语法对比表 Pascal vs C++ Operator Syntax Table

基本算术

逻辑

比较

赋值

自增/自减

数学函数


Hello, World!

环境配置

工欲善其事,必先利其器。

集成开发环境

IDE 操作较为简单,一般入门玩家会选用 IDE 来编写代码。在竞赛中最常见的是 Dev-C++(如果考试环境是 Windows 系统,一般也会提供这一 IDE)。

编译器

Windows

推荐使用 GNU 编译器。需要去 MinGW Distro 下载 MinGW 并安装。此外 Windows 下也可以选择 Microsoft Visual C++ 编译器,需要去 Visual Studio 页面 下载安装。

macOS

在终端中执行:

 
xcode-select --install
Linux

使用 g++ -v 来检查是否安装过 g++

使用如下命令可以安装:

 
sudo apt update && sudo apt install g++
在命令行中编译代码

熟练之后也有玩家会使用更灵活的命令行来编译代码,这样就不依赖 IDE 了,而是使用自己熟悉的文本编辑器编写代码。

 
g++ test.cpp -o test -lm

g++ 是 C++ 语言的编译器(C 语言的编译器为 gcc),-o 用于指定可执行文件的文件名,编译选项 -lm 用于链接数学库 libm,从而使得使用 math.h 的代码可以正常编译运行。

注:C++ 程序不需要 -lm 即可正常编译运行。历年 NOI/NOIP 试题的 C++ 编译选项中都带着 -lm,故这里也一并加上。

第一份代码

通过这样一个示例程序来展开 C++ 入门之旅吧~

注:请在编写前注意开启英文输入法。

C++ 语言

 
  1. #include <iostream> // 引用头文件
  2. using namespace std;
  3. // 引入命名空间(相关阅读 https://oi-wiki.org/lang/namespace/#using
  4. int main() { // 定义 main 函数
  5. cout << "Hello, world!"; // 输出 Hello, world!
  6. return 0; // 返回 0,结束 main 函数
  7. }

C 语言

  1. #include <stdio.h> // 引用头文件
  2. int main() { // 定义 main 函数
  3. printf("Hello, world!"); // 输出 Hello, world!
  4. return 0; // 返回 0,结束 main 函数
  5. }

C++ 语法基础

代码框架

如果你不想深究背后的原理,初学时可以直接将这个「框架」背下来:

 
  1. #include <cstdio>
  2. #include <iostream>
  3. int main() {
  4. // do something...
  5. return 0;
  6. }

什么是 include?

什么是 main()

注释

在 C++ 代码中,注释有两种写法:

  1. 行内注释

    // 开头,行内位于其后的内容全部为注释。

  2. 注释块

    /* 开头,*/ 结尾,中间的内容全部为注释,可以跨行。

注释对程序运行没有影响,可以用来解释程序的意思,还可以在让某段代码不执行(但是依然保留在源文件里)。

在工程开发中,注释可以便于日后维护、他人阅读。

在 OI 中,很少有人写许多注释,但注释可以便于在写代码的时候理清思路,或者便于日后复习。而且,如果要写题解、教程的话,适量的注释可以便于读者阅读,理解代码的意图。希望各位同学能养成写注释的好习惯。

输入与输出

cincout

 
  1. #include <iostream>
  2. int main() {
  3. int x, y; // 声明变量
  4. std::cin >> x >> y; // 读入 x 和 y
  5. std::cout << y << std::endl << x; // 输出 y,换行,再输出 x
  6. return 0; // 结束主函数
  7. }

什么是变量?

可以参考 变量 页面。

什么是 std

std 是 C++ 标准库所使用的 命名空间。使用命名空间是为了避免重名。

关于命名空间的详细知识,可以参考 命名空间 页面。

scanfprintf

scanfprintf 其实是 C 语言提供的函数。大多数情况下,它们的速度比 cincout 更快,并且能够方便地控制输入输出格式。

读入输出优化

cin/coutscanf/prinf 的具体差别和读入输出优化,请参考 读入、输出优化 页面。

 
  1. #include <cstdio>
  2. int main() {
  3. int x, y;
  4. scanf("%d%d", &x, &y); // 读入 x 和 y
  5. printf("%d\n%d", y, x); // 输出 y,换行,再输出 x
  6. return 0;
  7. }

其中,%d 表示读入/输出的变量是一个有符号整型(int 型)的变量。

类似地:

  1. %s 表示字符串。
  2. %c 表示字符。
  3. %lf 表示双精度浮点数 (double)。
  4. %lld 表示长整型 (long long)。根据系统不同,也可能是 %I64d
  5. %u 表示无符号整型 (unsigned int)。
  6. %llu 表示无符号长整型 (unsigned long long),也可能是 %I64u

除了类型标识符以外,还有一些控制格式的方式。许多都不常用,选取两个常用的列举如下:

  1. %1d 表示长度为 1 的整型。在读入时,即使没有空格也可以逐位读入数字。在输出时,若指定的长度大于数字的位数,就会在数字前用空格填充。若指定的长度小于数字的位数,就没有效果。
  2. %.6lf,用于输出,保留六位小数。

这两种运算符的相应地方都可以填入其他数字,例如 %.3lf 表示保留三位小数。

「双精度浮点数」,「长整型」是什么

为什么 scanf 中有 & 运算符?

什么是 \n

一些扩展内容

C++ 中的空白字符

在 C++ 中,所有空白字符(空格、制表符、换行),多个或是单个,都被视作是一样的。(当然,引号中视作字符串的一部分的不算。)

因此,你可以自由地使用任何代码风格(除了行内注释、字符串字面量与预处理命令必须在单行内),例如:

 
  1. /* clang-format off */
  2. #include <iostream>
  3. int
  4. main(){
  5. int/**/x, y; std::cin
  6. >> x >>y;
  7. std::cout <<
  8. y <<std::endl
  9. << x
  10. ;
  11. return 0; }

当然,这么做是不被推荐的。

一种也被广泛使用但与 OI Wiki 要求的码风不同的代码风格:

 
  1. /* clang-format off */
  2. #include <iostream>
  3. int main()
  4. {
  5. int x, y;
  6. std::cin >> x >> y;
  7. std::cout << y << std::endl << x;
  8. return 0;
  9. }

#define 命令

#define 是一种预处理命令,用于定义宏,本质上是文本替换。例如:

 
  1. #include <iostream>
  2. #define n 233
  3. // n 不是变量,而是编译器会将代码中所有 n 文本替换为 233,但是作为标识符一部分的
  4. // n 的就不会被替换,如 fn 不会被替换成 f233,同样,字符串内的也不会被替换
  5. int main() {
  6. std::cout << n; // 输出 233
  7. return 0;
  8. }

什么是标识符?

什么是预处理命令?

宏可以带参数,带参数的宏可以像函数一样使用:

 
  1. #include <iostream>
  2. #define sum(x, y) ((x) + (y))
  3. #define square(x) ((x) * (x))
  4. int main() {
  5. std::cout << sum(1, 2) << ' ' << 2 * sum(3, 5) << std::endl; // 输出 3 16
  6. }

但是带参数的宏和函数有区别。因为宏是文本替换,所以会引发许多问题。如:

 
  1. #include <iostream>
  2. #define sum(x, y) x + y
  3. // 这里应当为 #define sum(x, y) ((x) + (y))
  4. #define square(x) ((x) * (x))
  5. int main() {
  6. std::cout << sum(1, 2) << ' ' << 2 * sum(3, 5) << std::endl;
  7. // 输出为 3 11,因为 #define 是文本替换,后面的语句被替换为了 2 * 3 + 5
  8. int i = 1;
  9. std::cout << square(++i) << ' ' << i;
  10. // 输出未定义,因为 ++i 被执行了两遍
  11. // 而同一个语句中多次修改同一个变量是未定义行为(有例外)
  12. }

使用 #define 是有风险的(由于 #define 作用域是整个程序,因此可能导致文本被意外地替换,需要使用 #undef 及时取消定义),因此应谨慎使用。较为推荐的做法是:使用 const 限定符声明常量,使用函数代替宏。

但是,在 OI 中,#define 依然有用武之处(以下两种是不被推荐的用法,会降低代码的规范性):

  1. #define int long long+signed main()。通常用于避免忘记开 long long 导致的错误,或是调试时排除忘开 long long 导致错误的可能性。(也可能导致增大常数甚至 TLE,或者因为爆空间而 MLE)
  2. #define For(i, l, r) for (int i = (l); i <= (r); ++i)#define pb push_back#define mid ((l + r) / 2),用于减短代码长度。

不过,#define 也有优点,比如结合 #ifdef 等预处理指令有奇效,比如:

  1. #ifdef LINUX
  2. // code for linux
  3. #else
  4. // code for other OS
  5. #endif

变量

数据类型

C++ 的类型系统由如下几部分组成:

  1. 基础类型(括号内为代表关键词/代表类型)
    1. 无类型/void 型 (void)
    2. (C++11 起)空指针类型 (std::nullptr_t)
    3. 算术类型
      1. 整数类型 (int)
      2. 布尔类型/bool 型 (bool)
      3. 字符类型 (char)
      4. 浮点类型 (float,double)
  2. 复合类型2

布尔类型

一个 bool 类型的变量取值只可能为两种:truefalse

一般情况下,一个 bool 类型变量占有 字节(一般情况下, 字节 = 位)的空间。

C 语言的布尔类型

C 语言最初是没有布尔类型的,直到 C99 时才引入 _Bool 关键词作为布尔类型,其被视作无符号整数类型。

Note

C 语言的 bool 类型从 C23 起不再使用整型的零与非零值定义,而是定义为足够储存 truefalse 两个常量的类型。

为方便使用,stdbool.h 中提供了 bool,true,false 三个宏,定义如下:

 
  1. #define bool _Bool
  2. #define true 1
  3. #define false 0

这些宏于 C23 中移除,并且 C23 起引入 true,falsebool 作为关键字,同时保留 _Bool 作为替代拼写形式1

整数类型

用于存储整数。最基础的整数类型是 int.

注意

由于历史原因,C++ 中布尔类型和字符类型会被视作特殊的整型。

在几乎所有的情况下都 不应该 将除 signed charunsigned char 之外的字符类型作为整型使用。

整数类型一般按位宽有 5 个梯度:char,short,int,long,long long.

C++ 标准保证 1 == sizeof(char) <= sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long)

由于历史原因,整数类型的位宽有多种流行模型,为解决这一问题,C99/C++11 引入了 定宽整数类型

int 类型的大小

在 C++ 标准中,规定 int 的位数 至少 位。

事实上在现在的绝大多数平台,int 的位数均为 位。

对于 int 关键字,可以使用如下修饰关键字进行修饰:

符号性:

  • signed:表示带符号整数(默认);
  • unsigned:表示无符号整数。

大小:

  • short:表示 至少 位整数;
  • long:表示 至少 位整数;
  • (C++11 起)long long:表示 至少 位整数。

下表给出在 一般情况下,各整数类型的位宽和表示范围大小(少数平台上一些类型的表示范围可能与下表不同):

类型名等价类型位宽(C++ 标准)位宽(常见)位宽(较罕见)
signed charsigned char--
unsigned charunsigned char--
short,short int,signed short,signed short intshort int-
unsigned short,unsigned short intunsigned short int-
int,signed,signed intint(常见于 Win16 API)
unsigned,unsigned intunsigned int(常见于 Win16 API)
long,long int,signed long,signed long intlong int(常见于 64 位 Linux、macOS)
unsigned long,unsigned long intunsigned long int(常见于 64 位 Linux、macOS)
long long,long long int,signed long long,signed long long intlong long int-
unsigned long long,unsigned long long intunsigned long long int-

当位宽为 时,有符号类型的表示范围为 , 无符号类型的表示范围为 . 具体而言,有下表:

位宽表示范围
有符号:, 无符号:
有符号:, 无符号:
有符号:, 无符号:
有符号:, 无符号:

等价的类型表述

在不引发歧义的情况下,允许省略部分修饰关键字,或调整修饰关键字的顺序。这意味着同一类型会存在多种等价表述。

例如 intsignedint signedsigned int 表示同一类型,而 unsigned longunsigned long int 表示同一类型。

另外,一些编译器实现了扩展整数类型,如 GCC 实现了 128 位整数:有符号版的 __int128_t 和无符号版的 __uint128_t,如果您在比赛时想使用这些类型,请仔细阅读比赛规则 以确定是否允许或支持使用扩展整数类型。

字符类型

分为「窄字符类型」和「宽字符类型」,由于算法竞赛几乎不会用到宽字符类型,故此处仅介绍窄字符类型。

窄字符型位数一般为 位,实际上底层存储方式仍然是整数,一般通过 ASCII 编码 实现字符与整数的一一对应,有如下三种:

  • signed char:有符号字符表示的类型,表示范围在 之间。
  • unsigned char:无符号字符表示的类型,表示范围在 之间。
  • char 拥有与 signed charunsigned char 之一相同的表示和对齐,但始终是独立的类型。

    char 的符号性取决于编译器和目标平台:ARM 和 PowerPC 的默认设置通常没有符号,而 x86 与 x64 的默认设置通常有符号。

    GCC 可以在编译参数中添加 -fsigned-char-funsigned-char 指定将 char 视作 signed charunsigned char,其他编译器请参照文档。需要注意指定与架构默认值不同的符号有可能会破坏 ABI,造成程序无法正常工作。

注意

与其他整型不同,charsigned charunsigned char三种不同的类型

一般来说 signed char,unsigned char 不应用来存储字符,绝大多数情况下,这两种类型均被视作整数类型。

浮点类型

用于存储「实数」(注意并不是严格意义上的实数,而是实数在一定规则下的近似),包括以下三种:

  • float:单精度浮点类型。如果支持就会匹配 IEEE-754 binary32 格式。
  • double:双精度浮点类型。如果支持就会匹配 IEEE-754 binary64 格式。
  • long double:扩展精度浮点类型。如果支持就会匹配 IEEE-754 binary128 格式,否则如果支持就会匹配 IEEE-754 binary64 扩展格式,否则匹配某种精度优于 binary64 而值域至少和 binary64 一样好的非 IEEE-754 扩展浮点格式,否则匹配 IEEE-754 binary64 格式。
浮点格式位宽最大正数精度位数
IEEE-754 binary32 格式
IEEE-754 binary64 格式
IEEE-754 binary64 扩展格式
IEEE-754 binary128 格式

IEEE-754 浮点格式的最小负数是最大正数的相反数。

因为 float 类型表示范围较小,且精度不高,实际应用中常使用 double 类型表示浮点数。

另外,浮点类型可以支持一些特殊值:

  • 无穷(正或负):INFINITY.
  • 负零:-0.0,例如 1.0 / 0.0 == INFINITY,1.0 / -0.0 == -INFINITY.
  • 非数(NaN):std::nan,NAN,一般可以由 0.0 / 0.0 之类的运算产生。它与任何值(包括自身)比较都不相等,C++11 后可以 使用 std::isnan 判断一个浮点数是不是 NaN.

无类型

void 类型为无类型,与上面几种类型不同的是,不能将一个变量声明为 void 类型。但是函数的返回值允许为 void 类型,表示该函数无返回值。

空指针类型

请参阅指针的 对应章节

定宽整数类型

C++11 起提供了定宽整数的支持,具体如下:

  • <cstdint>:提供了若干定宽整数的类型和各定宽整数类型最大值、最小值等的宏常量。
  • <cinttypes>:为定宽整数类型提供了用于 std::fprintf 系列函数和 std::fscanf 系列函数的格式宏常量。

定宽整数有如下几种:

  • intN_t: 宽度 恰为 位的有符号整数类型,如 int32_t.
  • int_fastN_t: 宽度 至少 位的 最快的 有符号整数类型,如 int_fast32_t.
  • int_leastN_t: 宽度 至少 位的 最小的 有符号整数类型,如 int_least32_t.

无符号版本只需在有符号版本前加一个字母 u 即可,如 uint32_t,uint_least8_t.

标准规定必须实现如下 16 种类型:

int_fast8_t,int_fast16_t,int_fast32_t,int_fast64_t,

int_least8_t,int_least16_t,int_least32_t,int_least64_t,

uint_fast8_t,uint_fast16_t,uint_fast32_t,uint_fast64_t,

uint_least8_t,uint_least16_t,uint_least32_t,uint_least64_t.

绝大多数编译器在此基础上都实现了如下 8 种类型:

int8_t,int16_t,int32_t,int64_t,

uint8_t,uint16_t,uint32_t,uint64_t.

在实现了对应类型的情况下,C++ 标准规定必须实现表示对应类型的最大值、最小值、位宽的宏常量,格式为将类型名末尾的 _t 去掉后转大写并添加后缀:

  • _MAX 表示最大值,如 INT32_MAX 即为 int32_t 的最大值。
  • _MIN 表示最小值,如 INT32_MIN 即为 int32_t 的最小值。

注意

定宽整数类型本质上是普通整数类型的类型别名,所以混用定宽整数类型和普通整数类型可能会影响跨平台编译,例如:

示例代码

 
  1. #include <algorithm>
  2. #include <cstdint>
  3. #include <iostream>
  4. int main() {
  5. long long a;
  6. int64_t b;
  7. std::cin >> a >> b;
  8. std::cout << std::max(a, b) << std::endl;
  9. return 0;
  10. }

int64_t 在 64 位 Windows 下一般为 long long int, 而在 64 位 Linux 下一般为 long int, 所以这段代码在使用 64 位 Linux 下的 GCC 时不能通过编译,而使用 64 位 Windows 下的 MSVC 时可以通过编译,因为 std::max 要求输入的两个参数类型必须相同。

此外,C++17 起在 <limits> 中提供了 std::numeric_limits 类模板,用于查询各种算数类型的属性,如最大值、最小值、是否是整形、是否有符号等。

 
  1. #include <cstdint>
  2. #include <limits>
  3. std::numeric_limits<int32_t>::max(); // int32_t 的最大值, 2'147'483'647
  4. std::numeric_limits<int32_t>::min(); // int32_t 的最小值, -2'147'483'648
  5. std::numeric_limits<double>::min(); // double 的最小值, 约为 2.22507e-308
  6. std::numeric_limits<double>::epsilon(); // 1.0 与 double 的下个可表示值的差,
  7. // 约为 2.22045e-16

类型转换

在一些时候(比如某个函数接受 int 类型的参数,但传入了 double 类型的变量),我们需要将某种类型,转换成另外一种类型。

C++ 中类型的转换机制较为复杂,这里主要介绍对于基础数据类型的两种转换:数值提升和数值转换。

数值提升

数值提升过程中,值本身保持不变。

Note

C 风格的可变参数域在传值过程中会进行默认参数提升。如:

示例代码

 
  1. #include <stdarg.h>
  2. #include <stdio.h>
  3. void test(int tot, ...) {
  4. va_list valist;
  5. int i;
  6. // 初始化可变参数列表
  7. va_start(valist, tot);
  8. for (i = 0; i < tot; ++i) {
  9. // 获取第 i 个变量的值
  10. double xx = va_arg(valist, double); // Correct
  11. // float xx = va_arg(valist, float); // Wrong
  12. // 输出第 i 个变量的底层存储内容
  13. printf("i = %d, value = 0x%016llx\n", i, *(long long *)(&xx));
  14. }
  15. // 清理可变参数列表的内存
  16. va_end(valist);
  17. }
  18. int main() {
  19. float f;
  20. double fd, d;
  21. f = 123.; // 0x42f60000
  22. fd = 123.; // 0x405ec00000000000
  23. d = 456.; // 0x407c800000000000
  24. test(3, f, fd, d);
  25. }

在调用 test 时,f 提升为 double,从而底层存储内容和 fd 相同,输出为

 
  1. i = 0, value = 0x405ec00000000000
  2. i = 1, value = 0x405ec00000000000
  3. i = 2, value = 0x407c800000000000

若将 double xx = va_arg(valist, double); 改为 float xx = va_arg(valist, float);,GCC 应该给出一条类似下文的警告:

 
  1. In file included from test.c:2:
  2. test.c: In functiontest’:
  3. test.c:14:35: warning: ‘float’ is promoted to ‘double’ when passed through ‘...’
  4. 14 | float xx = va_arg(valist, float);
  5. | ^
  6. test.c:14:35: note: (so you should pass ‘double’ not ‘float’ to ‘va_arg’)
  7. test.c:14:35: note: if this code is reached, the program will abort

此时的程序将会在输出前终止。

这一点也能解释为什么 printf%f 既能匹配 float 也能匹配 double

整数提升

小整数类型(如 char)的纯右值可转换成较大整数类型(如 int)的纯右值。

具体而言,算术运算符不接受小于 int 的类型作为它的实参,而在左值到右值转换后,如果适用就会自动实施整数提升。

具体地,有如下规则:

  • 源类型为 signed charsigned short / short 时,可提升为 int
  • 源类型为 unsigned charunsigned short 时,若 int 能保有源类型的值范围,则可提升为 int,否则可提升为 unsigned int。(C++20char8_t 也适用本规则)
  • char 的提升规则取决于其底层类型是 signed char 还是 unsigned char
  • bool 类型可转换到 intfalse 变为 0true 变为 1
  • 若目标类型的值范围包含源类型,且源类型的值范围不能被 intunsigned int 包含,则源类型可提升为目标类型。3

注意

char->short 不是数值提升,因为 char 要优先提升为 int / unsigned int,之后是 int / unsigned int->short,不满足数值提升的条件。

如(以下假定 int 为 32 位,unsigned short 为 16 位,signed charunsigned char 为 8 位,bool 为 1 位)

  • (signed char)'\0' - (signed char)'\xff' 会先将 (signed char)'\0' 提升为 (int)0、将 (signed char)'\xff' 提升为 (int)-1, 再进行 int 间的运算,最终结果为 (int)1
  • (unsigned char)'\0' - (unsigned char)'\xff' 会先将 (unsigned char)'\0' 提升为 (int)0、将 (unsigned char)'\xff' 提升为 (int)255, 再进行 int 间的运算,最终结果为 (int)-255
  • false - (unsigned short)12 会先将 false 提升为 (int)0、将 (unsigned short)12 提升为 (int)12, 再进行 int 间的运算,最终结果为 (int)-12
浮点提升

位宽较小的浮点数可以提升为位宽较大的浮点数(例如 float 类型的变量和 double 类型的变量进行算术运算时,会将 float 类型变量提升为 double 类型变量),其值不变。

数值转换

数值转换过程中,值可能会发生改变。

注意

数值提升优先于数值转换。如 bool->int 时是数值提升而非数值转换。

整数转换
  • 如果目标类型为位宽为 的无符号整数类型,则转换结果是原值 后的结果。

    • 若目标类型位宽大于源类型位宽:

      • 若源类型为有符号类型,一般情况下需先进行符号位扩展再转换。

        • (short)-1(short)0b1111'1111'1111'1111)转换为 unsigned int 类型时,先进行符号位扩展,得到 0b1111'1111'1111'1111'1111'1111'1111'1111,再进行整数转换,结果为 (unsigned int)4'294'967'295(unsigned int)0b1111'1111'1111'1111'1111'1111'1111'1111)。
        • (short)32'767(short)0b0111'1111'1111'1111)转换为 unsigned int 类型时,先进行符号位扩展,得到 0b0000'0000'0000'0000'0111'1111'1111'1111,再进行整数转换,结果为 (unsigned int)32'767(unsigned int)0b0000'0000'0000'0000'0111'1111'1111'1111)。
      • 若源类型为无符号类型,则需先进行零扩展再转换。

        如将 (unsigned short)65'535(unsigned short)0b1111'1111'1111'1111)转换为 unsigned int 类型时,先进行零扩展,得到 0b0000'0000'0000'0000'1111'1111'1111'1111,再进行整数转换,结果为 (unsigned int)65'535(unsigned int)0b0000'0000'0000'0000'1111'1111'1111'1111)。

    • 若目标类型位宽不大于源类型位宽,则需先截断再转换。

      如将 (unsigned int)4'294'967'295(unsigned int)0b1111'1111'1111'1111'1111'1111'1111'1111)转换为 unsigned short 类型时,先进行截断,得到 0b1111'1111'1111'1111,再进行整数转换,结果为 (unsigned short)65'535(unsigned short)0b1111'1111'1111'1111)。

  • 如果目标类型为位宽为 的带符号整数类型,则 一般情况下,转换结果可以认为是原值 后的结果。4

    例如将 (unsigned int)4'294'967'295(unsigned int)0b1111'1111'1111'1111'1111'1111'1111'1111)转换为 short 类型时,结果为 (short)-1(short)0b1111'1111'1111'1111)。

  • 如果目标类型是 bool,则是 布尔转换

  • 如果源类型是 bool,则 false 转为对应类型的 0,true 转为对应类型的 1。

浮点转换

位宽较大的浮点数转换为位宽较小的浮点数,会将该数舍入到目标类型下最接近的值。

浮点整数转换
  • 浮点数转换为整数时,会舍弃浮点数的全部小数部分。

    如果目标类型是 bool,则是 布尔转换

  • 整数转换为浮点数时,会舍入到目标类型下最接近的值。

    如果该值不能适应到目标类型中,那么行为未定义。

    如果源类型是 bool,那么 false 转换为零,而 true 转换为一。

布尔转换

将其他类型转换为 bool 类型时,零值转换为 false,非零值转换为 true

定义变量

简单地说5,定义一个变量,需要包含类型说明符(指明变量的类型),以及要定义的变量名。

例如,下面这几条语句都是变量定义语句。

 
  1. int oi;
  2. double wiki;
  3. char org = 'c';

在目前我们所接触到的程序段中,定义在花括号包裹的地方的变量是局部变量,而定义在没有花括号包裹的地方的变量是全局变量。实际有例外,但是现在不必了解。

定义时没有初始化值的全局变量会被初始化为 。而局部变量没有这种特性,需要手动赋初始值,否则可能引起难以发现的 bug。

变量作用域

作用域是变量可以发挥作用的代码块。

全局变量的作用域,自其定义之处开始6,至文件结束位置为止。

局部变量的作用域,自其定义之处开始,至代码块结束位置为止。

由一对大括号括起来的若干语句构成一个代码块。

 
  1. int g = 20; // 定义全局变量
  2. int main() {
  3. int g = 10; // 定义局部变量
  4. printf("%d\n", g); // 输出 g
  5. return 0;
  6. }

如果一个代码块的内嵌块中定义了相同变量名的变量,则内层块中将无法访问外层块中相同变量名的变量。

例如上面的代码中,输出的 的值将是 。因此为了防止出现意料之外的错误,请尽量避免局部变量与全局变量重名的情况。

常量

常量是固定值,在程序执行期间不会改变。

常量的值在定义后不能被修改。定义时加一个 const 关键字即可。

  1. const int a = 2;
  2. a = 3;
 

如果修改了常量的值,在编译环节就会报错:error: assignment of read-only variable‘a’

 

可以在编译的时候通过 -DLINUX 来控制编译出的代码,而无需修改源文件。这还有一个优点:通过 -DLINUX 编译出的可执行文件里并没有其他操作系统的代码,那些代码在预处理的时候就已经被删除了。

#define 还能使用 ### 运算符,极大地方便调试。

运算

算术运算符

运算符功能
+ (单目)
- (单目)
* (双目)乘法
/除法
%取模
+ (双目)加法
- (双目)减法

单目与双目运算符

算术运算符中有两个单目运算符(正、负)以及五个双目运算符(乘法、除法、取模、加法、减法),其中单目运算符的优先级最高。

其中取模运算符 % 意为计算两个整数相除得到的余数,即求余数。

- 为双目运算符时做减法运算符,如 2-1 ;为单目运算符时做负值运算符,如 -1

使用方法如下

op=x-y*z

得到的 op 的运算值遵循数学中加减乘除的优先规律,首先进行优先级高的运算,同优先级自左向右运算,括号提高优先级。

算术运算中的类型转换

对于双目算术运算符,当参与运算的两个变量类型相同时,不发生 类型转换 ,运算结果将会用参与运算的变量的类型容纳,否则会发生类型转换,以使两个变量的类型一致。

转换的规则如下:

  • 先将 charboolshort 等类型提升至 int (或 unsigned int ,取决于原类型的符号性)类型;
  • 若存在一个变量类型为 long double ,会将另一变量转换为 long double 类型;
  • 否则,若存在一个变量类型为 double ,会将另一变量转换为 double 类型;
  • 否则,若存在一个变量类型为 float ,会将另一变量转换为 float 类型;
  • 否则(即参与运算的两个变量均为整数类型):
    • 若两个变量符号性一致,则将位宽较小的类型转换为位宽较大的类型;
    • 否则,若无符号变量的位宽不小于带符号变量的位宽,则将带符号数转换为无符号数对应的类型;
    • 否则,若带符号操作数的类型能表示无符号操作数类型的所有值,则将无符号操作数转换为带符号操作数对应的类型;
    • 否则,将带符号数转换为相对应的无符号类型。

例如,对于一个整型( int )变量 和另一个双精度浮点型( double )类型变量

  • x/3 的结果将会是整型;
  • x/3.0 的结果将会是双精度浮点型;
  • x/y 的结果将会是双精度浮点型;
  • x*1/3 的结果将会是整型;
  • x*1.0/3 的结果将会是双精度浮点型;

位运算符

运算符功能
~逐位非
& (双目)逐位与
|逐位或
^逐位异或
<<逐位左移
>>逐位右移

位操作的意义请参考 位运算 页面。需要注意的是,位运算符的优先级低于普通的算数运算符。

自增/自减 运算符

有时我们需要让变量进行增加 1(自增)或者减少 1(自减),这时自增运算符 ++ 和自减运算符 -- 就派上用场了。

自增/自减运算符可放在变量前或变量后面,在变量前称为前缀,在变量后称为后缀,单独使用时前缀后缀无需特别区别,如果需要用到表达式的值则需注意,具体可看下面的例子。详细情况可参考 引用 介绍的例子部分。

 
  1. i = 100;
  2. op1 = i++; // op1 = 100,先 op1 = i,然后 i = i + 1
  3. i = 100;
  4. op2 = ++i; // op2 = 101,先 i = i + 1,然后赋值 op2
  5. i = 100;
  6. op3 = i--; // op3 = 100,先赋值 op3,然后 i = i - 1
  7. i = 100;
  8. op4 = --i; // op4 = 99,先 i = i - 1,然后赋值 op4

复合赋值运算符

复合赋值运算符实际上是表达式的缩写形式。可分为复合算术运算符 +=-=*=/=%= 和复合位运算符 &=|=^=<<=>>=

例如,op = op + 2 可写为 op += 2op = op - 2 可写为 op -= 2op= op * 2 可写为 op *= 2

条件运算符

条件运算符可以看作 if 语句的简写,a ? b : c 中如果表达式 a 成立,那么这个条件表达式的结果是 b,否则条件表达式的结果是 c

比较运算符

运算符功能
>大于
>=大于等于
<小于
<=小于等于
==等于
!=不等于

其中特别需要注意的是要将等于运算符 == 和赋值运算符 = 区分开来,这在判断语句中尤为重要。

if (op=1)if (op==1) 看起来类似,但实际功能却相差甚远。第一条语句是在对 op 进行赋值,若赋值为非 0 时为真值,表达式的条件始终是满足的,无法达到判断的作用;而第二条语句才是对 op 的值进行判断。

逻辑运算符

运算符功能
&&逻辑与
||逻辑或
!逻辑非
 
  1. Result = op1 && op2; // 当 op1 与 op2 都为真时则 Result 为真
  2. Result = op1 || op2; // 当 op1 或 op2 其中一个为真时则 Result 为真
  3. Result = !op1; // 当 op1 为假时则 Result 为真

逗号运算符

逗号运算符可将多个表达式分隔开来,被分隔开的表达式按从左至右的顺序依次计算,整个表达式的值是最后的表达式的值。逗号表达式的优先级在所有运算符中的优先级是 最低 的。

 
  1. exp1, exp2, exp3; // 最后的值为 exp3 的运算结果。
  2. Result = 1 + 2, 3 + 4, 5 + 6;
  3. //得到 Result 的值为 3 而不是 11,因为赋值运算符 "="
  4. //的优先级比逗号运算符高,先进行了赋值运算才进行逗号运算。
  5. Result = (1 + 2, 3 + 4, 5 + 6);
  6. // 若要让 Result 的值得到逗号运算的结果则应将整个表达式用括号提高优先级,此时
  7. // Result 的值才为 11

成员访问运算符

运算符功能
[]数组下标
.对象成员
& (单目)取地址/获取引用
* (单目)间接寻址/解引用
->指针成员

这些运算符用来访问对象的成员或者内存,除了最后一个运算符外上述运算符都可被重载。与 &*-> 相关的内容请阅读 指针引用 教程。这里还省略了两个很少用到的运算符 .*->* ,其具体用法可以参见 C++ 语言手册

 
  1. auto result1 = v[1]; // 获取v中下标为2的对象
  2. auto result2 = p.q; // 获取p对象的q成员
  3. auto result3 = p -> q; // 获取p指针指向的对象的q成员,等价于 (*p).q
  4. auto result4 = &v; // 获取指向v的指针
  5. auto result5 = *v; // 获取v指针指向的对象

C++ 运算符优先级总表

来自 C++ 运算符优先级 - cppreference ,有修改。

运算符描述例子可重载性
第一级别
::作用域解析符Class::age = 2;不可重载
第二级别
++后自增运算符for (int i = 0; i < 10; i++) cout << i;可重载
--后自减运算符for (int i = 10; i > 0; i--) cout << i;可重载
type() type{}强制类型转换unsigned int a = unsigned(3.14);可重载
()函数调用isdigit('1')可重载
[]数组数据获取array[4] = 2;可重载
.对象型成员调用obj.age = 34;不可重载
->指针型成员调用ptr->age = 34;可重载
第三级别 (从右向左结合)
++前自增运算符for (i = 0; i < 10; ++i) cout << i;可重载
--前自减运算符for (i = 10; i > 0; --i) cout << i;可重载
+正号int i = +1;可重载
-负号int i = -1;可重载
!逻辑取反if (!done) …可重载
~按位取反flags = ~flags;可重载
(type)C 风格强制类型转换int i = (int) floatNum;可重载
*指针取值int data = *intPtr;可重载
&值取指针int *intPtr = &data;可重载
sizeof返回类型内存int size = sizeof floatNum; int size = sizeof(float);不可重载
new动态元素内存分配long *pVar = new long; MyClass *ptr = new MyClass(args);可重载
new []动态数组内存分配long *array = new long[n];可重载
delete动态析构元素内存delete pVar;可重载
delete []动态析构数组内存delete [] array;可重载
第四级别
.*类对象成员引用obj.*var = 24;不可重载
->*类指针成员引用ptr->*var = 24;可重载
第五级别
*乘法int i = 2 * 4;可重载
/除法float f = 10.0 / 3.0;可重载
%取余数(模运算)int rem = 4 % 3;可重载
第六级别
+加法int i = 2 + 3;可重载
-减法int i = 5 - 1;可重载
第七级别
<<位左移int flags = 33 << 1;可重载
>>位右移int flags = 33 >> 1;可重载
第八级别
<=>三路比较运算符if ((i <=> 42) < 0) ...可重载
第九级别
<小于if (i < 42) ...可重载
<=小于等于if (i <= 42) ...可重载
>大于if (i > 42) ...可重载
>=大于等于if (i >= 42) ...可重载
第十级别
==等于if (i == 42) ...可重载
!=不等于if (i != 42) ...可重载
第十一级别
&位与运算flags = flags & 42;可重载
第十二级别
^位异或运算flags = flags ^ 42;可重载
第十三级别
|位或运算flags = flags | 42;可重载
第十四级别
&&逻辑与运算if (conditionA && conditionB) ...可重载
第十五级别 (从右向左结合)
||逻辑或运算if (conditionA || conditionB) ...可重载
第十六级别 (从右向左结合)
? :条件运算符int i = a > b ? a : b;不可重载
throw异常抛出throw EClass("Message");不可重载
=赋值int a = b;可重载
+=加赋值运算a += 3;可重载
-=减赋值运算b -= 4;可重载
*=乘赋值运算a *= 5;可重载
/=除赋值运算a /= 2;可重载
%=模赋值运算a %= 3;可重载
<<=位左移赋值运算flags <<= 2;可重载
>>=位右移赋值运算flags >>= 2;可重载
&=位与赋值运算flags &= new_flags;可重载
^=位异或赋值运算flags ^= new_flags;可重载
|=位或赋值运算flags |= new_flags;可重载
第十七级别
,逗号分隔符for (i = 0, j = 0; i < 10; i++, j++) ...可重载

需要注意的是,表中并未列出 const_caststatic_castdynamic_castreinterpret_casttypeidsizeof...noexceptalignof 等运算符,因为它们的使用形式与函数调用相同,不会出现歧义。

分支

一个程序默认是按照代码的顺序执行下来的,有时我们需要选择性的执行某些语句,这时候就需要分支的功能来实现。选择合适的分支语句可以提高程序的效率。

if 语句

基本 if 语句

以下是基本 if 语句的结构。

 
  1. if (条件) {
  2. 主体;
  3. }

if 语句通过对条件进行求值,若结果为真(非 0),执行语句,否则不执行。

如果主体中只有单个语句的话,花括号可以省略。

if...else 语句

 
  1. if (条件) {
  2. 主体1;
  3. } else {
  4. 主体2;
  5. }

if...else 语句和 if 语句类似,else 不需要再写条件。当 if 语句的条件满足时会执行 if 里的语句,if 语句的条件不满足时会执行 else 里的语句。同样,当主体只有一条语句时,可以省略花括号。

else if 语句

 
  1. if (条件1) {
  2. 主体1;
  3. } else if (条件2) {
  4. 主体2;
  5. } else if (条件3) {
  6. 主体3;
  7. } else {
  8. 主体4;
  9. }

else if 语句是 if 和 else 的组合,对多个条件进行判断并选择不同的语句分支。在最后一条的 else 语句不需要再写条件。例如,若条件 1 为真,执行主体 1,条件 3 为真而条件 1 和条件 2 都为假,执行主体 3,所有的条件都为假才执行主体 4。

实际上,这一个语句相当于第一个 if 的 else 分句只有一个 if 语句,就将花括号省略之后放在一起了。如果条件相互之间是并列关系,这样写可以让代码的逻辑更清晰。

在逻辑上,大约相当于这一段话:

解一元二次方程的时候,方程的根与判别式的关系:

  • 如果 () 方程无解;
  • 否则,如果 () 方程有两个相同的实数解;
  • 否则 方程有两个不相同的实数解;

switch 语句

 
  1. switch (选择句) {
  2. case 标签1:
  3. 主体1;
  4. case 标签2:
  5. 主体2;
  6. default:
  7. 主体3;
  8. }

switch 语句执行时,先求出选择句的值,然后根据选择句的值选择相应的标签,从标签处开始执行。其中,选择句必须是一个整数类型表达式,而标签都必须是整数类型的常量。例如:

 
  1. int i = 1; // 这里的 i 的数据类型是整型 ,满足整数类型的表达式的要求
  2. switch (i) {
  3. case 1:
  4. cout << "OI WIKI" << endl;
  5. }
 
  1. char i = 'A';
  2. // 这里的 i 的数据类型是字符型 ,但 char
  3. // 也是属于整数的类型,满足整数类型的表达式的要求
  4. switch (i) {
  5. case 'A':
  6. cout << "OI WIKI" << endl;
  7. }

switch 语句中还要根据需求加入 break 语句进行中断,否则在对应的 case 被选择之后接下来的所有 case 里的语句和 default 里的语句都会被运行。具体例子可看下面的示例。

 
  1. char i = 'B';
  2. switch (i) {
  3. case 'A':
  4. cout << "OI" << endl;
  5. break;
  6. case 'B':
  7. cout << "WIKI" << endl;
  8. default:
  9. cout << "Hello World" << endl;
  10. }

以上代码运行后输出的结果为 WIKIHello World,如果不想让下面分支的语句被运行就需要 break 了,具体例子可看下面的示例。

 
  1. char i = 'B';
  2. switch (i) {
  3. case 'A':
  4. cout << "OI" << endl;
  5. break;
  6. case 'B':
  7. cout << "WIKI" << endl;
  8. break;
  9. default:
  10. cout << "Hello World" << endl;
  11. }

以上代码运行后输出的结果为 WIKI,因为 break 的存在,接下来的语句就不会继续被执行了。最后一个语句不需要 break,因为下面没有语句了。

处理入口编号不能重复,但可以颠倒。也就是说,入口编号的顺序不重要。各个 case(包括 default)的出现次序可任意。例如:

 
  1. char i = 'B';
  2. switch (i) {
  3. case 'B':
  4. cout << "WIKI" << endl;
  5. break;
  6. default:
  7. cout << "Hello World" << endl;
  8. break;
  9. case 'A':
  10. cout << "OI" << endl;
  11. }

switch 的 case 分句中也可以选择性的加花括号。不过要注意的是,如果需要在 switch 语句中定义变量,花括号是必须要加的。例如:

  1. char i = 'B';
  2. switch (i) {
  3. case 'A': {
  4. int i = 1, j = 2;
  5. cout << "OI" << endl;
  6. ans = i + j;
  7. break;
  8. }
  9. case 'B': {
  10. int qwq = 3;
  11. cout << "WIKI" << endl;
  12. ans = qwq * qwq;
  13. break;
  14. }
  15. default: {
  16. cout << "Hello World" << endl;
  17. }
  18. }

循环

有时,我们需要做一件事很多遍,为了不写过多重复的代码,我们需要循环。

有时,循环的次数不是一个常量,那么我们无法将代码重复多遍,必须使用循环。

for 语句

以下是 for 语句的结构:

 
  1. for (初始化; 判断条件; 更新) {
  2. 循环体;
  3. }

执行顺序:

e.g. 读入 n 个数:

 
  1. for (int i = 1; i <= n; ++i) {
  2. cin >> a[i];
  3. }

for 语句的三个部分中,任何一个部分都可以省略。其中,若省略了判断条件,相当于判断条件永远为真。

while 语句

以下是 while 语句的结构:

 
  1. while (判断条件) {
  2. 循环体;
  3. }

执行顺序:

e.g. 验证 3x+1 猜想:

 
  1. while (x > 1) {
  2. if (x % 2 == 1) {
  3. x = 3 * x + 1;
  4. } else {
  5. x = x / 2;
  6. }
  7. }

do...while 语句

以下是 do...while 语句的结构:

 
  1. do {
  2. 循环体;
  3. } while (判断条件);

执行顺序:

与 while 语句的区别在于,do...while 语句是先执行循环体再进行判断的。

e.g. 枚举排列:

 
  1. do {
  2. // do someting...
  3. } while (next_permutation(a + 1, a + n + 1));

三种语句的联系

 
  1. // for 语句
  2. for (statement1; statement2; statement3) {
  3. statement4;
  4. }
  5. // while 语句
  6. statement1;
  7. while (statement2) {
  8. statement4;
  9. statement3;
  10. }

在 statement4 中没有 continue 语句(见下文)的时候是等价的,但是下面一种方法很少用到。

 
  1. // while 语句
  2. statement1;
  3. while (statement2) {
  4. statement1;
  5. }
  6. // do...while 语句
  7. do {
  8. statement1;
  9. } while (statement2);

在 statement1 中没有 continue 语句的时候这两种方式也也是等价的。

 
  1. while (1) {
  2. // do something...
  3. }
  4. for (;;) {
  5. // do something...
  6. }

这两种方式都是永远循环下去。(可以使用 break(见下文)退出。)

可以看出,三种语句可以彼此代替,但一般来说,语句的选用遵守以下原则:

  1. 循环过程中有个固定的增加步骤(最常见的是枚举)时,使用 for 语句;
  2. 只确定循环的终止条件时,使用 while 语句;
  3. 使用 while 语句时,若要先执行循环体再进行判断,使用 do...while 语句。一般很少用到,常用场景是用户输入。

break 与 continue 语句

break 语句的作用是退出循环。

continue 语句的作用是跳过循环体的余下部分。下面以 continue 语句在 do...while 语句中的使用为例:

 
  1. do {
  2. // do something...
  3. continue; // 等价于 goto END;
  4. // do something...
  5. END:;
  6. } while (statement);

break 与 continue 语句均可在三种循环语句的循环体中使用。

一般来说,break 与 continue 语句用于让代码的逻辑更加清晰,例如:

 
  1. // 逻辑较为不清晰,大括号层次复杂
  2. for (int i = 1; i <= n; ++i) {
  3. if (i != x) {
  4. for (int j = 1; j <= n; ++j) {
  5. if (j != x) {
  6. // do something...
  7. }
  8. }
  9. }
  10. }
  11. // 逻辑更加清晰,大括号层次简单明了
  12. for (int i = 1; i <= n; ++i) {
  13. if (i == x) continue;
  14. for (int j = 1; j <= n; ++j) {
  15. if (j == x) continue;
  16. // do something...
  17. }
  18. }
 
  1. // for 语句判断条件复杂,没有体现「枚举」的本质
  2. for (int i = l; i <= r && i % 10 != 0; ++i) {
  3. // do something...
  4. }
  5. // for 语句用于枚举,break 用于「到何时为止」
  6. for (int i = l; i <= r; ++i) {
  7. if (i % 10 == 0) break;
  8. // do something...
  9. }
  1. // 语句重复,顺序不自然
  2. statement1;
  3. while (statement3) {
  4. statement2;
  5. statement1;
  6. }
  7. // 没有重复语句,顺序自然
  8. while (1) {
  9. statement1;
  10. if (!statement3) break;
  11. statement2;
  12. }

函数

函数的声明

编程中的函数(function)一般是若干语句的集合。我们也可以将其称作「子过程(subroutine)」。在编程中,如果有一些重复的过程,我们可以将其提取出来,形成一个函数。函数可以接收若干值,这叫做函数的参数。函数也可以返回某个值,这叫做函数的返回值。

声明一个函数,我们需要返回值类型、函数的名称,以及参数列表。

 
  1. // 返回值类型 int
  2. // 函数的名称 some_function
  3. // 参数列表 int, int
  4. int some_function(int, int);

如上图,我们声明了一个名为 some_function 的函数,它需要接收两个 int 类型的参数,返回值类型也为 int。可以认为,这个函数将会对传入的两个整数进行一些操作,并且返回一个同样类型的结果。

实现函数:编写函数的定义

只有函数的声明(declaration)还不够,他只能让我们在调用时能够得知函数的 接口 类型(即接收什么数据、返回什么数据),但其缺乏具体的内部实现,也就是函数的 定义(definition)。我们可以在 声明之后的其他地方 编写代码 实现(implement)这个函数(也可以在另外的文件中实现,但是需要将分别编译后的文件在链接时一并给出)。

如果函数有返回值,则需要通过 return 语句,将值返回给调用方。函数一旦执行到 return 语句,则直接结束当前函数,不再执行后续的语句。

 
  1. int some_function(int, int); // 声明
  2. /* some other code here... */
  3. int some_function(int x, int y) { // 定义
  4. int result = 2 * x + y;
  5. return result;
  6. result = 3; // 这条语句不会被执行
  7. }

在定义时,我们给函数的参数列表的变量起了名字。这样,我们便可以在函数定义中使用这些变量了。

如果是同一个文件中,我们也可以直接将 声明和定义合并在一起,换句话说,也就是在声明时就完成定义。

 
int some_function(int x, int y) { return 2 * x + y; }

如果函数不需要有返回值,则将函数的返回值类型标为 void;如果函数不需要参数,则可以将参数列表置空。同样,无返回值的函数执行到 return; 语句也会结束执行。

 
  1. void say_hello() {
  2. cout << "hello!\n";
  3. cout << "hello!\n";
  4. cout << "hello!\n";
  5. return;
  6. cout << "hello!\n"; // 这条语句不会被执行
  7. }

函数的调用

和变量一样,函数需要先被声明,才能使用。使用函数的行为,叫做「调用(call)」。我们可以在任何函数内部调用其他函数,包括这个函数自身。函数调用自身的行为,称为 递归(recursion)。

在大多数语言中,调用函数的写法,是 函数名称加上一对括号 (),如 foo()。如果函数需要参数,则我们将其需要的参数按顺序填写在括号中,以逗号间隔,如 foo(1, 2)。函数的调用也是一个表达式,函数的返回值 就是 表达式的值

函数声明时候写出的参数,可以理解为在函数 当前次调用的内部 可以使用的变量,这些变量的值由调用处传入的值初始化。看下面这个例子:

 
  1. void foo(int, int);
  2. /* ... */
  3. void foo(int x, int y) {
  4. x = x * 2;
  5. y = y + 3;
  6. }
  7. /* ... */
  8. a = 1;
  9. b = 1;
  10. // 调用前:a = 1, b = 1
  11. foo(a, b); // 调用 foo
  12. // 调用后:a = 1, b = 1

在上面的例子中,foo(a, b) 是一次对 foo 的调用。调用时,foo 中的 xy 变量,分别由调用处 ab 的值初始化。因此,在 foo 中对变量 xy 的修改,并不会影响到调用处的变量的值

如果我们需要在函数(子过程)中修改变量的值,则需要采用「传引用」的方式。

 
  1. void foo(int& x, int& y) {
  2. x = x * 2;
  3. y = y + 3;
  4. }
  5. /* ... */
  6. a = 1;
  7. b = 1;
  8. // 调用前:a = 1, b = 1
  9. foo(a, b); // 调用 foo
  10. // 调用后:a = 2, b = 4

上述代码中,我们看到函数参数列表中的「int」后面添加了一个「&(and 符号)」,这表示对于 int 类型的 引用(reference)。在调用 foo 时,调用处 ab 变量分别初始化了 foo 中两个对 int 类型的引用 xy。在 foo 中的 xy,可以理解为调用处 ab 变量的「别名」,即 foo 中对 xy 的操作,就是对调用处 ab 的操作。

main 函数

特别的,每个 C/C++ 程序都需要有一个名为 main 的函数。任何程序都将从 main 函数开始运行。

main 函数也可以有参数,通过 main 函数的参数,我们可以获得外界传给这个程序的指令(也就是「命令行参数」),以便做出不同的反应。

下面是一段调用了函数(子过程)的代码:

  1. // hello_subroutine.cpp
  2. #include <iostream>
  3. void say_hello() {
  4. std::cout << "hello!\n";
  5. std::cout << "hello!\n";
  6. std::cout << "hello!\n";
  7. }
  8. int main() {
  9. say_hello();
  10. say_hello();
  11. }

c++进阶

类(class)是结构体的拓展,不仅能够拥有成员元素,还拥有成员函数。

在面向对象编程(OOP)中,对象就是类的实例,也就是变量。

C++ 中 struct 关键字定义的也是类,上文中的 结构体 的定义来自 C。因为某些历史原因,C++ 保留并拓展了 struct

定义类

类使用关键字 class 或者 struct 定义,下文以 class 举例。

 
  1. class ClassName {
  2. ...
  3. };
  4. // Example:
  5. class Object {
  6. public:
  7. int weight;
  8. int value;
  9. } e[array_length];
  10. const Object a;
  11. Object b, B[array_length];
  12. Object *c;

与使用 struct 大同小异。该例定义了一个名为 Object 的类。该类拥有两个成员元素,分别为 weight,value;并在 } 后使用该类型定义了一个数组 e

定义类的指针形同 struct

访问说明符

不同于 struct 中的举例,本例中出现了 public,这属于访问说明符。

  • public:该访问说明符之后的各个成员都可以被公开访问,简单来说就是无论 类内 还是 类外 都可以访问。
  • protected:该访问说明符之后的各个成员可以被 类内、派生类或者友元的成员访问,但类外 不能访问
  • private:该访问说明符之后的各个成员 只能类内 成员或者友元的成员访问,不能 被从类外或者派生类中访问。

对于 struct,它的所有成员都是默认 public。对于 class,它的所有成员都是默认 private

关于 "友元" 和 "派生类",可以参考下方折叠框,或者查询网络资料进行详细了解。

对于算法竞赛来说,友元和派生类并不是必须要掌握的知识点。

关于友元以及派生类的基本概念

访问与修改成员元素的值

方法形同 struct

  • 对于变量,使用 . 符号。
  • 对于指针,使用 -> 符号。

成员函数

成员函数,顾名思义。就是类中所包含的函数。

常见成员函数举例

 
  1. class Class_Name {
  2. ... type Function_Name(...) { ... }
  3. };
  4. // Example:
  5. class Object {
  6. public:
  7. int weight;
  8. int value;
  9. void print() {
  10. cout << weight << endl;
  11. return;
  12. }
  13. void change_w(int);
  14. };
  15. void Object::change_w(int _weight) { weight = _weight; }
  16. Object var;

该类有一个打印 Object 成员元素的函数,以及更改成员元素 weight 的函数。

和函数类似,对于成员函数,也可以先声明,在定义,如第十四行(声明处)以及十七行后(定义处)。

如果想要调用 varprint 成员函数,可以使用 var.print() 进行调用。

重载运算符

何为重载

重载运算符,可以部分程度上代替函数,简化代码。

下面给出重载运算符的例子。

 
  1. class Vector {
  2. public:
  3. int x, y;
  4. Vector() : x(0), y(0) {}
  5. Vector(int _x, int _y) : x(_x), y(_y) {}
  6. int operator*(const Vector& other) { return x * other.x + y * other.y; }
  7. Vector operator+(const Vector&);
  8. Vector operator-(const Vector&);
  9. };
  10. Vector Vector::operator+(const Vector& other) {
  11. return Vector(x + other.x, y + other.y);
  12. }
  13. Vector Vector::operator-(const Vector& other) {
  14. return Vector(x - other.x, y - other.y);
  15. }
  16. // 关于4,5行表示为x,y赋值,具体实现参见后文。

该例定义了一个向量类,并重载了 * + - 运算符,并分别代表向量内积,向量加,向量减。

重载运算符的模板大致可分为下面几部分。

 
  1. /*类定义内重载*/ 返回类型 operator符号(参数){...}
  2. /*类定义内声明,在外部定义*/ 返回类型 类名称::operator符号(参数){...}

对于自定义的类,如果重载了某些运算符(一般来说只需要重载 < 这个比较运算符),便可以使用相应的 STL 容器或算法,如 sort

如要了解更多,参见「参考资料」第四条。

可以被重载的运算符

在实例化变量时设定初始值

为完成这种操作,需要定义 默认构造函数(Default constructor)。

 
  1. class ClassName {
  2. ... ClassName(...)... { ... }
  3. };
  4. // Example:
  5. class Object {
  6. public:
  7. int weight;
  8. int value;
  9. Object() {
  10. weight = 0;
  11. value = 0;
  12. }
  13. };

该例定义了 Object 的默认构造函数,该函数能够在我们实例化 Object 类型变量时,将所有的成员元素初始化为 0

若无显式的构造函数,则编译器认为该类有隐式的默认构造函数。换言之,若无定义任何构造函数,则编译器会自动生成一个默认构造函数,并会根据成员元素的类型进行初始化(与定义 内置类型 变量相同)。

在这种情况下,成员元素都是未初始化的,访问未初始化的变量的结果是未定义的(也就是说并不知道会返回和值)。

如果需要自定义初始化的值,可以再定义(或重载)构造函数。

关于定义(或重载)构造函数

使用 C++11 或以上时,可以使用 {} 进行变量的初始化。

关于 {}

 
  1. class Object {
  2. public:
  3. int weight;
  4. int value;
  5. Object() {
  6. weight = 0;
  7. value = 0;
  8. }
  9. Object(int _weight = 0, int _value = 0) {
  10. weight = _weight;
  11. value = _value;
  12. }
  13. // the same as
  14. // Object(int _weight,int _value):weight(_weight),value(_value) {}
  15. };
  16. // the same as
  17. // Object::Object(int _weight,int _value){
  18. // weight = _weight;
  19. // value = _value;
  20. // }
  21. //}
  22. Object A; // ok
  23. Object B(1, 2); // ok
  24. Object C{1, 2}; // ok,(C++11)

关于隐式类型转换

销毁

这是不可避免的问题。每一个变量都将在作用范围结束走向销毁。

但对于已经指向了动态申请的内存的指针来说,该指针在销毁时不会自动释放所指向的内存,需要手动释放动态内存。

如果结构体的成员元素包含指针,同样会遇到这种问题。需要用到析构函数来手动释放动态内存。

析构 函数(Destructor)将会在该变量被销毁时被调用。重载的方法形同构造函数,但需要在前加 ~

默认定义的析构函数通常对于算法竞赛已经足够使用,通常我们只有在成员元素包含指针时才会重载析构函数。

 
  1. class Object {
  2. public:
  3. int weight;
  4. int value;
  5. int* ned;
  6. Object() {
  7. weight = 0;
  8. value = 0;
  9. }
  10. ~Object() { delete ned; }
  11. };

为类变量赋值

默认情况下,赋值时会按照对应成员元素赋值的规则进行。也可以使用 类名称()类名称{} 作为临时变量来进行赋值。

前者只是调用了复制构造函数(copy constructor),而后者在调用复制构造函数前会调用默认构造函数。

另外默认情况下,进行的赋值都是对应元素间进行 浅拷贝,如果成员元素中有指针,则在赋值完成后,两个变量的成员指针具有相同的地址。

 
  1. // A,tmp1,tmp2,tmp3类型为Object
  2. tmp1 = A;
  3. tmp2 = Object(...);
  4. tmp3 = {...};

如需解决指针问题或更多操作,需要重载相应的构造函数。

更多 构造函数(constructor)内容,参见「参考资料」第六条。

参考资料

  1. cppreference class
  2. cppreference access
  3. cppreference default_constructor
  4. cppreference operator
  5. cplusplus Data structures
  6. cplusplus Special members
  7. C++11 FAQ
  8. cppreference Friendship and inheritance
  9. cppreference value initialization

命名空间

概述

C++ 的 命名空间 机制可以用来解决复杂项目中名字冲突的问题。

举个例子:C++ 标准库的所有内容均定义在 std 命名空间中,如果你定义了一个叫 cin 的变量,则可以通过 cin 来访问你定义的 cin 变量,通过 std::cin 访问标准库的 cin 对象,而不用担心产生冲突。

声明

下面的代码声明了一个名字叫 A 的命名空间:

 
  1. namespace A {
  2. int cnt;
  3. void f(int x) { cnt = x; }
  4. } // namespace A

声明之后,在这个命名空间外部,你可以通过 A::f(x) 来访问命名空间 A 内部的 f 函数。

命名空间的声明是可以嵌套的,因此下面这段代码也是允许的:

 
  1. namespace A {
  2. namespace B {
  3. void f() { ... }
  4. } // namespace B
  5. void f() {
  6. B::f(); // 实际访问的是 A::B::f(),由于当前位于命名空间 A
  7. // 内,所以可以省略前面的 A::
  8. }
  9. } // namespace A
  10. void f() // 这里定义的是全局命名空间的 f 函数,与 A::f 和 A::B::f
  11. // 都不会产生冲突
  12. {
  13. A::f();
  14. A::B::f();
  15. }

using 指令

声明了命名空间之后,如果在命名空间外部访问命名空间内部的成员,需要在成员名前面加上 命名空间::

有没有什么比较方便的方法能让我们直接通过成员名访问命名空间内的成员呢?答案是肯定的。我们可以使用 using 指令。

using 指令有如下两种形式:

  1. using 命名空间::成员名;:这条指令可以让我们省略某个成员名前的命名空间,直接通过成员名访问成员,相当于将这个成员导入了当前的作用域。
  2. using namespace 命名空间;:这条指令可以直接通过成员名访问命名空间中的 任何 成员,相当于将这个命名空间的所有成员导入了当前的作用域。

因此,如果执行了 using namespace std;,就会将 std 中的所有名字引入到全局命名空间当中。这样,我们就可以用 cin 代替 std::cin,用 cout 代替 std::cout

using 指令可能会导致命名冲突!

有了 using 指令,C++ 语法基础 中的代码可以有这两种等价写法:

 
  1. #include <iostream>
  2. using std::cin;
  3. using std::cout;
  4. using std::endl;
  5. int main() {
  6. int x, y;
  7. cin >> x >> y;
  8. cout << y << endl << x;
  9. return 0;
  10. }
 
  1. #include <iostream>
  2. using namespace std;
  3. int main() {
  4. int x, y;
  5. cin >> x >> y;
  6. cout << y << endl << x;
  7. return 0;
  8. }

应用

在一些具有多个子任务的问题中,我们可以对每个子任务各开一个命名空间,在其中定义我们解决该子任务所需要的变量与函数,这样各个子任务间互不干扰,会在一定程度上方便调试,也会改善程序的可读性。

值类别

注意:这部分的内容很可能对算法竞赛无用,但如果你希望更深入地理解 C++,写出更高效的代码,那么本文的内容也许会对你有所帮助。

每个 C++ 表达式都有两个属性:类型 (type) 和值类别 (value category)。前者是大家都熟悉的,但作为算法竞赛选手,很可能完全不知道后者是什么。不管你在不在意,值类别是 C++ 中非常重要的一个概念。

关于名词的翻译

type 和 category 都可以翻译为「类型」或「类别」,但为了区分两者,下文中统一将 type 翻译为「类型」,category 翻译为「类别」。

从 CPL 语言的定义说起

左值与右值的概念最早出现在 C 语言的祖先语言:CPL。

在 CPL 的定义中,lvalue 意为 left-hand side value,即能够出现在赋值运算符(等号)左侧的值,右值的定义亦然。

C 和 C++11 以前

C 语言沿用了相似的分类方法,但左右值的判断标准已经与赋值运算符无关。在新的定义中,lvalue 意为 locate value,即能进行取地址运算 (&) 的值。

可以这么理解:左值是有内存地址的对象,而右值只是一个中间计算结果(虽然编译器往往需要在内存中分配地址来储存这个值,但这个内存地址是无法被程序员感知的,所以可以认为它不存在)。中间计算结果就意味着这个值马上就没用了,以后不会再访问它。

比如在 int a = 0; 这段代码中,a 就是一个左值,而 0 是一个右值。

常见的关于左右值的误解

以下几种类型是经常被误认为右值的左值:

  • 字符串字面量:由于 C++ 兼容 C 风格的字符串,需要能对一个字符串字面量取地址(即头指针)来传参。但是其他的字面量,包括自定义字面量,都是右值。
  • 数组:数组名就是数组首个元素的指针这种说法似乎误导了很多人,但这个说法显然是错误的,对数组进行取地址是可以编译的。数组名可以隐式的退化成首个元素的指针,这才是右值。

C++11 开始

从 C++11 开始,为了配合移动语义,值的类别就不是左值右值这么简单了。

考虑一个简单的场景:

 
  1. std::vector<int> a{...};
  2. std::vector<int> b;
  3. b = a;

我们知道第三行的赋值运算复杂度是正比于 a 的长度的,复制的开销很大。但有些情况下,比如 a 在以后的代码中不会再使用,那么我们完全可以把 a 所持有的内存「转移」到 b 上,这就是移动语义干的事情。

我们姑且不管移动是怎么实现的,先来考虑一下我们如何标记 a 是可以移动的。显然不管能否移动,这个表达式的类型都是 vector 不变,所以只能对值类别下手。不可移动的 a 是左值,如果要在原有的体系下标记可以移动的 a,我们只能把它标记为右值。但标记为右值又是不合理的,因为这个 a 实际上拥有自己的内存地址,与其他右值有有根本上的不同。所以 C++11 引入了 亡值 (xvalue) 这一值类别来标记这一种表达式。

于是我们现在有了三种类别:左值 (lvalue)、纯右值 (prvalue)、亡值 (xvalue)(纯右值就是原先的右值)。

然后我们发现亡值同时具有一些左值和纯右值的性质,比如它可以像左值一样取地址,又像右值一样不会再被访问。

所以又有了两种组合类别:泛左值 (glvalue)(左值和亡值)、右值 (rvalue)(纯右值和亡值)。

有一个初步的感性理解后,来看一下标准委员会对它们的定义:

  • A glvalue(generalized lvalue) is an expression whose evaluation determines the identity of an object, bit-field, or function.
  • A prvalue(pure rvalue) is an expression whose evaluation initializes an object or a bit-field, or computes the value of an operand of an operator, as specified by the context in which it appears, or an expression that has type cv void.
  • An xvalue(eXpiring value) is a glvalue that denotes an object or bit-field whose resources can be reused(usually because it is near the end of its lifetime)。
  • An lvalue is a glvalue that is not an xvalue.
  • An rvalue is a prvalue or an xvalue.

上述定义中提到了一个叫位域 (bit-field) 的东西。如果你不知道位域是什么,忽略它即可,后文也不会提及。

其中关键的两个概念:

  • 是否拥有身份 (identity):可以确定表达式是否与另一表达式指代同一实体,例如比较它们所标识的对象或函数的(直接或间接获得的)地址
  • 是否可以被移动 (resources can be reused):对象的资源可以移动到别的对象中

这 5 种类型无非就是根据上面两种属性的是与否区分的,所以用下面的这张表格可以帮助理解:

拥有身份(glvalue)不拥有身份
可移动(rvalue)xvalueprvalue
不可移动lvalue不存在

注意不拥有身份就意味着这个对象以后无法被访问,这样的对象显然是可以被移动的,所以不存在不拥有身份不可移动的值。

C++17 带来的新变化

从拷贝到移动提升了不少速度,那么我们是否能够优化的更彻底一点,把移动的开销都省去呢?

考虑这样的代码:

 
  1. std::vector<int> make_vector(...) {
  2. std::vector<int> result;
  3. // ...
  4. return result;
  5. }
  6. std::vector<int> a = make_vector(...);

make_vector 函数根据一输入生成一个 vector。这个 vector 一开始在 make_vector 的栈上被构造,随后又被移动到调用者的栈上,需要一次移动操作,这显然很浪费,能不能省略这次移动?

答案是肯定的,这就是 RVO 优化,即省略拷贝。通常的方法是编译器让 make_vector 返回的对象直接在调用者的栈上构造,然后 make_vector 在上面进行修改。这相当与这样的代码:

 
  1. void make_vector(std::vector<int>& result, ...) {
  2. // ... (对 result 进行操作)
  3. }
  4. std::vecctor<int> a;
  5. make_vector(a, ...);

在 C++17 以前,尽管标准未做出规定,但主流编译器都实现了这种优化。在 C++17 以后,这种优化成为标准的硬性规定。

回到和移动语义刚被提出时的问题,如何确定一个移动赋值是可以省略的?再引入一种新的值类别?

不,C++11 的值类别已经够复杂了。我们意识到在 C++11 的标准下,亡值和纯右值都是可以移动的,那么就可以在这两种类别上做文章。

C++17 以后,纯右值不再能移动,但可以隐式地转变为亡值。对于纯右值用于初始化的情况下,可以省略拷贝,而其他不能省略的情况下,隐式转换为亡值进行移动。

所以在 C++17 之后的值类别,被更为整齐的划分为泛左值与纯右值两大块,右值存在的意义被削弱。这样的改变某种程度上简化了整个值类别体系。

重载运算符

重载运算符是通过对运算符的重新定义,使得其支持特定数据类型的运算操作。重载运算符是重载函数的特殊情况。

C++ 自带的运算符,最初只定义了一些基本类型的运算规则。当我们要在用户自定义的数据类型上使用这些运算符时,就需要定义运算符在这些特定类型上的运算方式。

限制

重载运算符存在如下限制:

  • 只能对现有的运算符进行重载,不能自行定义新的运算符。
  • 以下运算符不能被重载:::(作用域解析),.(成员访问),.*(通过成员指针的成员访问),?:(三目运算符)。
  • 重载后的运算符,其运算优先级,运算操作数,结合方向不得改变。
  • &&(逻辑与)和 ||(逻辑或)的重载失去短路求值。

实现

重载运算符分为两种情况,重载为成员函数或非成员函数。

当重载为成员函数时,因为隐含一个指向当前成员的 this 指针作为参数,此时函数的参数个数与运算操作数相比少一个。

而当重载为非成员函数时,函数的参数个数与运算操作数相同。

下面将给出几个重载运算符的示例。

函数调用运算符

函数调用运算符 () 只能重载为成员函数。通过对一个类重载 () 运算符,可以使该类的对象能像函数一样调用。

重载 () 运算符的一个常见应用是,将重载了 () 运算符的结构体作为自定义比较函数传入优先队列等 STL 容器中。

下面就是一个例子:给出 个学生的姓名和分数,按分数降序排序,分数相同者按姓名字典序升序排序,输出排名最靠前的人的姓名和分数。

 
  1. #include <iostream>
  2. #include <queue>
  3. using namespace std;
  4. struct student {
  5. string name;
  6. int score;
  7. };
  8. struct cmp {
  9. bool operator()(const student& a, const student& b) const {
  10. return a.score < b.score || (a.score == b.score && a.name > b.name);
  11. }
  12. };
  13. priority_queue<student, vector<student>, cmp> pq;
  14. int main() {
  15. int n;
  16. cin >> n;
  17. for (int i = 1; i <= n; i++) {
  18. string name;
  19. int score;
  20. cin >> name >> score;
  21. pq.push({name, score});
  22. }
  23. student rk1 = pq.top();
  24. cout << rk1.name << ' ' << rk1.score << endl;
  25. return 0;
  26. }

自增自减运算符

自增自减运算符分为两类,前置和后置。为了能将两类运算符区别开来,对于后置自增自减运算符,重载的时候需要添加一个类型为 int 的空置形参。

另外一点是,内置的自增自减运算符中,前置的运算符返回的是引用,而后置的运算符返回的是值。虽然重载后的运算符不必遵循这一限制,不过在语义上,仍然期望重载的运算符与内置的运算符在返回值的类型上保持一致。

因此,对于类型 T,典型的重载自增运算符的定义如下:

重载定义(以 ++ 为例)成员函数非成员函数
前置T& T::operator++();T& operator++(T& a);
后置T T::operator++(int);T operator++(T& a, int);

比较运算符

std::sort 和一些 STL 容器中,需要用到 < 运算符。在使用自定义类型时,我们需要手动重载。

还是以讲函数调用运算符时举的例子说起,如果我们重载比较运算符,实现代码是这样的(主函数因为没有改动就略去了):

 
  1. struct student {
  2. string name;
  3. int score;
  4. bool operator<(const student& a) const {
  5. return score < a.score || (score == a.score && name > a.name);
  6. // 上面省略了 this 指针,完整表达式如下:
  7. // this->score<a.score||(this->score==a.score&&this->name>a.name);
  8. }
  9. };
  10. priority_queue<student> pq;

上面的代码将小于号重载为了成员函数,当然重载为非成员函数也是可以的。

 
  1. struct student {
  2. string name;
  3. int score;
  4. };
  5. bool operator<(const student& a, const student& b) {
  6. return a.score < b.score || (a.score == b.score && a.name > b.name);
  7. }
  8. priority_queue<student> pq;

事实上,只要有了 < 运算符,则其他五个比较运算符的重载也可以很容易实现。

 
  1. /* clang-format off */
  2. // 下面的几种实现均将小于号重载为非成员函数
  3. bool operator<(const T& lhs, const T& rhs) { /* 这里重载小于运算符 */ }
  4. bool operator>(const T& lhs, const T& rhs) { return rhs < lhs; }
  5. bool operator<=(const T& lhs, const T& rhs) { return !(lhs > rhs); }
  6. bool operator>=(const T& lhs, const T& rhs) { return !(lhs < rhs); }
  7. bool operator==(const T& lhs, const T& rhs) { return !(lhs < rhs) && !(lhs > rhs); }
  8. bool operator!=(const T& lhs, const T& rhs) { return !(lhs == rhs); }

参考资料与注释:

 

引用

引用可以看成是 C++ 封装的指针,用来传递它所指向的对象。在 C++ 代码中实际上会经常和引用打交道,但是通常不会显式地表现出来。引用的基本原则是在声明时必须指向对象,以及对引用的一切操作都相当于对原对象操作。另外,引用不是对象,因此不存在引用的数组、无法获取引用的指针,也不存在引用的引用。

注意引用类型不属于对象类型,所以才需要 reference_wrapper 这种设施。

引用主要分为两种,左值引用和右值引用。此外还有两种特殊的引用:转发引用和垂悬引用,不作详细介绍。另外,本文还牵涉到一部分常值的内容,请用 常值 一文辅助阅读。

左值引用

左值和右值

左值表达式

通常我们会接触到的引用为左值引用,即绑定到左值的引用,但 const 的左值引用可以绑定到右值。以下是来自 参考手册 的一段示例代码。

 
  1. #include <iostream>
  2. #include <string>
  3. int main() {
  4. std::string s = "Ex";
  5. std::string& r1 = s;
  6. const std::string& r2 = s;
  7. r1 += "ample"; // 修改 r1,即修改了 s
  8. // r2 += "!"; // 错误:不能通过到 const 的引用修改
  9. std::cout << r2 << '\n'; // 打印 r2,访问了s,输出 "Example"
  10. }

左值引用最常用的地方是函数参数,通过左值引用传参可以起到与通过指针传参相同的效果。

 
  1. #include <iostream>
  2. #include <string>
  3. // 参数中的 s 是引用,在调用函数时不会发生拷贝
  4. char& char_number(std::string& s, std::size_t n) {
  5. s += s; // 's' 与 main() 的 'str' 是同一对象
  6. // 此处还说明左值也是可以放在等号右侧的
  7. return s.at(n); // string::at() 返回 char 的引用
  8. }
  9. int main() {
  10. std::string str = "Test";
  11. char_number(str, 1) = 'a'; // 函数返回是左值,可被赋值
  12. std::cout << str << '\n'; // 此处输出 "TastTest"
  13. }

右值引用 (C++ 11)

右值引用是绑定到右值的引用。右值 可以在内存里也可以在 CPU 寄存器中。另外,右值引用可以被看作一种 延长临时对象生存期的方式

 
  1. #include <iostream>
  2. #include <string>
  3. int main() {
  4. std::string s1 = "Test";
  5. // std::string&& r1 = s1; // 错误:不能绑定到左值
  6. const std::string& r2 = s1 + s1; // 可行:到常值的左值引用延长生存期
  7. // r2 += "Test"; // 错误:不能通过到常值的引用修改
  8. std::string&& r3 = s1 + s1; // 可行:右值引用延长生存期
  9. r3 += "Test"; // 可行:能通过到非常值的右值引用修改
  10. std::cout << r3 << '\n';
  11. }

在上述代码中,r3 是一个右值引用,引用的是右值 s1 + s1r2 是一个左值引用,可以发现 右值引用可以转为 const 修饰的左值引用

一些例子

++ii++

++ii++ 是典型的左值和右值。++i 的实现是直接给 i 变量加一,然后返回 i 本身。因为 i 是内存中的变量,因此可以是左值。实际上前自增的函数签名是 T& T::operator++();。而 i++ 则不一样,它的实现是用临时变量存下 i,然后再对 i 加一,返回的是临时变量,因此是右值。后自增的函数签名是 T T::operator++(int);

 
  1. int n1 = 1;
  2. int n2 = ++n1;
  3. int n3 = ++ ++n1; // 因为是左值,所以可以继续操作
  4. int n4 = n1++;
  5. // int n5 = n1++ ++; // 错误,无法操作右值
  6. // int n6 = n1 + ++n1; // 未定义行为
  7. int&& n7 = n1++; // 利用右值引用延长生命期
  8. int n8 = n7++; // n8 = 1

移动语义和 std::move(C++11)

在 C++11 之后,C++ 利用右值引用新增了移动语义的支持,用来避免对象在堆空间的复制(但是无法避免栈空间复制),STL 容器对该特性有完整支持。具体特性有 移动构造函数移动赋值 和具有移动能力的函数(参数里含有右值引用)。 另外,std::move 函数可以用来产生右值引用,需要包含 <utility> 头文件。

注意:一个对象被移动后不应对其进行任何操作,无论是修改还是访问。被移动的对象处于有效但未指定的状态,具体内容依赖于 stl 的实现。如果需要访问(即指定一种状态),可以使用该对象的 swap 成员函数或者偏特化的 std::swap 交换两个对象(同样可以避免堆空间的复制)。

 
  1. // 移动构造函数
  2. std::vector<int> v{1, 2, 3, 4, 5};
  3. std::vector<int> v2(std::move(v)); // 移动v到v2, 不发生拷贝
  4. // 移动赋值函数
  5. std::vector<int> v3;
  6. v3 = std::move(v2);
  7. // 有移动能力的函数
  8. std::string s = "def";
  9. std::vector<std::string> numbers;
  10. numbers.push_back(std::move(s));

注意上述代码仅在 C++11 之后可用。

函数返回引用

让函数返回引用值可以避免函数在返回时对返回值进行拷贝,如

 
char &get_val(std::string &str, int index) { return str[index]; }

你不能返回在函数中的局部变量的引用,如果一定要在函数内的变量。请使用动态内存。例如如下两个函数都会产生悬垂引用,导致未定义行为。

 
  1. std::vector<int>& getLVector() { // 错误:返回局部变量的左值引用
  2. std::vector<int> x{1};
  3. return x;
  4. }
  5. std::vector<int>&& getRVector() { // 错误:返回局部变量的右值引用
  6. std::vector<int> x{1};
  7. return std::move(x);
  8. }

当右值引用指向的空间在进入函数前已经分配时,右值引用可以避免返回值拷贝。

 
  1. struct Beta {
  2. Beta_ab ab;
  3. Beta_ab const& getAB() const& { return ab; }
  4. Beta_ab&& getAB() && { return std::move(ab); }
  5. };
  6. Beta_ab ab = Beta().getAB(); // 这里是移动语义,而非拷贝

参考内容

  1. C++ 语言文档——引用声明
  2. C++ 语言文档——值类别
  3. Is returning by rvalue reference more efficient?
  4. 浅谈值类别及其历史

常值

C++ 定义了一套完善的只读量定义方法,被常量修饰符 const 修饰的对象或类型都是只读量,只读量的内存存储与一般变量没有任何区别,但是编译器会在编译期进行冲突检查,避免对只读量的修改。因此合理使用 const 修饰符可以增加代码健壮性。

常类型

在类型的名字前增加 const 修饰会将该类型的变量标记为不可变的。具体使用情况有常量和常引用(指针)两种。

常量

这里的常量即常变量,指的是 const 类型的变量(而不是标题里泛指的只读量)。常类型量在声明之后便不可重新赋值,也不可访问其可变成员,只能访问其常成员。常成员的定义见后文。

类型限定符

 
  1. const int a = 0; // a 的类型为 const int
  2. // a = 1; // 报错,不能修改常量

常引用、常指针

常引用和常指针也与常量类似,但区别在于他们是限制了访问,而没有更改原变量的类型。

 
  1. int a = 0;
  2. const int b = 0;
  3. int *p1 = &a;
  4. *p1 = 1;
  5. const int *p2 = &a;
  6. // *p2 = 2; // 报错,不能通过常指针修改变量
  7. // int *p3 = &b; // 报错,不能用普通指针指向 const 变量
  8. const int *p4 = &b;
  9. int &r1 = a;
  10. r1 = 1;
  11. const int &r2 = a;
  12. // r2 = 2; // 报错,不能通过常引用修改变量
  13. // int &p3 = b; // 报错,不能用普通引用指向 const 变量
  14. const int &r4 = b;

另外需要区分开的是「常类型指针」和「常指针变量」(即常指针、指针常量),例如下列声明

 
  1. int* const p1; // 类型为int的常指针,需要初始化
  2. const int* p2; // 类型为const int的指针
  3. const int* const p3; // 类型为const int的常指针
  4. int (*f1)(int); // 普通的函数指针
  5. // int (const *f2)(int); // 指向常函数的指针,不可行
  6. int (*const f3)(int) = some_func; // 指向函数的常指针,需要初始化
  7. int const* (*f4)(int); // 指向返回常指针的函数指针
  8. int const* (*const f5)(int) = some_func; // 指向返回常指针的函数的常指针

我们把常类型指针又称 底层指针、常指针变量又称 顶层指针

另外,C++ 中还提供了 const_cast 运算符来强行去掉或者增加引用或指针类型的 const 限定,不到万不得已的时候请不要使用这个关键字。

常参数

在函数参数里限定参数为常类型可以避免在函数里意外修改参数,该方法通常用于引用参数。此外,在类型参数中添加 const 修饰符还能增加代码可读性,能区分输入和输出参数。

 
  1. void sum(const std::vector<int> &data, int &total) {
  2. for (auto iter = data.begin(); iter != data.end(); ++iter)
  3. total += *iter; // iter 是 const 迭代器,解引用后的类型是 const int
  4. }

常成员

常成员指的是类型中被 const 修饰的成员,常成员可以用来限制对常对象的修改。其中,常成员变量与常量声明相同,而常成员函数声明方法为在成员函数声明的 末尾(参数列表的右括号的右边)添加 const 修饰符。

 
  1. #include <iostream>
  2. struct ConstMember {
  3. const int *p; // 常类型指针成员
  4. int *const q; // 常指针变量成员
  5. int s;
  6. void func() { std::cout << "General Function" << std::endl; }
  7. void constFunc1() const { std::cout << "Const Function 1" << std::endl; }
  8. void constFunc2(int ss) const {
  9. // func(); // 常成员函数不能调用普通成员函数
  10. constFunc1(); // 常成员函数可以调用常成员函数
  11. // s = ss; // 常成员函数不能改变普通成员变量
  12. // p = &ss; // 常成员函数不能改变常成员变量
  13. }
  14. };
  15. int main() {
  16. const int a = 1;
  17. int b = 1;
  18. struct ConstMember c = {.p = &a, .q = &b}; // 指派初始化器是C++20中的一种语法
  19. // *(c.p) = 2; // 常类型指针无法改变指向的值
  20. c.p = &b; // 常类型指针可以改变指针指向
  21. *(c.q) = 2; // 常指针变量可以改变指向的值
  22. // c.q = &a; // 常指针变量无法改变指针指向
  23. const struct ConstMember d = c;
  24. // d.func(); // 常对象不能调用普通成员函数
  25. d.constFunc2(b); // 常对象可以调用常成员函数
  26. return 0;
  27. }

常表达式 constexpr(C++11)

constexpr 说明符的作用是声明可以在编译时求得函数或变量的值,它的行为和 C 语言中的 const 关键字是一致的,会将变量结果直接编译到栈空间中。constexpr 还可以用来替换宏定义的常量,规避 宏定义的风险。constexpr 修饰的是变量和函数,而 const 修饰的是类型。

实际上把 const 理解成 "readonly",而把 constexpr 理解成 "const" 更加直观。

 
  1. constexpr int a = 10; // 直接定义常量
  2. constexpr int FivePlus(int x) { return 5 + x; }
  3. void test(const int x) {
  4. std::array<x> c1; // 错误,x在编译期不可知
  5. std::array<FivePlus(6)> c2; // 可行,FivePlus编译期可以推断
  6. }

参考资料

编译优化

OI 界的常用编程语言是 C++。既然使用了这门语言,就注定要和编译器、语言标准打交道了。众所周知,C++ 非常混乱邪恶,本文旨在给出实用的编译器相关知识,足够竞赛使用。

编译器优化简介

什么是优化 (Optimization)

保持语义不变的情况下,对程序运行速度、程序可执行文件大小作出改进。

常见的编译器优化

常量折叠 (Constant Folding)

常量折叠,又称常量传播 (Constant Propagation),如果一个表达式可以确定为常量,在他的下一个定义 (Definition) 前,可以进行常量传播。

 
  1. int x = 1;
  2. int y = x; // x = 1, => y = 1
  3. x = 3;
  4. int z = 2 * y; // z => 2 * y = 2 * 1 = 2
  5. int y2 = x * 2; // x = 3, => y2 = 6

这段代码在编译期间即可被转换为:

 
  1. int x = 1;
  2. int y = 1;
  3. x = 3;
  4. int z = 2;
  5. int y2 = 6;

实例:Compiler Explorer

死代码消除 (Deadcode Elimination)

故名思义,就是一段代码没用上就会被删去。

 
  1. int test() {
  2. int a = 233;
  3. int b = a * 2;
  4. int c = 234;
  5. return c;
  6. }

将被转换为

 
int test() { return 234; }

注意,这个代码首先进行了常量折叠,使得返回值可以确定为 234,a, b 为不活跃变量,因此删除。

循环旋转 (Loop Rotate)

将循环从 "for" 形式,转换为 "do-while" 形式,前面再多加一个条件判断。这个变换主要为其他变换做准备。

 
  1. for (int i = 0; i < n; ++i) {
  2. auto v = *p;
  3. use(v);
  4. }

变换为

 
  1. if (0 < n) {
  2. do {
  3. auto v = *p;
  4. use(v);
  5. ++i;
  6. } while (i < n);
  7. }

循环不变量外提 (Loop Invariant Code Motion)

基于别名分析 (Alias Analysis),将循环中被证明是不变量(可能包含内存访问,load/store,因此依赖别名分析)的代码外提出循环体,这样可以让循环体内部少一些代码。

 
  1. for (int i = 0; i < n; ++i) {
  2. auto v = *p;
  3. use(v);
  4. }

这个代码直观来看可以外提为:

 
  1. auto v = *p;
  2. for (int i = 0; i < n; ++i) {
  3. use(v);
  4. }

但实际上,如果 n <= 0,这个循环永远不会被进入,但我们又执行了一条多的指令(可能有副作用!)。因此,循环通常被 Rotate 为 do-while 形式,这样可以方便插入一个 "loop guard"。之后再进行循环不变量外提。

 
  1. if (0 < n) { // loop guard
  2. auto v = *p;
  3. do {
  4. use(v);
  5. ++i;
  6. } while (i < n);
  7. }

循环展开 (Loop Unroll)

循环包含循环体和各类分支语句,需要现代 CPU 进行一定的分支预测。直接把循环展开,用一定的代码大小来换取运行时间。

 
  1. for (int i = 0; i < 3; i++) {
  2. a[i] = i;
  3. }

变换为:

 
  1. a[0] = 0;
  2. a[1] = 1;
  3. a[2] = 2;

循环判断外提 (Loop Unswitching)

循环判断外提将循环中的条件式移到循环之外,然后在外部的两个条件各放置两个循环,这样可以增加循环向量化、并行化的可能性(通常简单循环更容易被向量化)。

 
  1. // clang-format off
  2. void before(int x) {
  3. for(;/* i in some range */;) {
  4. /* A */;
  5. if (/* condition */ x % 2) {
  6. /* B */;
  7. }
  8. /* C */;
  9. }
  10. }
  11. void after(int x) {
  12. if (/* condition */ x % 2) {
  13. for(;/* i in some range */;) {
  14. /* A */;
  15. /* B */; // 直接执行 B ,不进行循环判断
  16. /* C */;
  17. }
  18. } else {
  19. for(;/* i in some range */;) {
  20. /* A */;
  21. // 不执行 B
  22. /* C */;
  23. }
  24. }
  25. }

代码布局优化 (Code Layout Optimizations)

程序在执行时,可以将执行的路径分为冷热路径 (cold/hot path)。CPU 跳转执行,绝大多数情况下没有直接顺序执行快,后者通常被编译器作者称为 "fallthrough"。与之对应的,经常被执行到的代码成为热代码,与之相对的成为冷代码。OI 代码中,如果有一段是循环中的特判边界条件,或者异常处理,类似的逻辑,则此部分代码为冷代码。

基本块 (Basic Block),是控制流的基本结构,一个过程 (Procedure) 由若干个基本块组成,形成一个有向图。生成可执行文件的过程中,编译器需要安排一个放置基本块的布局 (Layout),而如何编排布局,是此优化的重点。

原则上,应该更偏好与将热代码放在一起,而将冷代码隔开。原因是这样能够更好地利用指令缓存,热代码的局部性会更好。

 
  1. // clang-format off
  2. int hotpath; // <-- 热!
  3. if (/* 边界条件 */ false) {
  4. // <-- 冷!
  5. }
  6. int hotpath_again; // <-- 热!
基本块放置 (Basic Block Placement)

我们用 label 来表达一种「伪机器码」,这个 C++ 程序有两种翻译方法:

布局 1

 
  1. // clang-format off
  2. hotblock1:
  3. Stmts; // <-- 热!
  4. if (/* 边界条件不成立 */ true)
  5. goto hotblock2; // 经常发生! ------+
  6. coldblock: /* | */
  7. Stmt; // <- 冷 |
  8. Stmt; // <- 冷 |
  9. Stmt; // <- 冷 | 跨越了大量指令,代价高昂!
  10. Stmt; // <- 冷 |
  11. Stmt; // <- 冷 |
  12. Stmt; // <- 冷 |
  13. Stmt; // <- 冷 |
  14. hotblock2: /* | */
  15. Stmts; // <- 热! <----------+

另一种布局为:

布局 2

 
  1. // clang-format off
  2. hotblock1:
  3. Stmts; // <-- 热!
  4. if (/* 边界条件 */ false)
  5. goto coldblock; // 很少发生
  6. hotblock2: /* | 低代价! */
  7. Stmts; // <- 热! <-----------------+
  8. coldblock:
  9. Stmt; // <- 冷
  10. Stmt; // <- 冷
  11. Stmt; // <- 冷
  12. Stmt; // <- 冷
  13. Stmt; // <- 冷

我们看到后一种布局中,两个热代码块被放到了一起,执行效率更优秀。

为了告诉编译器分支是否容易被执行,可以使用 C++20 [[likely]][[unlikely]]:C++ attribute: likely, unlikely (since C++20) - cppreference.com

如果比赛没有采用 C++20 以上标准,则可以利用 __builtin_expect(GNU Extension)。

 
  1. #define likely(x) __builtin_expect(!!(x), 1)
  2. #define unlikely(x) __builtin_expect(!!(x), 0)
  3. if (unlikely(/* 一些边界条件检查 */ false)) {
  4. // 冷代码
  5. }
冷热代码分离 (Hot Cold Splitting)

一个过程 (Procedure) 包含同时包含冷热路径,而冷代码较长,更好的做法是让冷代码作为函数调用,而不是阻断热路径。这同时也提示我们不要自作聪明的让所有函数 inline。冷代码对执行速度的阻碍比函数调用要多得多。

不好的代码布局

 
  1. // clang-format off
  2. void foo() {
  3. // clang-format off
  4. hotblock1:
  5. Stmts; // <-- 热!
  6. if (/* 边界条件不成立 */ true)
  7. goto hotblock2; // 经常发生! ------+
  8. coldblock: /* | */
  9. Stmt; // <- 冷 |
  10. Stmt; // <- 冷 |
  11. Stmt; // <- 冷 | 跨越了大量指令,代价高昂!
  12. Stmt; // <- 冷 |
  13. Stmt; // <- 冷 |
  14. Stmt; // <- 冷 |
  15. Stmt; // <- 冷 |
  16. hotblock2: /* | */
  17. Stmts; // <- 热! <----------+
  18. }

好的代码布局

 
  1. // clang-format off
  2. void foo() {
  3. hotblock1:
  4. Stmts; // <-- 热!
  5. if (/* 边界条件 */ false)
  6. codeBlock(); // 将冷代码分离出,使得热路径对 cache 更友好
  7. hotblock2:
  8. Stmts; // <- 热!
  9. }
  10. void coldBlock() {
  11. Stmt; // <- 冷
  12. Stmt; // <- 冷
  13. Stmt; // <- 冷
  14. Stmt; // <- 冷
  15. Stmt; // <- 冷
  16. Stmt; // <- 冷
  17. Stmt; // <- 冷
  18. }

冷热代码分离,其实就是函数内联 (Function Inlining) 的反向操作,这一优化的存在启示我们,函数内联不一定会让程序跑的更快。甚至如果内联代码是冷代码,反而会让程序跑的更慢!一些编译器存在强制内联的编译选项,但不推荐使用。编译器内部有一个静态分析过程,计算每个基本块、分支的概率,以及一个函数调用相关的代价模型,以此决定是否内联,自己决定是否内联不一定比编译器的决策好。

事实上,在没有额外信息的情况下,编译器通常会假设分支跳转与不跳转的概率一致,以此为依据传播各个控制流路径的冷热程度。PGO (Profile Guided Optimization) 的一部分便是通过若干次性能测试与实验得出真正环境下的程序分支概率,这些信息可以让代码布局更加优秀。

函数内联 (Function Inlining)

函数调用通常需要寄存器和栈传递参数,调用者 (caller) 和被调用者 (callee) 都需要保存一定的寄存器状态,这个过程通常被叫做调用约定 (calling convention)。一个函数调用因此会引起一些时间损耗,而内联函数就是指将函数直接写在调用方过程中,不进行真正的函数调用。

 
  1. int add(int x) { return x + 1; }
  2. int foo() {
  3. int a = 1;
  4. a = add(a);
  5. }

add() 可以被内联到 foo() 当中:

 
  1. int foo() {
  2. int a = 1;
  3. a = a + 1; // <-- add() 的函数体,未经过传参
  4. }
always_inline,__force_inline

Attributes in Clang — Clang 17.0.0git documentation

一些编译器提供了手动内联函数调用的方法,在函数前加 __attribute__((always_inline))。这样使用不一定会比函数调用快,编译器在这个时候相信程序员有足够好的判断能力。

尾调用优化 (Tail Call Optimization)

当一个函数调用位于函数体尾部的位置时,这种函数调用被成为尾调用 (Tail Call)。对于这种特殊形式的调用,可以进行一些特别的优化。绝大多数体系结构拥有 Frame Pointer (a.k.a FP) 和 Stack Pointer (a.k.a SP),维护者函数的调用帧 (Frame),而如果调用位于函数尾部,则我们可以不保留外层函数的调用记录,直接用内层函数取代。

用跳转指令代替函数调用

函数调用在绝大多数体系结构下,需要保存当前程序计数器 $pc 的位置,保存若干 caller saved register,以便回到现场。而尾调用不需要此过程,将被直接翻译为跳转指令,因为尾递归永远不会返回到函数运行的位置。

一个简单的例子:Compiler Explorer

 
  1. int test(int a);
  2. int tailCall(int x) { return test(x); }
 
  1. tailCall(int): ; @tailCall(int)
  2. jmp test(int)@PLT ; TAILCALL
自动尾递归改写

如果一个函数的尾调用是自身,则此函数是尾递归的。广义来讲,间接递归(由两个函数 以上共同形成递归)形成递归,且都是尾调用的,也属于尾递归的范畴。尾递归可以被编译器优化为非递归的形式,减小额外的栈开销和函数调用代价。许多算法竞赛选手热衷于写非递归的代码,在不开优化下这样可以极大优化代码的常数,然而如果开优化,递归代码生成的二进制质量和手写的代码没有什么区别。

 
  1. int fac(int n) {
  2. if (n < 2) return 1;
  3. return /* 使用 */ n * fac(n - 1); /* 使用了变量 n ,无法直接做尾递归优化!*/
  4. }

注意到这个函数并不是尾递归的,但可以改写为:

 
  1. int fac(int acc, int n) {
  2. if (n < 2) return acc;
  3. return fac(acc * n, n - 1);
  4. }

新的代码即是尾递归的。

现代编译器可以自动帮你完成这个过程,如果你的代码有机会被改写为尾递归,则编译器可以识别出这种形式,然后完成改写。

尾递归消除 -Rpass=tailcallelim

既然函数已经尾递归,那就可以直接删除递归语句,通过一定的静态分析,将函数直接转换为非递归的形式。我们此处并不去深究编译器作者如何做到这一点,从实际体验来看,绝大多数 OI 代码,如果存在递归版本和非递归版本,则此代码一般可自动优化为非递归版本。这里给读者一些具体的例子:

GCD

 
int gcd(int a, int b) { return b ? gcd(b, a % b) : a; }

斐波那契数列

 
  1. // 展开 fib(n - 2) 这一项
  2. // fib(n - 1) 不能变换为非递归,优化后的代码依然是指数级别的
  3. int fib(int n) {
  4. if (n < 2) return 1;
  5. return fib(n - 1) + fib(n - 2);
  6. }

阶乘

 
  1. // 展开成标量循环,然后执行自动向量化,生成的代码是 SIMD 的
  2. unsigned fac(unsigned n) {
  3. if (n < 2) return 1;
  4. return n * fac(n - 1);
  5. }

这些函数被优化后的汇编和非递归版完全相同,递归将被直接消除。对于 OI 选手而言,可以在开 O2 的情况下放心写递归版本的各种算法,和非递归版不会有什么区别。如果你写的函数本身无法被改写成非递归的形式,那么编译器也无能为力。

强度削减 (Strength Reduction)

常见的编译优化。最简单的例子是 x * 2 变为 x << 1,第二种写法在 OI 中相当常见。编译器会自动做类似的优化,在打开优化开关的情况下,x * 2x << 1 是完全等价的。强度削减 (Strength Reduction) 将高开销的指令转换为低开销的指令。

标量运算符变换
位运算代替乘法
 
  1. int a;
  2. a = x * 2; // bad!
  3. a = x << 1; // good!

需要注意的是有符号数和无符号数在移位 (shifting) 和类型提升 (promotion) 层面有明显的差异。符号位在移位时有着特别的处理,包括算术移位和逻辑移位两种类型。这在编写二分查找/线段树等含有大量除二操作的时候表现突出,有符号整数除法不能直接优化为一步右移位运算。

 
  1. int l, r;
  2. /* codes */
  3. int mid = (l + r) / 2; /* 如果编译器不能假定 l, r 非负,则会生成较差的代码 */
  4. // 不能优化为
  5. // mid = (l + r) >> 1
  6. // 反例:
  7. // mid = -127
  8. // mid / 2 = -63
  9. // mid >> 1 = -64
 
  1. int mid = (l + r);
  2. int sign = mid >> 31; /* 逻辑右移, 得到符号位 */
  3. mid += sign;
  4. mid >>= 1; /* 算术右移 */

可行的解决方案:

  • unsigned l, r;,下标本来就应该是无符号的
  • 在源代码中使用移位
乘法代替除法
 
int x = a / 3;

此过程可以被变换为 x = a * 0x55555556 >> 32,具体可以看 这篇知乎回答 或者 原始论文

索引变量强度削减 (IndVars)

编译器自动识别出循环中的索引变量,并将相关的高开销过程转换为低开销

 
  1. int a = 0;
  2. for (int i = 1; i < 10; i++) {
  3. int a;
  4. a = 2 * i; // bad!
  5. a = a + 2; // good!
  6. }

此处如果直接使用 a = 2 * i 在 OI 中很常见,而编译器可以自动分析出,等价的变换为 a = a + 2,用代价更低的加法代替乘法。分析循环变量的迭代过程,被称为 SCEV (Scalar Evolution)。

SCEV 还可以做到优化一些循环:

 
  1. int test() {
  2. int ans = 1;
  3. for (int i = 0; i < n; i++) {
  4. ans = i * (i + 1);
  5. }
  6. return ans
  7. }

此函数会被优化为 公式求和,参考 Compiler Explorer。这个行为目前仅有基于 LLVM 的编译器会出现,GCC 编译器更加保守。

 
  1. test(int): ; @test(int)
  2. lea ecx, [rdi - 1]
  3. imul ecx, edi
  4. test edi, edi
  5. mov eax, 1
  6. cmovg eax, ecx
  7. ret

自动向量化 (Auto-Vectorization)

单指令流多数据流是很好的提供单核并行的方法。使用这种指令,可以利用 CPU 的 SIMD 寄存器,比通用寄存器更宽,例如一次放 4 个整数然后计算。OI 选手不需要了解自动向量化的细节,通常而言,Clang 编译器会做比 GCC 更激进的自动向量化:

 
  1. // https://godbolt.org/z/h1hx5sWoE
  2. void test(int *a, int *b, int n) {
  3. for (int i = 0; i < n; i++) {
  4. a[i] += b[i];
  5. }
  6. }
__restrict type specifier (GNU, MSVC)

两个任意指针对应的区域可能出现重叠 (overlap),此时需要特判是否可以使用向量代码。下图展示了一个指针重叠的例子:

__restrict 作为一种约定使编译器假定两个指针所指向的内存区域永远不会重叠。

 
  1. void test(int* __restrict a, int* __restrict b, int n) {
  2. for (int i = 0; i < n; i++) {
  3. a[i] += b[i];
  4. }
  5. }

__restrict 并非 C++ 标准的一部分,但各大编译器都可以使用。此关键字影响自动向量化的代码生成质量,极端卡常的情况下可以使用。

和编译优化相关的常见语言误用

inline - 内联

函数内联在开 O2 的情况下通常由编译器自动完成。结构体定义中的 inline 完全是多余的,如果准备的比赛开 O2 优化,则完全不必声明为内联。如果不开 O2 则使用 inline 也不会让编译器真正内联。

inline 关键字在现代 C++ 被当作是一种链接、与导出符号的语义行为,而不是做函数内联。

register - 虚假的寄存器建议

现代编译器会直接忽略你的 register 关键字,你自己认为的寄存器分配一般没有编译器直接跑寄存器分配算法来的聪明。此关键字于 C++11 被弃用,于 C++17 被删除1

C++ keyword: register - cppreference.com

Sanitizer

理智保证器。在运行时检查你的程序是否有未定义行为、数组越界、空指针,等等功能。 在本地调试模式下,建议开启一些 sanitizer,可以极大缩短你的 Debug 时间。这些 sanitizer 由 Google 开发,绝大多数可以在 GCC 和 Clang 中使用。sanitizer 在 LLVM 中更加成熟,因此推荐选手本地使用 Clang 编译器进行相关除错。

Address Sanitizer -fsanitize=address

AddressSanitizer — Clang 17.0.0git documentation

GCC 和 Clang 都支持这个 Sanitizer。包括如下检查项:

  • 越界
  • 释放后使用 (use-after-free)
  • 返回后使用 (use-after-return)
  • 重复释放 (double-free)
  • 内存泄漏 (memory-leaks)
  • 离开作用域后使用 (use-after-scope)

应用这项检查会让你的程序慢 2x 左右。

Undefined Behavior Sanitizer -fsanitize=undefined

UndefinedBehaviorSanitizer — Clang 17.0.0git documentation

Undefined Behavior Sanitizer (a.k.a UBSan) 用于检查代码中的未定义行为。GCC 和 Clang 都支持这个 Sanitizer。自动检查你的程序有无未定义行为。UBSan 的检查项目包括:

  • 位运算溢出,例如 32 位整数左移 72 位
  • 有符号整数溢出
  • 浮点数转换到整数数据溢出

UBSan 的检查项可选,对程序的影响参考提供的网页地址。

杂项

Compiler Explorer

在这里观察各个编译器的行为和汇编代码:Compiler Explorer

参考资料与注释


  1. Remove Deprecated Use of the register Keyword (open-std.org) 

C++ 与其他常用语言的区别

本文介绍 C++ 与其他常用语言的区别,重点介绍 C 与 C++ 之间重要的或者容易忽略的区别。尽管 C++ 几乎是 C 的超集,C/C++ 代码混用一般也没什么问题,但是了解 C/C++ 间比较重要的区别可以避免碰到一些奇怪的 bug。如果你是以 C 为主力语言的 OIer,那么本文也能让你更顺利地上手 C++。C++ 相比 C 增加的独特特性可以阅读 C++ 进阶 部分的教程。此外,本文也简要介绍了 Python, Java 和 C++ 的区别。

C 与 C++ 的区别

宏与模板

C++ 的模板在设计之初的一个用途就是用来替换宏定义。学会模板编程是从 C 迈向 C++ 的重要一步。模板不同于宏的文字替换,在编译时会得到更全面的编译器检查,便于编写更健全的代码。模板特性在 C++11 后支持了可变长度的模板参数表,可以用来替代 C 中的可变长度函数并保证类型安全。

指针与引用

C++ 中你仍然可以使用 C 风格的指针,但是对于变量传递而言,更推荐使用 C++ 的 引用 特性来实现类似的功能。由于引用指向的对象不能为空,因此可以避免一些空地址访问的问题。不过指针由于其灵活性,也仍然有其用武之地。值得一提的是,C 中的 NULL 空指针在 C++11 起有类型安全的替代品 nullptr。引用和指针之间可以通过 * 和 & 运算符 相互转换。

bool

与 C++ 不同的是,C 语言最初并没有布尔类型。

C99 标准加入了 _Bool 关键字(以及等效的 bool 宏)以及 truefalse 两个宏。如果需要使用 booltruefalse 这三个宏,需要在程序中引入 stdbool.h 头文件。而使用 _Bool 则不需要引入任何额外头文件。

 
  1. bool x = true; // 需要引入 stdbool.h
  2. _Bool x = 1; // 不需要引入 stdbool.h

C23 起,truefalse 成为 C 语言中的关键字,使用它们不需要再引入 stdbool.h 头文件2

下表展示了 C 语言不同标准下,bool 类型支持的变化情况(作为对照,加入了 C++ 的支持情况):

语言标准booltrue/false_Bool
C89//保留3
C99 起,C23 以前宏,与 _Bool 等价,需要 stdbool.h 头文件宏,true1 等价,false0 等价,需要 stdbool.h 头文件关键字
C23 起宏,与 _Bool 等价,需要 stdbool.h 头文件关键字关键字
C++关键字关键字保留3

struct

尽管在 C 和 C++ 中都有 struct 的概念,但是他们对应的东西是不能混用的!C 中的 struct 用来描述一种固定的内存组织结构,而 C++ 中的 struct 就是一种类,它与类唯一的区别就是它的成员和继承行为默认是 public 的,而一般类的默认成员是 private 的。这一点在写 C/C++ 混合代码时尤其致命。

另外,声明 struct 时 C++ 也不需要像 C 那么繁琐,C 版本:

 
  1. typedef struct Node_t {
  2. struct Node_t *next;
  3. int key;
  4. } Node;

C++ 版本

 
  1. struct Node {
  2. Node *next;
  3. int key;
  4. };

const

const 在 C 中只有限定变量不能修改的功能,而在 C++ 中,由于大量新特性的出现,const 也被赋予的更多用法。C 中的 const 在 C++ 中的继任者是 constexpr,而 C++ 中的 const 的用法请参见 常值 页面的说明。

内存分配

C++ 中新增了 newdelete 关键字用来在「自由存储区」上分配空间,这个自由存储区可以是堆也可以是静态存储区,他们是为了配合「类」而出现的。其中 delete[] 还能够直接释放动态数组的内存,非常方便。newdelete 关键字会调用类型的构造函数和析构函数,相比 C 中的 malloc()realloc()free() 函数,他们对类型有更完善的支持,但是效率不如 C 中的这些函数。

简而言之,如果你需要动态分配内存的对象是基础类型或他们的数组,那么你可以使用 malloc() 进行更高效的内存分配;但如果你新建的对象是非基础的类型,那么建议使用 new 以获得安全性检查。值得注意的是尽管 newmalloc() 都是返回指针,但是 new 出来的指针 只能delete 回收,而 malloc() 出来的指针也只能用 free() 回收,否则会有内存泄漏的风险。

变量声明

C99 前,C 的变量声明必须位于语句块开头,C++ 和 C99 后无此限制。

可变长数组

C99 后 C 语言支持 VLA(可变长数组),C++ 始终不支持。

结构体初始化

C99 后 C 语言支持结构体的 指派符初始化(但是在 C11 中为可选特性),C++ 直到 C++20 才支持有顺序要求的指派符初始化,且 C 语言支持的乱序、嵌套、与普通初始化器混用、数组的指派符初始化特性 C++ 都不支持1

注释语法

C++ 风格单行注释 //,C 于 C99 前不支持。

Python 与 C++ 的区别

Python 是目前机器学习界最常用的语言。相比于 C++,Python 的优势在于易于学习,易于实践。Python 有着更加简单直接的语法,比如在定义变量时,不需要提前声明变量类型。但是,这样的简单也是有代价的。Python 相比于 C++ 牺牲了性能。C++ 几乎适用于包括嵌入式系统的所有平台,并且有着更快的执行速度,但是 Python 只可以在某些支持高级语言的平台上使用。C++ 更接近底层,所以可以用来进行编写操作系统。

Java 与 C++ 的区别

Java 与 C++ 都是面向对象的语言,都使用了面向对象的思想(封装、继承、多态),由于面向对象由许多非常好的特性(继承、组合等),因此二者有很好的可重用性。所以相比于 Python,Java 和 C++ 更加类似。

二者最大的区别在于 Java 有 JVM 的机制。JVM 全称是 Java Virtual Machine,中文意为 Java 虚拟机。Java 语言的一个非常重要的特点就是与平台的无关性。而使用 Java 虚拟机是实现这一特点的关键。一般的高级语言如果要在不同的平台上运行,至少需要编译成不同的目标代码。而引入 Java 语言虚拟机后,Java 语言在不同平台上运行时不需要重新编译。Java 语言使用 Java 虚拟机屏蔽了与具体平台相关的信息,使得 Java 语言编译程序只需生成在 Java 虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。

因为这个特点,Java 经常被用于需要移植到不同平台程序的开发。但是也由于编译 Java 程序时需要从字节码开始,所以 Java 的性能没有 C++ 好。

Pascal 转 C++ 急救

用来急救,不多废话。

药方食用提示

本急救贴可以让您充分了解以下内容(对应 C++ 语法快速提要):

  • 基本语法(块语句、注释、导入库、简单输入输出、声明变量、赋值……)
  • C++ 的 Hello World 与 A+B Problem 写法与解释

    对应语法 部分较为紧凑,正式食用可能需要额外参考资料(已给出)。此部分不包括指针与 C 风格数组的介绍,也没有结构体、运算符重载等等。

    重要不同之处 部分为 C++ 的语法特点,也是 Pascal 转 C++ 时会碰到的坑。

如要快速查找,请见附录:

C++ 快速安装与环境配置

注意:这里假设使用的系统是 Windows。

方式一:使用 IDE

以下 IDE 选择一个即可:

方式二:使用 代码编辑器 + 编译器 + 调试器

  • VS Code

    Visual Studio Code 官方网站上有文档解释如何进行 C++ 的配置。一般而言 VS Code 搭配插件使用更方便,见 VS Code 的官方网站

    C++ 语法快速提要 Start Here

C++ 程序都是从 main 这个部分开始运行的。

大括号表示块语句的开始与结束: { 就相当于 Pascal 里面的 begin ,而 } 就相当于 end

注意,和 Pascal 一样,C++ 每句话结束要加分号 ; ,不过大括号结尾不需要有分号,而且程序结束末尾不用打句号 .

// 表示行内注释, /* */ 表示块注释。

按照惯例,看看 Hello World 吧。

Hello World:第一个 C++ 程序

 
  1. #include <iostream> // 导入 iostream 库
  2. int main() // main 部分
  3. {
  4. std::cout << "Hello World!" << std::endl;
  5. return 0;
  6. }

然后编译运行一下,看看结果。

简要解释

第一行, #include <iostream> 的意思是,导入 iostream 这个库。

Pascal 的库文件

Pascal 其实是有库文件的,只不过,很多同学从来都没有用过……

看到第三行的 main 吗?程序从 main 开始执行。

接下来最重要的一句话是

 
std::cout << "Hello World!" << std::endl;

std::cout 是输出( cout 即 C-out)的命令。你可能看过有些 C++ 程序中直接写的是 cout

有关 std:: 前缀

有关 std:: 这个前缀的问题,请见 这节 底下的注释「什么是 std?」。

中间的 << 很形象地表示流动,其实它就是表示输出怎么「流动」的。这句代码的意思就是, "Hello World!" 会先被推到输出流,之后 std::endl 再被推到输出流。

std::endl输出 换行( endl 即 end-line)命令,这与 Pascal 的 writeln 类似,不过 C++ 里面可没有 coutln 。Pascal 与 C++ 的区别在于, write('Hello World!') 等价于 std::cout << "Hello World!" ,而 writeln('Hello World!') 等价于 std::cout << "Hello World!" << std::endl

此处 "Hello World!" 是字符串,Pascal 中字符串都是用单引号 ' 不能用双引号,而 C++ 的字符串必须用双引号。C++ 中单引号包围的字符会有别的含义,后面会再提及的。

好了,到这里 Hello World 应该解释的差不多了。

可能有同学会问,后面那个 return 0 是什么意思?那个 int main() 是啥意思? 先别管它 ,一开始写程序的时候先把它当作模板来写吧(这里也是用模板写的)。因为入门时并不会用到 main 中参数,所以不需要写成 int main(int argc, char const *argv[])

简单练习
  1. 试着换个字符串输出。
  2. 试着了解转义字符。

A+B Problem:第二个 C++ 程序

经典的 A+B Problem。

 
  1. #include <iostream>
  2. int main() {
  3. int a, b, c;
  4. std::cin >> a >> b;
  5. c = a + b;
  6. std::cout << c << std::endl;
  7. return 0;
  8. }

注:代码空行较多,若不习惯可去掉空行。

简要解释

std::cin 是读入( cin 即 C-in), >> 也与输出语法的类似。

这里多出来的语句中最重要的是两个,一个是变量声明语句

 
int a, b, c;

你可能习惯于 Pascal 里面的声明变量

 
  1. var
  2. a, b, c: integer;

C++ 的声明是直接以数据类型名开头的,在这里, int (整型)开头表示接下来要声明变量。

接着一个最重要的语句就是赋值语句

 
c = a + b;

这是 Pascal 与 C++ 语法较大的不同, 这是个坑 :Pascal 是 := ,C++ 是 = ;而 C++ 判断相等是 ==

C++ 也可直接在声明时进行变量初始化赋值

 
int a = 0, b = 0, c = 0;
简单练习
  1. 重写一遍代码,提交到 OJ 上,并且 AC。
  2. 更多的输入输出语法参考 这节内容 ,并试着了解 C++ 的格式化输出。

结束语与下一步

好了,到现在为止,你已经掌握了一些最基本的东西了,剩下就是找 Pascal 和 C++ 里面对应的语法和不同的特征。

不过在此之前,强烈建议先看 变量作用域:全局变量与局部变量 ,也可使用 附 B:文章检索 查阅阅读。

请善用Alt+←与Alt+→返回跳转。

对应语法 Syntax

变量 Variable

基本数据类型 Fundamental types

C++ 与 Pascal 基本上差不多,常见的有

  • bool Boolean 类型
  • int 整型
  • 浮点型
    • float
    • double
  • char 字符型
  • void 无类型

C++ 的单引号是专门用于表示单个字符的(字符型),比如 'a' ,而字符串(字符型数组)必须要用双引号。

C++ 还要很多额外的数据类型,请参考更多资料。

扩展阅读:

常量声明 Constant
 
const double PI = 3.1415926;

若不清楚有关宏展开的问题,建议使用常量,而不用宏定义。

运算符 Operator

请直接参考

条件

if 语句
 
  1. if (a = b) and (a > 0) and (b > 0) then
  2. begin
  3. b := a;
  4. end
  5. else
  6. begin
  7. a := b;
  8. end;
 
  1. if (a == b && a > 0 && b > 0) {
  2. b = a;
  3. } else {
  4. a = b;
  5. }

布尔运算与比较

  • and -> &&
  • or -> ||
  • not -> !
  • = -> ==
  • <> -> !=

注释:

  1. Pascal 中 and 与 C++ 中 && 优先级不同,C++ 不需要给判断条件加括号。
  2. Pascal 中判断相等是 = ,赋值是 := ;C++ 中判断相等是 == ,赋值是 =
  3. 如果在 if 语句的括号内写了 a = b 而不是 a == b ,程序不会报错,而且会把 b 赋值给 aa = b 这个语句的返回结果是 true
  4. C++ 不需要思考到底要不要在 end 后面加分号。
  5. C++ 布尔运算中,非布尔值可以自动转化为布尔值。

易错提醒

特别注意: 不要把 == 写成 =

由于 C/C++ 比 Pascal 语法灵活,如果在判断语句中写了 if (a=b) { ,那么程序会顺利运行下去,因为 C++ 中 a=b 是有返回值的。

caseswitch

用到得不多,此处不详细展开。

需要注意:C++ 没有 1..n ,也没有连续不等式(比如 1 < x < 2 )。

循环 Loop

以下三种循环、六份代码实现的功能是一样的。

while 循环

while 很相似。(C++ 此处并非完整程序,省略一些框架模板,后同)

 
  1. var i: integer;
  2. begin
  3. i := 1;
  4. while i <= 10 do
  5. begin
  6. write(i,' ');
  7. inc(i); // 或者 i := i + 1;
  8. end;
  9. end.
 
  1. int i = 1;
  2. while (i <= 10) {
  3. std::cout << i << " ";
  4. i++;
  5. }
for 循环

C++ 的 for 语句非常不同。

 
  1. var i: integer;
  2. begin
  3. for i:= 1 to 10 do
  4. begin
  5. write(i, ' ');
  6. end;
  7. end.
 
  1. for (int i = 1; i <= 10; i++) {
  2. std::cout << i << " ";
  3. }

注释:

  1. for (int i = 1; i <= 10; i++){ 这一行语句很多, for 中有三个语句。
  2. 第一个语句 int i = 1; 此时声明一局部变量 i 并初始化。(这个设计比 Pascal 要合理得多。)
  3. 第二个语句 i <= 10; 作为判断循环是否继续的标准。
  4. 第三个语句 i++ ,在每次循环结尾执行,意思大约就是 Pascal 中的 inc(i) ,此处写成 ++i 也是一样的。 i++++i 的区别请参考其他资料。
repeat untildo while 循环

注意, repeat untildo while 是不同的,请对比以下代码

 
  1. var i: integer;
  2. begin
  3. i := 1;
  4. repeat
  5. write(i, ' ');
  6. inc(i);
  7. until i = 11;
  8. end.
 
  1. int i = 1;
  2. do {
  3. std::cout << i << " ";
  4. i++;
  5. } while (i <= 10);
循环控制 Loop Control

C++ 中 break 的作用与 Pascal 是一样的,退出循环。

continue 也是一样的,跳过当前循环,进入下一次循环(回到开头)。

数组与字符串 Array and String

不定长数组:标准库类型 Vector

C++ 标准库中提供了 vector ,相当于不定长数组,调用前需导入库文件。

 
  1. #include <iostream>
  2. #include <vector> // 导入 vector 库
  3. int main() {
  4. std::vector<int> a; // 声明 vector a 并定义 a 为空 vector 对象
  5. int n;
  6. std::cin >> n;
  7. // 读取 a
  8. for (int i = 0; i < n; i++) {
  9. int t;
  10. std::cin >> t;
  11. a.push_back(t); // 将读入的数字 t,放到 vector a 的末尾;该操作复杂度 O(1)
  12. /* 这里不能使用下标访问来赋值,因为声明时,a 大小依然为空,
  13. 此处使用 `a[i] = t;` 是错误做法。
  14. */
  15. }
  16. // 将读入到 a 中的所有数打印出
  17. for (int i = 0; i < n; i++) {
  18. std::cout << a[i] << ", "; // !注意,a 中第一个数是 a[0];
  19. // 如果下标越界,它会返回一个未知的值(溢出),而不会报错
  20. }
  21. std::cout << std::endl;
  22. return 0;
  23. }

C++ 访问数组成员,与 Pascal 类似,不过有很重要的区别:数组的第一项是 a[0] ,而 Pascal 中是可以自行指定的。

扩展阅读:

字符串:标准库类型 String

C++ 标准库中提供了 string ,与 vector 可以进行的操作有些相同,同样需要导入库文件。

 
  1. #include <iostream>
  2. #include <string>
  3. int main() {
  4. std::string s; // 声明 string s
  5. std::cin >> s; // 读入 s;
  6. // 读入时会忽略开头所有空格符(空格、换行符、制表符),读入的字串直到下一个空格符为止。
  7. std::cout << s << std::endl;
  8. return 0;
  9. }

扩展阅读:

C 风格数组 Array

如果要用不定长的数组请用 Vector,不要用 C 风格的数组。

C 风格的数组与指针有密切关系,所以此处不多展开。

扩展阅读:

重要不同之处 Differences

变量作用域 Scope:全局变量与局部变量

C++ 几乎可以在 任何地方 声明变量。

以下对于 C++ 的变量作用域的介绍摘自 变量作用域 - OI Wiki

作用域是变量可以发挥作用的代码块。

变量分为全局变量和局部变量。在所有函数外部声明的变量,称为全局变量。在函数或一个代码块内部声明的变量,称为局部变量。

全局变量的作用域是整个文件,全局变量一旦声明,在整个程序中都是可用的。

局部变量的作用域是声明语句所在的代码块,局部变量只能被函数内部或者代码块内部的语句使用。

由一对大括号括起来的若干语句构成一个代码块。

 
  1. int g = 20; // 声明全局变量
  2. int main() {
  3. int g = 10; // 声明局部变量
  4. printf("%d\n", g); // 输出 g
  5. return 0;
  6. }

在一个代码块中,局部变量会覆盖掉同名的全局变量,比如上面的代码输出的 g 就是 10 而不是 20 。为了防止出现意料之外的错误,请尽量避免局部变量与全局变量重名的情况。

在写 Pascal 过程/函数时,容易忘记声明局部变量 i 或者 j ,而一般主程序里会有循环,于是大部分情况下 ij 都是全局变量,于是,在这种情况下,过程/函数中对 i 操作极易出错。更要命的是,如果忘记声明这种局部变量,编译器编译不报错,程序可以运行。(有很多难找的 bug 就是这么来的。)

所以,在使用 C++ 时,声明变量,比如循环中使用的 i不要用全局变量,能用局部变量就用局部变量 。如果这么做,不用担心函数中变量名(比如 i )冲突。

额外注

C++ 可以自动转换类型

 
  1. int i = 2;
  2. if (i) { // i = 0 会返回 false,其余返回 true
  3. std::cout << "true";
  4. } else {
  5. std::cout << "false";
  6. }

不光是 int 转成 bool ,还有 intfloat 相互转换。在 Pascal 中可以把整型赋给浮点型,但不能反过来。C++ 没有这个问题。

 
  1. int a;
  2. a = 3.2; // 此时 a = 3
  3. float b = a; // 此时 b = 3.0

区分 / 是整除还是浮点除法,是通过除数与被除数的类型判断的

 
  1. float a = 32 / 10; // 32/10 的结果是 3(整除);a = 3.0
  2. float b = 32.0 / 10; // 32.0/10 的结果是 3.2;b = 3.2

pow(a, b) 计算 ,该函数返回的是浮点型,如果直接用来计算整数的幂,由于有自动转换,不需要担心它会报错

 
int a = pow(2, 3);  // 计算 2^3

还有 charint 之间相互转换。

 
  1. char a = 48; // ASCII 48'0'
  2. int b = a + 1; // b = 49
  3. std::cout << (a == '0'); // true 输出 1

其实 C++ 中的 charbool 本质上是整型。

扩展阅读:

C++ 很多语句有返回值:以如何实现读取数量不定数据为例

有些时候需要读取到数据结束,比如,求一组不定数量的数之和(数据可以多行),直到文件末尾,实现方式是

文件末尾 EOF

 
  1. #include <iostream>
  2. int main() {
  3. int sum = 0, a = 0;
  4. while (std::cin >> a) {
  5. sum += a;
  6. }
  7. std::cout << sum << std::endl;
  8. return 0;
  9. }

实现原理: while (std::cin >> a)std::cin >> a 若在输入有问题或遇到文件结尾时,会返回 false,使得循环中断。

函数 Function:C++ 只有函数没有过程但有 void ,没有函数值变量但有 return

Pascal 函数与 C++ 函数对比示例:

 
  1. function abs(x:integer):integer;
  2. begin
  3. if x < 0 then
  4. begin
  5. abs := -x;
  6. end
  7. else
  8. begin
  9. abs := x;
  10. end;
  11. end;
 
  1. int abs(int x) {
  2. if (x < 0) {
  3. return -x;
  4. } else {
  5. return x;
  6. }
  7. }

C++ 中函数声明 int abs ,就定义了 abs() 函数且返回值为 int 型(整型),函数的返回值就是 return 语句给出的值。

如果不想有返回值(即 Pascal 的「过程」),就用 voidvoid 即「空」,什么都不返回。

 
  1. var ans: integer;
  2. procedure printAns(ans:integer);
  3. begin
  4. writeln(ans);
  5. end;
  6. begin
  7. ans := 10;
  8. printAns(ans);
  9. end.
 
  1. #include <iostream>
  2. void printAns(int ans) {
  3. std::cout << ans << std::endl;
  4. return;
  5. }
  6. int main() {
  7. int ans = 10;
  8. printAns(ans);
  9. return 0;
  10. }

C++ 的 return 与 Pascal 中给函数变量赋值有一点非常大的不同。C++ 的 return 即返回一个值,执行完这个语句,函数就执行结束了;而 Pascal 中给函数变量赋值并不会跳出函数本身,而是继续执行。于是,如果 Pascal 需要某处中断函数/过程,就需要一个额外的命令,即 exit 。而 C++ 则不需要,如果需要在某处中断,可以直接使用 return 。比如(由于实在想不出来简短且实用的代码,所以就先这样)

 
  1. #include <iostream>
  2. void printWarning(int x) {
  3. if (x >= 0) {
  4. return; // 该语句在此处相当于 Pascal 中的 `exit;`
  5. }
  6. std::cout << "Warning: input a negative number.";
  7. }
  8. int main() {
  9. int a;
  10. std::cin >> a;
  11. printWarning(a);
  12. return 0;
  13. }

而在某种意义上,前面的 abs 函数,这样才是严格等效的

 
  1. function abs(x:integer):integer;
  2. begin
  3. if x < 0 then
  4. begin
  5. abs := -x; exit; // !注意此处
  6. end
  7. else
  8. begin
  9. abs := x; exit; // !注意此处
  10. end;
  11. end;
 
  1. int abs(int x) {
  2. if (x < 0) {
  3. return -x;
  4. } else {
  5. return x;
  6. }
  7. }

特别提醒

C++ 中 exit 是退出程序;不要顺手把 exit 打上去,要用 return

C++ 把函数和过程统统视作函数,连 main 都不放过,比如写 int main ,C++ 视 main 为一个整型的函数,这里返回值是 0 。它是一种习惯约定,返回 0 代表程序正常退出。

也许你已经猜到了, main(int argc, char const *argv[]) 中的参数就是 int argcchar const *argv[] ,不过意义请参考其他资料。

在函数中传递参数 Passing Parameters to Functions

C++ 中没有 Pascal 的 var 关键字可以改变传递的参数,但是 C++ 可以使用引用和指针达到同样的效果。

 
  1. var a, b: integer;
  2. procedure swap(var x,y:integer);
  3. var temp:integer;
  4. begin
  5. temp := x;
  6. x := y;
  7. y := temp;
  8. end;
  9. begin
  10. a := 10; b:= 20;
  11. swap(a, b);
  12. writeln(a, ' ', b);
  13. end.
 
  1. // 使用指针的代码
  2. #include <iostream>
  3. void swap(int* x, int* y) {
  4. int temp;
  5. temp = *x;
  6. *x = *y;
  7. *y = temp;
  8. }
  9. int main() {
  10. int a = 10, b = 20;
  11. swap(&a, &b);
  12. std::cout << a << " " << b;
  13. return 0;
  14. }

注意,此处 C++ 代码 涉及指针问题 。指针问题还是很麻烦的,建议去阅读相关资料。

 
  1. // 使用引用的代码
  2. #include <iostream>
  3. void swap(int& x, int& y) {
  4. int temp;
  5. temp = x;
  6. x = y;
  7. y = temp;
  8. }
  9. int main(int argc, char const* argv[]) {
  10. int a = 10, b = 20;
  11. swap(a, b);
  12. std::cout << a << " " << b;
  13. return 0;
  14. }

注意,此处 C++ 代码涉及 引用相关类型问题 。在用引用调用一些 STL 库、模板库的时候可能会遇到一些问题,这时候需要手动声明别类型。具体资料可以在《C++ Primer》第五版或者网络资料中自行查阅。

C++ 中函数传递参数还有其他方法,其中一种是 直接使用全局变量传递参数 ,如果不会用指针,可以先用这种方法。但是这种方法的缺陷是没有栈保存数据, 没有办法在递归函数中传参 。(除非手写栈,注意,手写栈也是一种突破系统栈限制的方法。)

C++ 标准库与参考资料 Reference

千万不要重复造轮子(除非为了练习),想要自己动手写一个功能出来之前,先去看看有没有这个函数或者数据结构。

C++ 标准库

C++ 标准库中 <algorithm> 有很多有用的函数比如快排、二分查找等,可以直接调用。请参考这个页面: STL 算法 - OI Wiki

还有 STL 容器,比如数组、向量(可变大小的数组)、队列、栈等,附带很多函数。请参考这个页面: STL 容器简介 - OI Wiki

如果要找关于字符串操作的函数见

C/C++ 的指针是很灵活的东西,如果想要彻底理解指针,建议找本书或者参考手册仔细阅读。

错误排查与技巧

C++ 语言资料

后记

写到这里,很多同学会觉得这一点都不急救啊,有很多东西没有提到啊。那也是没办法的事情。

虽然是为了急救,但很多东西像怎么把字符串转化为数字,怎么搜索字符串中的字符,这些东西也不适合一篇精悍短小的急救帖,如果把这些都写出来,那就是 C++ 入门教程,所以请充分利用本 Wiki、参考手册与搜索引擎。

需要指出的一点是,上面说 C++ 的语法,其实有很多语法是从 C 语言来的,标题这么写比较好——《Pascal 转 C/C++ 急救帖》。

Pascal 在上个世纪后半叶是门很流行的语言,它早于 C 语言,不过随着 UNIX 系统的普及,微软使用 C 语言,现在 Pascal 已经成为历史了。Pascal 后期发展也是有的,比如 Free Pascal 这个开源编译器项目,增加面向对象的特性(Delphi 语言)。Pascal 目前的用处除了在信息竞赛外,有一个特点是其他语言没有的——编译支持非常非常多老旧机器,比如 Gameboy 这种上个世纪的任天堂游戏机,还有一个用处就是以伪代码的形式(Pascal 风格的伪代码)出现在各种教科书中。

最后,Pascal 的圈子其实很小,C/C++ 的圈子很大,帮助手册与教程很多很全,一定要掌握好英语。世界上还有很多很多编程语言,而计算机这门学科与技术不光是信息竞赛和编程语言。

本文 Pascal 语言的参考文献

附 A:Pascal 与 C++ 运算符与数学函数语法对比表 Pascal vs C++ Operator Syntax Table

仅包括最常用的运算符与函数。

基本算术

PascalC++
加法a + ba + b
减法a - ba - b
乘法a * ba * b
整除a div ba / b
浮点除法a / ba / b
取模a mod ba % b

逻辑

PascalC++
not(a)!a
a and ba && b
a or ba || b

比较

PascalC++
相等a = ba == b
不等a <> ba != b
大于a > ba > b
小于a < ba < b
大于等于a >= ba >= b
小于等于a <= ba <= b

赋值

PascalC++
a := ba = b
a := a + ba += b
a := a - ba -= b
a := a * ba *= b
a := a div ba := a / ba /= b
a := a mod ba %= b

自增/自减

PascalC++
自增inc(a)a++
自增inc(a)++a
自减dec(a)a--
自减dec(a)--a

数学函数

使用需要导入 <cmath> 库。

PascalC++
绝对值abs(a)abs(a) (整数)
绝对值abs(a)fabs(a) (浮点数)
N/A (*)pow(a, b)
截断取整trunc(a)trunc(a)
近似取整round(a)round(a)

*Extended Pascal 中有 a**b 不过需要导入 Math 库。

其他函数请参考:

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/小小林熬夜学编程/article/detail/567072
推荐阅读
相关标签
  

闽ICP备14008679号