C++:从Type到Control

一、基本数据类型

        计算机的存储空间由最基本的二进制数(比特bit)组成,若干连续的二进制位(一般为8位)组成一个字节byte并被分配一个内存地址(Memeory \ address),所以单独的比特没有地址,通常情况下CPU也不会一个比特一个比特读取数据,相反,字节被当作基本操作单位。在此前提下,一切要存储在计算机上的数据必须经由一定的协议格式,将其正确编码为二进制序列,同时又能解码为原始的数据。在程序中,充当这种翻译官作用的协议称之为数据类型(data \ type)。

       Type, which both restricts the operations that are permitted for those entities and provides semantic meaning to the otherwise generic sequences of bits.
        类型(数据类型)既限制了程序实体允许的操作,又为其比特序列提供了语义含义。

        在程序中,最小的不可分割的数据类型,称之为基础数据类型(fundamental data types),也称之为基本类型(basic \ type),原始类型(primitive \ types)或者内置类型(built-in \ types)。

        C++的类型体系兼容了C的类型体系,所以,对于C的过来者,你将十分熟悉下面的列表:

源自: C++ 数据类型 | 菜鸟教程 (runoob.com)

 其中

typedef short int wchar_t;

        后缀\_t表明这是一种类型,你会在C++中的其它地方见到它。 

(一)sizeof 与固定整数

1、sizeof 运算符

        基本数据类型的大小在C++中正如在C中一样依赖于系统、编译器及其它,所以使用sizeof而不是直觉通常是一个好的编程实践。

        sizeof 是一个在 C++ 中非常有用的运算符,语法与C如出一辙,用于获取操作数所占用的字节数。其返回值类型是 size_t ,这是一个无符号整数类型,它的大小也可以通过sizeof得到。

        8字节似乎是C++所能创建对象的极限,但事实上并不会这么多。

2、固定整数

        对于在位数的特殊需求的编程场景,在 C 中我们有 stdint.h ,在 C++ 库中对应于 cstdint。这些固定整数类型的使用使得我们在编程时能够更加明确和精确地控制整数的大小和范围。

类型描述
int8_t8位带符号整数
int16_t16位带符号整数
int32_t32位带符号整数
int64_t64位带符号整数
uint8_t8位无符号整数
uint16_t16位无符号整数
uint32_t32位无符号整数
uint64_t64位无符号整数
类型描述
int_least8_t至少8位带符号整数
int_least16_t至少16位带符号整数
int_least32_t至少32位带符号整数
int_least64_t至少64位带符号整数
uint_least8_t至少8位无符号整数
uint_least16_t至少16位无符号整数
uint_least32_t至少32位无符号整数
uint_least64_t至少64位无符号整数
类型描述
int_fast8_t至少8位最快带符号整数
int_fast16_t至少16位最快带符号整数
int_fast32_t至少32位最快带符号整数
int_fast64_t至少64位最快带符号整数
uint_fast8_t至少8位最快无符号整数
uint_fast16_t至少16位最快无符号整数
uint_fast32_t至少32位最快无符号整数
uint_fast64_t至少64位最快无符号整数
类型描述
intmax_t最大带符号整数类型
uintmax_t最大无符号整数类型
类型描述
intptr_t能够容纳任何指针值的带符号整数类型
uintptr_t能够容纳任何指针值的无符号整数类型

(二)整数

1、进制表示

        同样的,在C++中支持在C中的整数进制表示形式:

    int decimal = 1234567890;
    int octal = 012345670;
    int hex = 0x12345670;
    int binary = 0b00010010001101000101011001111000;

        但是这里稍微提一下如何使用std::cout输出指定进制的整数,以下为简要表格

格式操作符(manipulator)适用类型使用说明
指定进制dec(默认)、oct、hex整型在输出对象前面添加,注意,这些操作符也叫做IO操作符,是流标记,将对后续的适用类型生效。后面介绍的一系列流标记也是如此。
 

显示进制基数

noshowbase(默认)showbase

整型
将数值形式里面存在的字母转为大写

nouppercase(默认)uppercase

整型、浮点型
在正数前面添加+

noshowpos(默认)showpos

十进制形式的整型、浮点型
std::cout << "Decimal: " << std::dec << 128 << std::endl;
std::cout << "Octal: " << std::oct << 128 << std::endl;
std::cout << "Hexadecimal: " << std::hex << 128 << std::endl;

 

std::cout << "Decimal: " << std::dec << std::showbase << 128 << std::endl;
std::cout << "Octal: "  << std::showbase << std::oct << 128 << std::endl;
std::cout << "Hexadecimal: " <<  std::showbase << std::hex << 128 << std::endl;

 

std::cout << "Hexadecimal: " <<  std::showbase << std::uppercase << std::hex << 128 << std::endl;

 

C++也支持二进制形式输出(使用bitset库),以下简要说明:

std::cout << "Binary: " << std::bitset<8> (128) << std::endl;
std::cout << "Binary: " << std::bitset<8> (64) << std::endl;

 

        

3、整型的溢出

        C++整型默认是有符号的,这点和C一样,有符号整数可以表示正数、负数和零,与之对应的无符号整型则只能表示非负整数,即 0 和正数。这两者都具有需求场景,但是也存在陷阱。

        有符号整数在溢出时,会根据其符号位进行环绕处理。例如,当一个有符号整数达到其最大值再增加时,会变为最小值;达到最小值再减小时,会变为最大值。

    short max  = 32767;
    short min = -32768;
    std::cout << "Size of int in bytes: " << sizeof(short) << std::endl;
    std::cout << "max value of short = " << max << std::endl;
    std::cout << "max + 1 = " << (short)(max + 1) << std::endl;
    std::cout << "max + 2 = " << (short)(max + 2) << std::endl;
    std::cout << "min value of short = " << min << std::endl;
    std::cout << "min - 1 = " << (short)(min - 1) << std::endl;
    std::cout << "min - 2 = " << (short)(min - 2) << std::endl;

 

        而无符号整数在溢出时,会从最大值直接回绕到最小值重新开始计数。

    unsigned short max_u  = 65535;
    unsigned short min_u = 0;
    std::cout << "Size of unsigned int in bytes: " << sizeof(unsigned short) << std::endl;
    std::cout << "max value of unsigned short = " << max_u << std::endl;
    std::cout << "max + 1 = " << (unsigned short)(max_u + 1) << std::endl;
    std::cout << "max + 2 = " << (unsigned short)(max_u + 2) << std::endl;
    std::cout << "min value of unsigned short = " << min_u << std::endl;
    std::cout << "min - 1 = " << (unsigned short)(min_u - 1) << std::endl;
    std::cout << "min - 2 = " << (unsigned short)(min_u - 2) << std::endl;

 

在此基础上,有符号整型和无符号整型混用,可能产生意想不到的结果 

    short max_short  = -1;
    unsigned int uint = 10;
    bool result = uint > max_short;
    std::cout << "result: " << std::boolalpha << result << std::endl;

 

(三)浮点数

知识准备

格式操作符说明
控制浮点数的显示格式

scientific(科学计数法格式)

fixed(固定计数法格式)

hexfloat(十六进制浮点数格式)

defaultfloat(重置浮点数显示格式)

控制浮点数显示小数点showpoint, noshowpoint
控制浮点数显示精度setprecision(int precision)
std::cout << "scientific notation: " << std::scientific << 3.1415926f << std::endl;
std::cout << "Fix notation: " << std::fixed << 3.1415926f << std::endl;
std::cout << "Hex float notation: " << std::hexfloat << 3.1415926f << std::endl;
std::cout << "default notation: " << std::defaultfloat << 3.1415926f << std::endl;

  

std::cout << "Precision 2 : " << std::setprecision(2) << 3.1415f << std::endl;
std::cout << "Precision 6 : " << std::setprecision(6) << 3.1415f << std::endl;

1、截断:浮点数的内存表示     

         浮点数和整数在计算机内存中的表示方式不同,浮点数一般按照如下方式存储数据

精度符号位(Sign bit)指数位(Exponent bits)尾数位(Mantissa bits)
单精度1823
双精度11152

        计算公式为

        数值 = (-1)^S × 1.M × 2^(E - bias)

        假设我们要表示十进制数 5.0 为单精度浮点数:

  1. 转换为二进制5.0 的二进制形式是 101.0
  2. 标准化:将二进制数规范化为 1.01 × 2^2
  3. 计算指数:指数为2,加上偏移值127得到129,即二进制形式为 1000 0001
  4. 尾数:尾数为 01,省略整数部分的1,只保留后面的二进制位,
  5. 符号位:由于5.0是正数,符号位为0。

        最终,5.0 在单精度浮点数中的二进制表示为:

0 10000001 01000000000000000000000

         设计一个程序验证一下:

#include <iostream>
#include <iomanip>
#include <cstdint>
#include <bitset>

union FloatBits {
    float value;
    std::uint32_t raw;
};

int main() {


    union FloatBits fbits = {.value = 5.0f};

    std::cout << "Float bits: " << std::bitset<32>(fbits.raw) << std::endl;
    // 0 1000'0001 01000000000000000000000

    return 0;
}

         到目前为止,一切都还好,但是,我们还没有考虑到小数部分,首先要将十进制小数转换为二进制,可以使用以下步骤:

  1. 将小数部分乘以2,将得到的整数部分作为二进制的第一位。
  2. 将得到的小数部分再次乘以2,将得到的整数部分作为二进制的第二位。
  3. 重复上述步骤,直到小数部分为0或达到所需的精度。
  4. 将得到的二进制数与整数部分的二进制数相连,得到最终的二进制表示。

比如,将十进制小数0.625转换为二进制:

0.625 * 2 = 1.25,整数部分为1,二进制第一位为1。

0.25 * 2 = 0.5,整数部分为0,二进制第二位为0。

0.5 * 2 = 1.0,整数部分为1,二进制第三位为1。

所以,0.625的二进制表示为0.101。

        但是,如果是0.3,你就会发现,我们无法将其正确转换为二进制,我们最终会因为尾数位数的限制而被截断(truncated),这导致了浮点数的不精确问题:

        不过,这也可能是true,在Clion里面运行结果如下,这取决于运行环境。

2、科学计数法:人性的设计

        写一个冗长的数字是十分苦恼的,因为你不得不小心谨慎,睁大眼睛确保不会少一个0,多一个点:

float num2 = 1200000000.00012F;

float num3 = 112.129831731231;

        所幸,C++(C)给我们提供了在数学中常用中工具科学计数法(scientific notation) ,       用法简略如下

number * 10^n \Rightarrow number\textbf{e}n

        科学计数法在 C++ 中为表示较大或较小的浮点数提供了便利。例如

unsigned long long x = 2e10; // 20000000000

        但是如果我就要写一串数字,并且这个数字不太好表示为科学计数法怎么办,就像上面的num3,也没有关系,C++提供了数字分隔符('),它不会对原来的数字产生任何影响,但是方便程序阅读。

float num3 = 112.129'831'731'231;
int num4 = 1'0000'0000'0000;
     

3、数的边缘情况:NaN 与 Inf

        考虑下面的情况,显然,这在纯粹的C语言中会报错,编译器无法处理

float num4 = 0.0 / 0.0;
float num5 = 1.0 / 0.0;
std::cout << num4 << " " << num5 << std::endl;

        然而,在C++中,编译器将优雅处理,你将会看到如下输出:

       

        这代表着浮点数的两种特殊类别。第一个是NaN,代表“不是数字(Not a Number)”。

        第二个是Inf,代表无穷大。 Inf 可以是正值,也可以是负值。

        仅当编译器对浮点数使用特定格式 (IEEE 754) 时,NaN 和 Inf 才可用。如果使用其他格式,代码会产生未定义的行为。

        尽管这很酷,因为你再也不是看到冰冷的除零错误而导致的程序中断,但是避免这样的运算情况仍然十分提倡。

(四)字符类型

1、字符类型:signed ?

       根据C语言的参考文档,signed \ charunsigned \ charchar是不兼容的(虽然笔者并没有看到不兼容的现象),尽管C++中不存在兼容

        Note: C++ has no concept of compatible types. A C program that declares two types that are compatible but not identical in different translation units is not a valid C++ program.

        这里明确指出 C++的语言体系中没有“兼容类型”这一说法。而对于 C 程序,如果在不同的翻译单元里声明了虽然兼容但不完全一样的类型,这样的 C 程序在 C++的规则下是不被认可为有效的。

        并且

         signed char and unsigned char are narrow character types, but they are not character types. In other words, the set of narrow character types is not a subset of the set of character types.

        有符号字符和无符号字符是窄字符类型,但它们不是字符类型。换句话说,窄字符类型的集合不是字符类型集合的子集。

        换句话说,具体的实现可能和标准有些出入,但是char类型的符号性在不同的编译器中可能有所不同,有的编译器将其视为有符号的(普遍),有的则视为无符号的,这一点可能需要注意。

        在大部分情况下,你可能只需要单纯地认为char = signed \ char

2、转义字符:特殊的符号

        转义字符,在诸多编程语言都存在,下面是一些常见的转义字符及其对应的含义。

  • \n:换行符
  • \t:制表符(跳到下一个制表符位置)
  • \':单引号
  • \":双引号
  • \\:反斜杠
  • \b:退格键(删除前一个字符)
  • \r:回车(光标移到行首)
  • \f:换页(将光标移到下一页开头)
  • \v:垂直制表符(跳到下一个垂直制表符位置)
  • \a:响铃声
  • \0:空字符

3、其它字符类型

        除了常见的char类型,C++还提供了其他字符类型,如wchar_t、char8_t、char16_t和char32_t。

        wchar_t通常用于表示宽字符,能够处理更广泛的字符集,如 Unicode 字符。但在不同的编译器和操作系统中,其大小和表示方式可能会有所不同。

        char8_t是 C++20 引入的新类型,用于表示 8 位的字符。

        char16_t和char32_t则分别用于表示 16 位和 32 位的字符,常用于处理 Unicode 字符。在使用这些类型时,需要考虑到字符编码和转换的问题,以确保正确处理各种字符数据。

(五)布尔类型

1、从 0 开始

        在 C++中,布尔类型使用bool关键字表示,其值只有true和false。在C语言中,可能还要使用库支持,不过C23有所引进

 

        当默认初始化一个布尔类型变量时,其值为false。

        为了输入输出布尔类型的变量,可以使用std::boolalphastd::noboolalpha

#include <iostream>

int main() {

    bool is_true = true;
    std::cout << "The value of is_true is " << std::boolalpha << is_true << std::endl;
    std::cout << "Input a boolean value: " << std::endl;
    bool input_value;
    std::cin >> std::boolalpha >> input_value;
    std::cout << "The value of input_value is " << std::boolalpha << input_value << std::endl;
    return 0;
}

        不过,应当注意,一旦开始std::boolalpha输入,只有输入true才为真,否则为假

2、True or False

        在C中,我们经常将非0作为真,0作为假,尤其是在像是if语句这样的判断条件中,在C++中,这同样适用,但是,既然已经存在了布尔类型,那么就应该存在相互转换,否则,布尔类型就太过独立了。而转换的规则和前面说的是差不多的。

布尔类型到其他类型的转换

当布尔类型转换为其他数值类型时,true 通常会被转换为 1,而 false 会被转换为 0

bool b = true;
int i = b;  // i 的值现在为 1

其他类型到布尔类型的转换

当其他类型转换为布尔类型时,转换规则如下:

  • 整数类型:任何非零值都会被转换为 true,零值会被转换为 false
  • 浮点数类型:任何非零值都会被转换为 true,零值会被转换为 false
  • 指针类型:非空指针会被转换为 true,空指针会被转换为 false

除此之外,由于C++的类型体系比C更加庞大复杂,一些复杂的类型需要特殊的处理才能转换为布尔类型。

二、常量与字符串

(一)定义一个常量

常量是指在程序执行过程中其值不会改变的量。C++中有两种主要类型的常量:

  1. 命名常量(Named const)(也称为符号常量symbolic const或常量变量const variable):这些常量与标识符关联,可以通过标识符引用其值。
  2. 字面量(Literal constants):这些常量直接出现在源代码中,没有与特定的标识符关联。

1、命名常量

命名常量通常使用const关键字声明。例如:

const int PI {314159}; // 这是一个命名常量

        这里需要注意的是,虽然PI被称为“常量”,但它实际上是常量变量,因为它具有一个名称(即标识符)。使用const关键字修饰的变量意味着一旦初始化后(必须初始化),其值就不能再被改变。

        const可以放在类型后面,这并不会影响什么,取决于个人。通常,在很多项目中,我们会倾向于将常量名设置为全大写,当然,这也取决于个人。

2、字面量

        字面量是直接写入源代码中的值,例如:

100 13.14 'a'

        它们通常通常用于赋值使用,虽然没有与标识符相关联,但是它们仍然具有类型

字面值示例默认类型
整数值5, 0, -3int
布尔值true, falsebool
浮点数值1.2, 0.0, 3.4double (not float!)
字符‘a’, ‘\n’char
C字符串“Hello, world!”const char[14]

        也可以通过添加后缀来指定字面量的具体类型,例如:

后缀意义
u 或 Uunsigned int
l 或 Llong
ul, uL, Ul, UL, lu, lU, Lu, LUunsigned long
ll 或 LLlong long
ull, uLL, Ull, ULL, llu, llU, LLu, LLUunsigned long long
z 或 ZThe signed version of std::size_t (C++23)
uz, uZ, Uz, UZ, zu, zU, Zu, ZUstd::size_t (C++23)
f 或 Ffloat
l 或 Llong double

        以上后缀都对大小写不做要求,但是考虑到程序可读性,仍然建议采用大写。

(二)关于优化那点事

        设想这样一个场景,你在分配地址内存,大概是下面这样,显然,不用注释,就已然知道地址的划分情况

const unsigned int base = 0x1000'0000;

const unsigned int group_A = base + 0x1000'0000 * 0;
const unsigned int group_B = base + 0x1000'0000 * 1;
const unsigned int group_C = base + 0x1000'0000 * 2;
const unsigned int group_D = base + 0x1000'0000 * 3;

        再者,对于一个值变量x,需要经过下面的转换,才能使用 

const \ int \ y = ( x * 10 ) / 2 + 30;

        显然,它们可以再化简,这样能减少代码量,也可以提高运行效率,但是由于某些原因——可读性,或者单纯很累——不想改了,它们被确定为这样。

        所以,是否能够在源码到二进制程序之间的转换中添加优化,使得鱼和熊掌兼得?当然是可以的,这种优化称为编译时评估(compile-time evaluation),是编译器优化代码的一部分;        

        Compile-time evaluation allows the compiler to do work at compile-time that would otherwise be done at runtime.

        编译时评估允许编译器在编译时完成原本在运行时完成的工作。

        这可能会导致编译时间更加漫长,但是相比于每次运行时都计算一个确定的值,在编译期就确定好值,这无疑会优化运行的效率。

1、常量表达式

        A constant expression is an expression that contains only compile-time constants and operators/functions that support compile-time evaluation.

        常量表达式是只包含编译期常量以及支持编译时评估的操作符或函数。

        换句话说,常量表达式的任何部分都可以在编译期确定。但是常量表达式并不是常量即可,这里需要说明一下,首先说明一下编译期变量

compile-time constant

        必须在编译时确定的值 ,以下说明三种

类型示例
字面量1、3.14、‘a’
constexpr声明的变量constexpr int a {1}; 
使用常量表达式初始化的int(包括char)类型变量const int a {1};
    char a {'a'};          // a 不是常量, 不是编译期常量
    int b {10};            // b 不是常量, 不是编译期常量
    const char c {'c'};    // c 是编译期常量, 使用字面量初始化
    const int d {10};      // d 是编译期常量, 使用字面量初始化

    const char e {a};       // c 不是编译期常量, 使用变量初始化
    const int f {b};        // d 不是编译期常量, 使用变量初始化
    const char g {c};       // g 是编译期常量, 使用常量表达式初始化
    const int h {d};        // h 是编译期常量, 使用常量表达式初始化

        为了更好确定,可以使用C++提供的\textbf{constexpr}(const \ expression),将上面的const替换掉,不是编译期常量的就会报错,所以常量不一定是编译期常量,不是编译期常量的常量也可称之为运行时常量(runtime constant)。

2、constexpr关键字

        先分清一下const变量和constexpr变量

        const means that the value of an object cannot be changed after initialization. The value of the initializer may be known at compile-time or runtime. The const object can be evaluated at runtime.

        “const”意味着对象的值在初始化后不能更改。初始化器的值可以在编译时或运行时已知。const 对象可以在运行时进行求值。
        constexpr means that the object can be used in a constant expression. The value of the initializer must be known at compile-time. The constexpr object can be evaluated at runtime or compile-time.

        “constexpr”意味着对象可以用于常量表达式。初始化器的值必须在编译时已知。constexpr 对象可以在运行时或编译时进行求值。

         所以constexpr很大的作用在于帮助程序员明确表达式是否可以进行编译期评估。所以,下面进一步展开

        常量表达式尽量使用字面量与constexpr变量的组合

constexpr int a { 10 };
constexpr float b { 20.0f };
constexpr int c { a + 2};

        也可以使用函数,不过函数应当使用constexpr修饰,类似如下

constexpr int getMax(int a, int b){
    return a > b ? a : b;
}

constexpr int d {getMax(a, b)};

        如果存在参数,则实参应当是编译期常量。关于编译期函数的优化,有很多值得讨论的方面,这留到后面。

 3、优化时机

        尽管常量表达式可以在编译期优化,但是实际上是否优化取决于是否需要优化,换句话说在编译期进行求值。

        显然,需要常量表达式的地方,需要编译期评估,比如常量的值,数组的大小,它的大小应当在编译期确定(求值)

const a {2 + 3};   // 常量的值应当在编译期确定
int array[1 + 2]; // 数组的大小应当在编译期间确定

         除此之外的地方,尽管是常量表达式也不一定会编译期评估,比如

int a {2 + 3};

         这完全可以运行时求值,尽管可能运行效率不高。所以编译期的优化是一件不太确定的事,一方面取决于编译期本身的实现,另一方面取决于编译的选项(gcc\g++可以选择编译优化等级)。

(三)C字符串与std::string

        在C语言中,字符串本质上是const \ char[]类型,并且以'\setminus 0'作为终止符,这存在两个问题,字符串常量的本质,导致其不能被修改,可是在C语言中允许

char * string = "Hello world!";

        一个char *类型的变量,会让人认为是可以修改的,当这样做时 

        不会收到编译错误,但是,你会运行失败,因为非法修改了常量

        这个bug在复杂一点的程序可以相当隐蔽,甚至,可能一扫而过,也不会发现问题。另一方面由于单纯使用'\setminus 0'作为字符串的结束标志,这在很大程度上会依赖于程序员,比如

        这会导致内存越界,在严重一点,就是内存泄漏。鉴于此,C++不允许 

        并且,推荐使用更加安全、现代化的std::string 。

        std::string不是基本数据类型,它是C++标准库中用于表示和处理字符串的类。关于类,后面介绍。对于小白,现在可以完全将其当作C字符串数组的升级版。

        使用std::string时,可能需要包含string头文件。

        字符串的创建和初始化相当简单

std::string s1;             // 创建一个空字符串
std::string s2("Hello");    // 使用C风格字符串初始化
std::string s3 = "World";   // 使用赋值操作符初始化
std::string s4(s3);         // 使用已有的字符串初始化

        字符串的访问和修改:支持C风格的操作,也可以使用函数at()来实现。 例如:

std::string s = "Hello";
std::cout << s[0] << std::endl; // 输出'H'
std::cout << s.at(1) << std::endl; // 输出'e'
s[0] = 'h';
std::cout << s << std::endl; // 输出"hello"

        除此之外,std::string不再需要额外的函数来操作字符串,例如

std::string s1 = "Hello";
std::string s2 = "World";
std::string s3 = s1 + " " + s2; // 拼接两个字符串
std::string s4 = s1.append(s2); // 连接两个字符串

std::string s5 = "Hello World";
size_t pos = s5.find("World");
if (pos != std::string::npos) {
    std::cout << "Found at position: " << pos << std::endl;
}

s5.replace(pos, 5, "C++");
std::cout << s5 << std::endl; // 输出"Hello C++"

std::string s6 = "Hello";
std::cout << s6.length() << std::endl; // 输出5
std::cout << s6.size() << std::endl; // 输出5

std::string s7 = "Hello";
std::string s8 = "World";
if (s7 < s8) {
    std::cout << "s1 is less than s2" << std::endl;
}

三、操作符

        由于C++的大部分运算符在C中都有,并且用法几乎一样,所以下面,快速回顾

(一)算术运算符

  • 加法(+):用于将两个数相加。
  • 减法(-):用于从一个数中减去另一个数。
  • 乘法(*):用于将两个数相乘。
  • 除法(/):用于将两个数相除。当运算对象都是整数时,结果也是整数(向下取整);如果至少有一个运算对象是浮点数,则结果也是浮点数。
  • 取模(%):用于计算两个数相除后的余数。参与运算的对象必须是整数类型。

注意事项

  • 除法运算时,如果运算对象的符号不同,则商为负值。在C++11新标准下,商一律向0取整(即舍弃掉小数部分)。
  • 早期C++允许m%n的符号匹配n的符号,但新标准下禁止了这种行为,如果正常的话,结果符号以m为准。

(二)赋值运算符

  • 简单赋值(=):用于将右侧表达式的值赋给左侧的变量。
  • 复合赋值(如+=、-=、*=、/=、%=):先执行算术运算,然后将结果赋给左侧的变量。

注意事项

  • 赋值运算符满足右结合律,即先计算右边对象或表达式的值。
  • 使用复合赋值运算符时,左侧变量的值只计算一次,而直接使用算术运算符后再赋值时,左侧变量的值会被计算两次。

(三)比较运算符

  • 等于(==):检查两个值是否相等。
  • 不等于(!=):检查两个值是否不相等。
  • 大于(>)小于(<)大于等于(>=)小于等于(<=):用于比较两个值的大小。

注意事项

  • 比较运算符的结果为布尔值(true或false)。
  • 当参与比较的对象是浮点数时,由于浮点数的精度问题,应谨慎使用等于(==)运算符进行比较。

(四)逻辑运算符

  • 逻辑与(&&):用于检查多个条件是否同时为真。
  • 逻辑或(||):用于检查多个条件是否至少有一个为真。
  • 逻辑非(!):用于取反一个条件。

注意事项

  • 逻辑运算符的优先级低于算术运算符和比较运算符。
  • 逻辑与和逻辑或运算符具有短路特性,即当能够确定整个表达式的值时,将不再计算剩余的表达式部分。
  • 优先级!> || > &&

(五)位运算符

  • 位与(&)位或(|)位异或(^)位取反(~)左移(<<)右移(>>):这些运算符用于对整数类型的二进制表示进行操作。

注意事项

  • 位运算符只能用于整数类型的运算对象。
  • 在进行移位操作时,右侧运算对象的值不能为负数,且其值必须小于左侧运算对象类型的位数。

(六)逗号运算符

        逗号也是运算符,不过可能是存在感最低的运算符了,优先级最低,在通常情况下,逗号都是作为分隔符的,所以如果真的要用的话,可能许多人会有点犯错误。

        逗号运算符的基本语法如下:

expression1, expression2, ..., expressionN

        其中,expression1 到 expressionN 是要按顺序执行的多个表达式,它们可以是任意合法的C++表达式,包括函数调用、赋值操作等。

        由于,逗号运算符的优先级最低,所以如果要赋值,应该这样,而不是

int a = (1, 3, 4);
// Error: int a = 1, 3, 4;

(七)三目运算符

        三目运算符(也称为条件运算符)是C++(以及许多其他编程语言)中用于根据条件选择两个值之一的一种简洁方式。它的基本语法是:

condition ? exprIfTrue : exprIfFalse

        如果condition为真(非零),则表达式的结果为exprIfTrue的值;如果condition为假(零),则表达式的结果为exprIfFalse的值。

注意事项:

  • 嵌套深度:虽然理论上可以无限嵌套三目运算符,但出于可读性和可维护性的考虑,应避免过深的嵌套。一般来说,超过两层或三层的嵌套就应该考虑使用其他结构来替代。

  • 短路行为:与if-else语句类似,三目运算符也具有短路行为。即,如果condition为真,则不会评估exprIfFalse;如果为假,则不会评估exprIfTrue。这可以用于在评估可能产生副作用的表达式时避免不必要的计算。

  • 类型兼容exprIfTrueexprIfFalse的类型应该兼容,或者至少应该能够隐式转换为一个共同的类型,以便赋值给整个表达式的结果变量。如果类型不兼容且无法隐式转换,则编译器将报错。

四、类型

(一)类型转换

        类型转换是指将一个数据类型的值转换为另一种数据类型的值,主要分为隐式类型转换(Implicit Conversion)和显式类型转换(Explicit Conversion)两大类。在C语言中,也涉及到,但是C++的类型转换要更加复杂,毕竟要匹配更加庞大的类型体系。

1、隐式类型转换

        编译器自动进行的类型转换,通常是为了更高效率的运算,根据场景有以下划分:

        类型提升(promotion)

        考虑以下的运算,计算机将如何处理

char a = 'a';
int  b = a ;

        charint的数据类型不同,更简单的说,它们的位数不同,显然这对计算机的运算是有挑战的,对于b,它的值通常可以很轻易的取得,但是a却是1字节,计算机需要更多操作,因为这不是计算机最佳的操作单位,为此C++将a进行类型提升,转成int类型,这种转换将保留其原值,所以叫做保值转换\textbf{value-preserving conversion}   ,也叫做安全转换safe \ conversion          这种转换经常发生在函数调用中,只要能够类型提升为形参的类型,就能够成功调用函数,这无疑会减少代码的冗余量,除此之外,也存在浮点型的类型提升,这里不多赘述。

        数值转换(numeric conversions)

        数值转换是不同数据类型赋值中发生的类型转换(这包括上面的类型提升)。

        如果,目标是更窄的数据类型,就会丢失数据信息。这称为窄化转换(narrowing conversions)。所以在进行窄化转换时,编译器会发出警告,以提示潜在的问题。

        主要分为以下情况 

  • 从浮点类型到整型。
  • 从浮点类型到更窄或级别更低的浮点类型,除非被转换的值是 constexpr 且在目标类型的范围内(即使目标类型没有精度来存储该数字的所有有效数字)。
  • 从整型到浮点类型,除非被转换的值是 constexpr 且其值可以准确地存储在目标类型中。
  • 从一种整型到另一种不能表示原始类型所有值的整型,除非被转换的值是 constexpr 且其值可以准确地存储在目标类型中。这涵盖了从较宽到较窄的整型转换,以及整型符号转换(有符号到无符号,反之亦然)。

        算术转换(arithmetic conversions)

        当一个表达式中包含不同类型的数值时,需要进行算术转换来确定结果的类型。算术转换会自动将一些类型转换为更适合的类型,以便进行计算。例如,如果一个表达式中包含一个整数和一个浮点数,整数会被自动转换为浮点数,以便进行精确的计算。

        我们可以使用typeid运算符验证,这可能需要包含<typeinfo>

std::cout << typeid(1 + 2.0).name() << std::endl; // double
std::cout << typeid(1 + 2).name() << std::endl; // int

2、显式类型转换

        考虑下面的场景,如果不出意外,结果将是2,可是如果我们想要结果为2.5

std::cout << 5 / 2 << std::endl;

       我们需要这样

std::cout << 5 / 2.0 << std::endl;

        可是如果,这样呢

int a = 5;
int b = 2;
std::cout << a / b << std::endl;

        我们可能就需要改变数据类型了, 这显然很麻烦,所以C/C++有显式类型转换(也可叫强制类型转换),这是一种主动的类型转换。        

int a = 5;
int b = 2;
std::cout << (float)a / (float )b << std::endl;

        这是C语言强制类型转换的唯一方法,很方便,但是有一些隐患,比如上面的代码也可以这样写        

int a = 5;
int b = 2;
std::cout << float(a) / float(b) << std::endl;

        太自由的语法往往可能导致代码的可读性变差,同时C的强制语法简单,几乎万能,对于复杂一点的数据类型,编译器不太会检查出错误,即使它们不可转换,比如下面的程序,编译正常

#include <iostream>

typedef int (*func)(int, int);

int main() {

    int a = 10;
    int * p = &a;
    void * ptr = (void *)p;

    std::cout << "The result of add(2, 3) is " << ((func)ptr)(2, 3) << std::endl;

    return 0;
}

         所以在C++中,将C语言的强制类型转换划分为四种强制类型转换,称之为命名强转Named cast),这里先介绍一种,基本用法像是这样

int a = 5;
int b = 2;
std::cout << static_cast<float>(a) / static_cast<float>(b) << std::endl;

         它看起来很冗长,但是,对于上面的错误,将在扼杀在摇篮之中,因为static\_cast对于类型的转换更加严格,同时它也适应C++的类型体系,后面再深入了解。

(二)别名

        别名,也称为类型别名,是为现有类型定义的一个新名字。如果读者用过复杂的类型组合,想必会领悟到它的魅力,在C++中,同样可以使用typedef关键字定义类型别名。

typedef int Integer; 
Integer a = 5; // 使用别名定义变量

  typedef可以为复杂的类型声明定义一个简洁的名字,使代码更加清晰。但仅仅是这样还不够,C++为我们提供了另一个类型命名的利器——using,可能从名字,看不出它的作用,试着将上面的代码转换为使用using,可能才有这么几分明了,可是就这样吗?这可能不如typedef直观。

using Integer = int; 
Integer b = 10; // 使用别名定义变量

        可如果,在之前的程序上添加这样一条语句

using namespace std;

        将不用写std::这样可能有点烦人的前缀,又如何呢?而这不是using的全部,向C++更深处发掘,它的魅力才真正体现。 

(三)类型推断

        类型推断是编译器自动推导出变量或表达式的类型的能力。C++11引入了auto关键字来实现类型推断,比如

auto x = 5;             // x的类型被推断为int 
auto y = 3.14;          // y的类型被推断为double 
const auto z = 'a';     // z的类型被推断为char,将去掉const
auto i = z;             // z 的类型为char,将去掉const

         在这样的场景,可能看不出auto的魅力,甚至它还可能不如想象中好,比如不能

auto Max(auto a, auto a){          // auto 不能用于函数原型

    return a > b ? a : b;
}

auto division(int a, int b) {     // 函数返回值类型不同,会最先推断为int类型
    if(a % b == 0){
        return a / b;
    } else {
        return static_cast<double>(a) / b;
    }
}

        目前,它的作用确实好像不太大,因为这常常结合C++更高级的特性。

        此外,C++14引入了decltype关键字,它并不直接进行类型推断,而是查询表达式的类型。它和auto有点像, 这里先介绍一下:        

int a = 10;
decltype(a) b = a;
decltype( 10) c = 20;
std::cout << "The type of a is " << typeid(a).name() << std::endl;
std::cout << "The type of b is " << typeid(b).name() << std::endl;
std::cout << "The type of c is " << typeid(c).name() << std::endl;

       

五、控制流

        就平心而论,像C语言这样的控制流是相当简单明了的,而简单的事物,它的问题往往来源于组合与创新。

(一)分支

1、匹配问题        

Dangling Else 问题

  Dangling else问题指的是在嵌套if语句中,else子句与哪个if相匹配的问题。C++(和大多数C风格的编程语言)采用“就近匹配”原则来解决这个问题。即else总是与最近的未匹配的if相匹配。

    int a = 10;
    int b = 20;
    if(a <= b)
        if(a == b)
            std::cout << "a and b are equal" << std::endl;
    else 
        std::cout << "b is greater than a" << std::endl;

        

括号问题

       问题的根源来源于,诸如下面的写法

if(condition)
    statement;
else
    statement;

if(condition){
    statement;
}else{
    statment;    
}

        当某一刻,加上一条语句而不记得多加括号,就会出现问题了,对于有些IDE,可能会提醒

2. constexpr if

   如果仔细观察,在上面的截图中,有一处也报错了

        的确,对于这样条件,根本不需要条件语句。但是在一些复杂的程序中,这一点可能会被隐藏,IDE并不会提醒你,在这种情况下,可以这样,简单来说,这将进行编译期优化,进行类似条件编译的操作

    const int a = 10;
    const int b = 20;
    if constexpr (a <= b)
         std::cout << "a and b are equal" << std::endl;
    else
        std::cout << "b is greater than a" << std::endl;

         优化后,可能是

    const int a = 10;
    const int b = 20;
    std::cout << "a and b are equal" << std::endl;

        但是,这看起来很无用,至少这个例子是如此

        For optimization purposes, modern compilers will generally treat non-constexpr if-statements that have constexpr conditionals as if they were constexpr-if-statements. However, they are not required to do so.

        出于优化目的,现代编译器通常会将具有常量表达式条件的非常量表达式 if 语句视为常量表达式 if 语句。然而,它们并非必须这样做。

        A compiler that encounters a non-constexpr if-statement with a constexpr conditional may issue a warning advising you to use if constexpr instead. This will ensure that compile-time evaluation will occur (even if optimizations are disabled).

         遇到具有常量表达式条件的非常量表达式 if 语句的编译器可能会发出警告,建议您使用if constexpr代替。这将确保进行编译时计算(即使优化被禁用)。

(三)选择

   switch语句提供了一种多路分支的能力,允许根据表达式的值选择多个代码块之一来执行。

1、Fallthrough

   由于case不过是标签,在一般情况下,在写switch的每个case时,习惯上应该先写一个break防止后续执行,但是这是个不确定性操作,因为不一定需要如此,比如        

    std::uint32_t month;
    std::cout << "Enter a month (1-12): ";
    std::cin >> month;
    
    int daysInYear {}; // assume every year has 365 days
    
    for (int i = 1; i <= month; i++) {
        switch (i) {
            case 2:
                daysInYear += 28; // February has 28 days in a common year
                break;
            case 4:case 6:case 9:case 11:
                daysInYear += 30; // April, June, September, and November have 30 days
                break;
            case 1: case 3:case 5:case 7:case 8:case 10:case 12:
                daysInYear += 31; // January, March, May, July, August, October, and December have 31 days
                break;
            default:
                break;
        }
    }

        在这种情况下,称之为Fallthrough,指的是在没有显式break语句的情况下,从一个case块“落入”到下一个case块的情况。在C++中,这是合法的,但通常不是期望的行为,因为它可能导致难以发现的错误。C++17引入了[[fallthrough]]属性(作为非标准扩展,后来成为C++20的正式部分),用于明确指示[[fallthrough]]是故意的。

for (int i = 1; i <= month; i++) {
        switch (i) {
            case 2:
                daysInYear += 28; // February has 28 days in a common year
                break;
            case 4: [[fallthrough]]; case 6: [[fallthrough]]; case 9: [[fallthrough]]; case 11:
                daysInYear += 30; // April, June, September, and November have 30 days
                break;
            case 1: [[fallthrough]]; case 3: [[fallthrough]]; case 5: [[fallthrough]]; case 7: [[fallthrough]]; 
            case 8: [[fallthrough]]; case 10: [[fallthrough]]; case 12:
                daysInYear += 31; // January, March, May, July, August, October, and December have 31 days
                break;
            default:
                break;
        }
    }

        这样,代码语义上就更加明了了。 

2、switch区域

        在switch语句中,只有标签下的语句是有效的,不应该在其它地方编写代码

        

        同时在switch语句中,每个case块的作用域并不相互隔离。这意呀着,在一个case块中声明的变量在后续的case块中也是可见的(尽管这可能不是好的编程实践)。

        为了避免这个问题,通常使用大括号来创建局部作用域。

(四)循环与中断

        对于C++的循环,其实和C一样,最怕的当属无限循环,这通常由于没有正确设置结束循环的条件,比如      

while(a = 1){
    //
}

        breakcontinue作为调节循环的语句,应该说语法相当简单,不过需要仔细,比如下面的程序,k是多少

    int i,j,k=0;
    for(i=0;i<3;i++)
    {
        for(j=0;j<4;j++)
        {
            if(j%2)
                continue;
            k++;
        }
        if(i%2)
            break;
    }

                 

(五)跳转

        C++中的跳转语句主要是goto,它允许程序无条件地跳转到同一函数内的另一位置。

        以下是goto语句的基本语法:

goto label;
...
...
label:
statement;

        其中,label是一个标识符,可以在程序中定义。当程序执行到goto语句时,会直接跳转到指定的label,并开始执行label处的语句。

        这其实和switch很像,只不过switch有条件,像是有条件的多goto集合。

        以下是一个使用goto语句的示例:

#include <iostream>
using namespace std;

int main() {
    int num;
    
    cout << "Enter a positive number: ";
    cin >> num;
    
    if (num <= 0) {
        goto error;
    }
    
    cout << "The entered number is: " << num << endl;
    
    return 0;
    
error:
    cout << "Error: Invalid number entered." << endl;
    return 1;
}

        这个示例中,如果用户输入的数小于等于0,程序会直接跳转到error标签,并输出错误信息。否则,会打印输入的数。

        其实这完全可以使用if,程序结构不会这么混乱,因为看到goto后,标签是可以在当前函数的任何地方的,可能需要前面看看或者后面看看,复杂一点的程序,标签不太容易找到,所以为了保持代码的清晰和可读性,应该尽量避免使用它,尽量使用其他控制语句(如if语句、循环语句)来实现相同的功能,除非有需求如下

    for (int i = 0; i < 10; ++i) {
        for (int j = 0; j < 10; ++j) {
            if (i == 5 && j == 5) {
                goto outer_loop;  // 使用标签退出外部循环
            }
            std::cout << "(" << i << ", " << j << ") ";
        }
    }
    outer_loop:;

(六)退出

        在程序执行过程中,通常会出现程序难以为继的情况,或者在某个情况下需要立即退出程序。同时,不想或无法正常通过main函数的结束而结束。比如,一个需要配置文件启动的程序,配置文件读取错误。

        这时就需要能够终止程序的机制,同时,最好能够提供程序终止的状态,以及清理资源,这些在C/C++中,可以通过以下函数实现。

1. exit

exit 函数用于正常终止程序,并返回一个退出状态给操作系统。这个状态通常用于指示程序是否成功执行完毕。

函数原型

#include <cstdlib>
void exit(int status);

参数

  • status:一个整数,表示程序的退出状态。通常 0 表示成功退出,非零值表示异常退出。
#include <iostream>
#include <cstdlib>

int main() {
    if (/* some condition */) {
        std::cout << "Error occurred." << std::endl;
        exit(1);  // 退出程序,返回状态 1
    }
    // 正常程序逻辑
    return 0;
}

2. atexit

atexit 函数用于注册一个函数,该函数将在程序正常终止前被调用。这对于清理资源(如关闭文件、释放内存等)非常有用。

函数原型

#include <cstdlib>
int atexit(void (*function)(void));

参数

  • function:一个指向函数的指针,该函数没有参数也不返回任何值。

返回值

  • 如果成功注册函数,则返回 0
  • 如果失败,则返回非零值。
#include <iostream>
#include <cstdlib>

void cleanup(void) {
    std::cout << "Cleaning up resources." << std::endl;
    // 这里可以添加清理资源的代码
}

int main() {
    if (atexit(cleanup) != 0) {
        std::cerr << "Failed to register cleanup function." << std::endl;
        exit(1);
    }

    // 正常程序逻辑
    std::cout << "Normal program execution." << std::endl;

    return 0;
}

3. abort

abort 函数用于异常终止程序,通常用于不可恢复的错误情况。当调用 abort 时,程序会立即终止,不会执行任何清理操作。

函数原型

#include <cstdlib>
void abort();
1#include <iostream>
2#include <cstdlib>
3
4int main() {
5    if (/* some critical error */) {
6        std::cout << "Critical error occurred." << std::endl;
7        abort();  // 异常终止程序
8    }
9    // 正常程序逻辑
10    return 0;
11}

注意事项

  • 使用 exit 时,可以进行资源清理。
  • 使用 atexit 注册的函数在 exit 之前调用。
  • 使用 abort 时,程序会立即终止,不执行任何清理操作。
  • 在程序中合理使用这些函数可以帮助确保资源得到正确的管理。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值