c/docs/notes/01_c-basic/04_xdx/index.md

54 KiB
Raw Blame History

第一章:变量(

1.1 程序中变化的数据

  • 在生活中,我们使用最多的不是固定的数据,而是会变化的数据:
    • ① 购物车商品的数量价格等。
    • ② 一首歌播放的时间进度条歌词的展示等。
    • ③ 微信聊天中消息条数时间语音的长度头像名称等。
    • ④ 游戏中技能的冷却时间血量蓝量buff 时间金币的数量等。
    • ……
  • 下图是一个购物车变化数据,即:

  • 那么,在实际开发中,我们就会使用变量保存操作这些变化数据

1.2 变量

  • 变量的定义:变量是程序中不可或缺的组成单位,最基本的存储单元。其实,变量就是一个存储数据的临时空间,可以向其中存储不同类型的数据,如:整数、小数、字符、字符串等,并且变量中的数据在程序运行的时候可以动态改变。

Note

  • 变量:用来存储数据容器
  • 数据:可以是一个用来计算的数字,如:上文购物车中的价格等;也可以是一句话中的关键词其它任意格式的数据
  • 变量的特别之处就在于它存放的数据是可以改变的。
  • 我们可以将变量想象为一个容器,盒子中装的就是我们想要的数据,并且我们需要盒子一个特别的名称;通过这个特别的名称,我们可以盒子添加数据移除数据,这个特别的名称就是变量名

Note

  • 变量是内存中的一个存储区域,该区域的数据可以在同一类型范围内不断变化
  • ② 通过变量名,可以操作这块内存区域,向其中存储数据获取数据以及移除数据
  • ③ 变量的构成包含三个要素:数据类型变量名需要存储的数据
  • ④ 在生活中,我们会经常说:这件衣服的价格是 100整型 元,这双鞋子的价格是 250.5(小数,浮点类型) 元,今天天气真好(字符串类型)之类的话;在计算机科学中,这些都是数据,并且它们是有类型,即:数据类型。(数据类型用于定义变量所能存储的数据的种类以及可以对这些数据进行的操作的一种分类,每种数据类型都有特定的属性和用途,它们决定了变量在内存中如何表示和存储,以及变量可以执行哪些操作)

1.3 变量的声明和使用

  • ① 变量必须先声明,后使用。
  • ② 可以先声明变量再赋值,也可以在声明变量的同时进行赋值。
  • ③ 变量的值可以在同一类型范围内不断变化。

Note

  • ① 在实际开发中,我们通常都会在声明变量的同时,给其赋值,这被称为初始化。
  • ② 如果不在声明变量的同时,进行初始化,默认情况下,系统会赋予的随机值,我们也称为垃圾值。
  • ③ 其实,变量既可以声明在 main() 函数的外面,称为全局变量;也可以声明在 main() 函数的立马,称为局部变量。使用未初始化的局部变量有很多风险,很多编译器会给出警告,提醒程序员注意。

Important

  • ① C 语言的编译器,在程序员在使用未初始化的局部变量会有警告的原因就是:变量声明的时候,会给变量分配一块内存空间,如果不对变量进行初始化,那么就意味着不对这块内存空间进行写入操作,那么这块内存空间的数据将保持不变。但是,这个内存空间的数据是哪里来的?是当前程序之前运行产生的,还是其它程序之前运行产生的,我们一无所知。由此可知,如果不进行初始化,那么变量对应的内存空间的数据是毫无意义的,是随机值,是垃圾值,没有任何价值。所以,建议在声明局部变量的同时进行初始化操作。
  • ② 在实际开发中,声明局部变量的时候,必须进行初始化操作,以便能够减少潜在的错误并提高代码的稳定性。
  • ③ 在很多编程语言Java ,如果局部变量没有进行初始化操作,将会在编译阶段报错。
  • 示例:先声明,再使用
#include <stdio.h>

int main() {

    // 声明一个整型变量,取名为 a
    int a;

    // 给变量赋值
    a = 10;

    printf("a = %d\n", a);

    return 0;
}
  • 示例:初始化(声明变量的同时给其赋值)
#include <stdio.h>

int main() {

    // 声明一个整型变量,取名为 b ,并直接赋值(初始化,实际开发中最为常用)
    int b = 200;

    // 修改变量 b 的值,将变量 a 的值赋值给变量 b
    b = 300;

    printf("b= %d\n", b);

    return 0;
}
  • 示例:同时声明多个变量并赋值
#include <stdio.h>

int main() {

    // 同时声明多个整型的变量并赋值
    int c1 = 10, c2 = 20, c3 = 30;
    printf("c1 = %d\n", c1);
    printf("c2 = %d\n", c2);
    printf("c3 = %d\n", c3);

    return 0;
}

1.4 浅谈变量周边概念

1.4.1 数据类型

  • 数据是放在内存中的,变量是给这块内存起的名字,有了变量就可以找到并使用这份数据。但是,该如何使用?
  • 我们知道,对于数字、文字、符号、图形、音频、视频等数据都是以二进制的形式被加载进内存中,进而被计算机中的 CPU 所识别,它们本质上没有任何区别。那么,对于 00010000 这个二进制数字,我们是理解为数字 16 ?还是理解为图像中的某个像素的颜色?如果没有特别指明,我们并不清楚。
  • 换言之,内存中的数据有多种解释方式;那么,我们在存储之前就必须明确指定,如: int num = 10; 中的 int 就是数据类型,用来限定 num (内存中的某个区域)中存储的是整数,而不是图像中某个像素的颜色。

Note

总结:

  • ① 数据类型用来说明数据的类型,确定了数据的解释方式,让计算机和程序员不会产生歧义。
  • ② C 语言中很多基本的数据类型char、short、int、long 等;如果需要,也可以组成更加复杂的数据类型(后续讲解)。

1.4.2 连续定义多个变量

  • 为了让程序的书写更加简洁C 语言支持多个变量的连续定义,如下所示:
int a,b,c;
float m=3.14,n=4.14;

Note

  • ① 连续定义的多个变量以逗号,分隔,并且要拥有相同的数据类型。变量可以初始化,也可以不初始化。
  • ② 很多 C 语言程序员喜欢这么写;但是,本人不是很喜欢,因为连续定义可能会导致代码的可读性降低,特别是在声明时变量之间用逗号分隔,容易导致混淆。

1.4.3 数据的长度

  • 所谓数据长度,就是指的是数据占用多少个字节。占用的字节越多,能存储的数据就越多;对于数字而言,值就会越大。反之,能存储的数字就有限。
  • 多个数据在内存中是连续存储的,彼此之间是没有明显的界限的。如果不指明数据的长度,那么计算机就不知道何时才能存取结束。假设我们保存了一个整数 1000 ,它占用 4 个字节的内存,而读取它的时候却读取了 3 个字节或 5 个字节;那么,显示是不正确的。
  • 所以,在定义变量的时候还要指明数据的长度,而这恰恰是数据类型的另外一个作用,即:数据类型除了指明数据的解释方式,还指明了数据的长度

Note

总结:在 C 语言中,每一种数据类型所占用的字节数都是固定的,知道了数据类型,也就知道了数据的长度。

  • 在 32 位环境中,各种数据类型的长度,如下所示:
数据类型 长度(字节)
char 1
short 2
int 4
long 4
long long 8
float 4
double 8
long double 8
pointer(指针) 4

Note

  • ① C 语言有多少种数据类型,每种数据类型长度是多少、该如何使用,这是每一位 C 程序员都必须要掌握的。
  • ② 当然,不必担心,后续还会一一讲解的。

Important

  • ① 数据类型只需要在定义变量时指明,而且必须指明。
  • ② 使用变量时无需再指明,因为此时的数据类型已经确定了。

1.5 从计算机底层看变量

1.5.1 内存条的内部结构

  • 如果只看内存条的外观,无非就是一些集成电路和颗粒而已,如下所示:

  • 并且,我们只需要将内存条插入到计算机主板对应的内存条插槽上,就可以正常工作,如下所示:

  • 在家用的台式机主板上,通常有 4 个插槽或 2 个插槽,例如:本人的计算机就支持 4 个插槽,如下所示:

Note

  • ① 上图中的外形规格是 DIMM所以我们通常也以 DIMM 也表示内存条。
  • ② DIMM 是内存条的物理形式,安装在主板的内存插槽中。
  • ③ 常见的 DIMM 类型包括 UDIMM非缓冲 DIMM、RDIMM缓冲 DIMM和 LRDIMM负载减少DIMM
  • 我们可以通过 CPU-Z 这个软件,查看 CPU 的一些指标信息,如下所示:

Note

  • ① 通过 CPU-Z 表明本人的台式机是支持双通道的,channel计算机中可以翻译信道通道
  • ② 通道是内存控制器与内存模块之间的通信路径。
  • ③ 多通道内存可以提高数据传输带宽。例如:双通道内存系统同时使用两个通道来传输数据,从而提高性能。
  • ④ 现代主板通常支持双通道Dual Channel、四通道Quad Channel甚至八通道Octa Channel
  • 对于家用台式机而言,如果将内存条的插槽从左到右依次编号,如下所示:

  • 其中,槽1槽2 是一个通道,槽3槽4 是一个通道;所以,通常是这么建议的:
    • 如果只有 1 根内存条,就插到 槽2 中。
    • 如果有 2 根内存条,就分别插入到 槽2槽4 中。
    • 如果有 4 根内存条,就全插满即可。

Note

组成双通道配置的内存条需要遵循一些基本要求来确保它们能够正常以双通道模式运行:

  • 相同容量:理想情况下,组成双通道的内存条应该具有相同的容量。这样可以确保它们在处理数据时的一致性和兼容性。
  • 匹配的速度规格内存条应该具有相同的速度规格即它们的频率DDR4-2400、DDR4-3200等应该相同。不同速度的内存条可以一起工作但系统会以所有内存条中最慢的那个的速度运行。
  • 相同的时序内存条的时序CL16-18-18-38应该匹配。时序参数影响内存的响应速度和稳定性不匹配的时序可能会降低性能或导致系统不稳定。
  • 相同的制造商和型号(推荐):虽然不是强制性要求,但选择相同制造商和型号的内存条可以最大限度地减少兼容性问题。不同制造商的内存条可能在微小的规格和性能上有差异,这有可能影响双通道配置的效能。
  • 内存条表面会有内存颗粒,如下所示:

Note

上图中的内存条有 8 个内存颗粒;但是,高端服务器上的内存条通常会存在 9 个内存颗粒,最后 1 个内存颗粒专门用来做 ECC 校验。

  • 一个内存条有两面,高端的内存条两面都有内存颗粒,我们将每个面称为 Rank 。那么,如果内存条有两个面,就是存在 Rank0 和 Rank1 ,即:

  • 内存条表面的黑色颗粒,我们称为 chip芯片 ,即:

Note

  • ① 内存颗粒是内存条上的 DRAM 芯片,每个芯片包含多个存储单元。
  • ② 内存颗粒存储数据并与内存控制器进行数据交换。
  • 在 chip 中还有 8 个 bank每个 bank 就是数据存储的实体,这些 bank 组成了一个二维矩阵,只要声明了 column 和 row 就可以从每个 bank 中取出 8bit 1 Bytes的数据如下所示

img

  • 综上所示,内存条的分层结构就是 Channel > DIMM > Rank -> Chip -> Bank -> Row/Column

1.5.2 变量的作用

  • 如果我们希望计算 10 和 20 的和;那么,在计算机中需要怎么做?

    • ① 首先,计算 10 和 20 的运算,一定在 CPU 中进行,因为在计算机中的各个部件中,只有 CPU 有运算器ALU
    • ② 其次,我们需要将 10 和 20 交给 CPU ;由于 CPU 只能和内存进行交互,那么我们必须将 10 和 20 存储到内存中。

    Note

    即使 10 和 20 是存储在文件中的,也需要先加载进内存,然后再交给 CPU 进行运算。

    • ③ 最后,只需要告诉 CPU 做何种运算,如:加、减、乘、除等。
  • 其中,最为重要的问题就是如何将数据存储到内存中?答案就是通过变量

  • 我们知道,计算机底层是使用二进制来表示指令和数据的;但是,如果我们的代码都是这样的,即:
0000,0000,000000010000 代表 LOAD A, 16
0000,0001,000000000001 代表 LOAD B, 1
0001,0001,000000010000 代表 STORE B, 16
  • 这样,直接使用内存地址来编写代码(机器语言)实现是太难阅读、修改和维护了;于是,我们就使用了汇编语言来编写代码,并通过编译器来将汇编语言翻译为机器语言,即:
LOAD A, 16   -- 编译 -->   0000,0000,000000010000
LOAD B, 1    -- 编译 -->   0000,0001,000000000001
STORE B, 16  -- 编译 -->   0001,0001,000000010000
  • 但是,这样的汇编语言还是面向机器的,编程时仍然需要记住和管理大量内存地址,不具备程序的移植性;于是,我们就是使用了高级语言来编写代码,并引入了变量的概念,即:
int num = 10;
  • 我们使用变量名关联内存地址,这样我们在编写代码的时候,就可以不用直接操作内存地址,极大地提高了代码的可读性和开发效率。并且,当程序运行完毕之后,程序所占用的内存还会交还给操作系统,以便其它程序使用。

  • 综上所述,高级语言编译器的作用就是:

    • ① 编写源代码时使用变量名。
    • ② 程序在经过编译器的编译之后,所有变量名被替换为具体地址。
    • ③ ……
  • 此时,我们就可以知道,变量就是内存中用于存储数据临时空间,并且变量中的值是可以变化的。

  • 内存中空间的最小单位字节Bytes即 8 个 0 或 1 ,如下所示:

00011001 00100110 00100110 00100110 00100110 ...

Note

计算机中存储单位的换算,如下所示:

  • 1 B = 8 bit。
  • 1 KB = 1024 B。
  • 1 MB = 1024 KB。
  • 1 GB = 1024 MB。
  • 1 TB = 1024 GB 。
  • ……
  • 在内存中,每一个字节都有一个编号,这个编号我们称之为地址。一个变量至少占用 1 个字节1 个或多个字节我们将变量的第一个字节所占用的地址变量的首地址就称之为该变量的地址。CPU 就可以通过变量地址找到某个变量的值,然后拿到具体的数据进行计算了。

Note

变量就是保存程序运行过程中临时产生的值。

  • 其实,到这里还是有疑惑的?我们说过,一个变量至少会占用 1 个字节,如果一个变量占用了 4 个字节,而 CPU 只会通过变量的地址(首地址)获取数据,那么 CPU 是如何获取完整的数据的?答案就是通过数据类型,数据类型除了限制数据的种类,还限制了数据在内存中所占空间的大小,如上图所示:

    • ① 假设变量 a 的首地址是 01 ,变量的数据类型是 4 个字节。
    • ② 那么CPU 就会依次,从 01 ~ 04 中获取数据。
  • 再次,剖析下变量的语法格式:

数据类型 变量名 = 值;
  • 变量名作用,如下所示:
    • ① 当我们编写代码的时候,使用变量名关联某块内存的地址
    • ② 当 CPU 执行的时候,会将变量名替换为具体的地址,再进行具体的操作。

Important

变量名(标识符)需要符合命名规则和命名规范!!!

  • 数据类型作用,如下所示:

    • ① 变量的数据类型决定了变量所占空间的大小。当我们在声明变量的时候写了数据数据类型CPU 就知道从变量的首地址位置开始取多少字节。
    • ② 变量的数据类型决定了两个变量是否能够运行以及能够做何种运算。例如JavaScript 就没有 char 类型的变量,都是 string 类型,可以和任意数据类型的数据拼接,并转换为 string 类型Java 中有 char 类型的变量,底层都会转换 unicode 编码,然后再计算。
  • 作用,如下所示:

    • 就是内存实际存储数据
    • = 是赋值操作符,就是将等号右侧的数据存储到等号左侧的变量名所代表的内存空间。
  • 那么,如下代码的含义就是:

// int 数据类型4 个字节
// num 变量名 -- 关联内存中的一块存储空间
// = 10 将 10 存储到 num 所代表的 4 个字节的存储空间中
int num = 10;

1.6 变量的重要操作

1.6.1 变量的输出

  • 在计算机中,所谓的输入输出都是以计算机CPU 和内存)为主体而言的,即:

Note

  • ① 输入:从输入设备(键盘、鼠标、扫描仪)向计算机输入数据。

  • ② 输出:从计算机向外部输出设备(显示器、打印机)输出数据。

  • 在 C 语言中,提供了 printf() 函数用于输出信息,其函数声明是:
int printf (const char *__format, ...) {
    ...
}
  • printf 的标准含义是格式化输出文本,来源于 print formatted格式化打印的缩写,其语法规则,如下所示:

Note

  • ① 格式化字符串:是使用双引号括起来的字符串,里面包含了普通的字符串和格式占位符。
  • ② 格式占位符(格式声明符):由 %格式字符组成,作用是将输出的数据转换为指定的格式后输出,这里的 %d 表示整数。
  • ③ 输出列表:是程序要输出的一些数据,可以是常量、变量或表达式,需要和格式占位符一一对应。
  • 在计算机中,二进制、八进制、十进制以及十六进制的英文名称和缩写,如下所示:

    • 二进制binary缩写是 bin。
    • 八进制octal缩写是 oct。
    • 十进制decimal缩写是 dec。
    • 十六进制Hexadecimal缩写是 hex。
  • 其实,我们也可以在 Windows 系统中的计算器中来看到,即:

Important

  • ① 在生活中的 decimal 是小数的意思。
  • ② 但是在计算机中decimal 的完整含义是 decimal integer ,即十进制整数。
  • 示例:
#include <stdio.h>

int main() {

    // 声明变量并赋值
    int num = 18;

    // 使用输出语句,将变量 num 的值输出,其中 %d 表示输出的是整数
    printf("我今年%d岁\n", num);

    return 0;
}

1.6.2 计算变量的大小

  • 我们可以使用 sizeof关键字(运算符)来计算变量或类型所占内存空间的大小。

  • 示例:

#include <stdio.h>

int main() {

    int num = 10;

    printf("变量所占内存空间的大小:%zd字节\n", sizeof(num));

    // 数据类型所占内存空间的大小
    printf("数据类型所占内存空间的大小:%zd字节\n", sizeof(int));

    return 0;
}

1.6.3 获取变量的地址

  • 在 C 语言中,我们可以使用取地址运算符 & 来获取变量的地址。

  • 示例:

#include <stdio.h>

int main() {

    int num = 10;

    printf("变量 num 的值是:%d\n", num);
    printf("变量 num 的地址(指针)是:%#p\n", &num);

    return 0;
}

1.6.4 变量的输入

  • 在 C 语言中,提供了 scanf() 函数用于从标准输入(通常是键盘)中读取数据并根据变量的地址赋值给变量(变量需要提前声明),其函数声明是:
int scanf(const char *__format, ...) {
    ...
}
  • 其语法规则,如下所示:

Note

&age&num 中的 &是寻址操作符,&age 表示变量 age 在内存中的地址。

Caution

  • ① scanf() 函数中的 %d,如果是连着写,即:%d%d,那么在输入数据的时候,数据之间不可以使用逗号,分隔只能使用空白字符空格、tab 键或回车键),即:2空格3tab2tab3回车等。

  • ② 如果是 %d,%d,则输入的时候需要加上逗号,,即:2,3

  • ③ 如果是 %d %d,则输入的时候需要加上空格,即:2空格3

  • 示例:计算圆的面积,半径由用户指定
#include <stdio.h>

int main() {

    // 禁用 stdout 缓冲区
    // CLion debug 独有,后文不再提及,如果 debug 有问题,就添加如下代码
    setbuf(stdout, NULL);
    
    float radius;

    printf("请输入一个半径:");
    scanf("%f", &radius);

    double area = 3.1415926 * radius * radius;

    printf("半径是%f的圆的面积是%.2lf", radius, area);

    return 0;
}
  • 示例:输入一个整数值,求其绝对值
#include <stdio.h>

int main() {

    int num;

    printf("请输入一个整数:");
    scanf("%d", &num);

    int absNum;

    if (num < 0) {
        absNum = -num;
    } else {
        absNum = num;
    }

    printf("%d的绝对值是%d", num, absNum);

    return 0;
}
  • 示例:输入多个变量的值,求其乘积
#include <stdio.h>

int main() {

    int a, b, c;
    printf("请输入整数 a 、b 和 c 的值:");
    scanf("%d %d %d", &a, &b, &c);

    int result = a * b * c;

    printf("%d × %d × %d = %d", a, b, c, result);

    return 0;
}

1.7 标识符

1.7.1 概述

  • 在 C 语言中,变量、函数、数组名、结构体等要素命名的时候使用的字符序列,称为标识符。

Note

  • ① 在上世纪 60 - 70 年代的时候,因为国家贫穷,人民生活不富裕等原因,家长虽然会给孩子取名为:张建国李华强等。
  • ② 但是,也会给孩子取小名,如:二狗子狗剩等,目的是希望孩子能健康成长(养活),像 张建国李华强二狗子狗剩都是名字(标识符),伴随人的一生。

1.7.2 标识符的命名规范

  • 强制规范:

    • ① 只能由小写大写英文字母0-9_ 组成。
    • ② 不能以数字开头。
    • ③ 不可以是关键字
    • ④ 标识符具有长度限制,不同编译器和平台会有所不同,一般限制在 63 个字符内。
    • ⑤ 严格区分大小写字母Hello、hello 是不同的标识符。
  • 建议规范:

    • ① 为了提高阅读性使用有意义的单词见名知意sumnamemaxyear 等。
    • ② 使用下划线连接多个单词组成的标识符max_classes_per_student 等。
    • ③ 多个单词组成的标识符,除了使用下划线连接,也可以使用小驼峰命名法,除第一个单词外,后续单词的首字母大写,如: studentId、student_name 等。
    • ④ 不要出现仅靠大小写区分不同的标识符name、Name 容易混淆。
    • ⑤ 系统内部使用了一些下划线开头的标识符C99 标准添加的类型 _Bool,为防止冲突,建议开发者尽量避免使用下划线开头的标识符。
  • 示例:合法(不一定建议)的标识符

a、BOOK_sun、MAX_SIZE、Mouse、student23、
Football、FOOTBALL、max、_add、num_1、sum_of_numbers
  • 示例:非法的标识符
$zj、3sum、ab#cd、23student、Foot-baii、
s.com、bc、j**p、book-1、tax rate、don't

1.7.3 关键字

  • C 语言中的关键字是编译器预定义保留字,它们有特定含义用途,用于控制程序的结构和执行。
  • C80 和 C90 ANSI C定义的关键字如下所示
类型(功能) 具体关键字
数据类型关键字 chardoublefloatintlongshortsignedunsignedvoid
存储类说明符关键字 autoexternregisterstatictypedefvolatileconst
控制语句关键字 breakcasecontinuedefaultdoelseforgotoifreturnswitchwhile
结构体、联合体和枚举关键字 enumstructunion
其他关键字 sizeof
  • C99 新增的关键字,如下所示:
类型(功能) 具体关键字
数据类型关键字 _Bool_Complex_Imaginary
存储类说明符关键字 inlinerestrict
其他关键字 _Complex_Imaginary
  • C11 新增的关键字,如下所示:
类型(功能) 具体关键字
存储类说明符关键字 _Atomic
其他关键字 _Alignas_Alignof_Generic_Noreturn_Static_assert_Thread_local

Important

  • ① 关键字不能用作标识符(如:变量名、函数名等)。
  • ② 不要死记硬背这些关键字,在实际开发中,并不一定全部使用到;而且,在学到后面的时候,会自动记住这些关键字以及对应的含义。

第二章:常量(

2.1 概述

  • 在程序运行过程中,不能改变的量就是常量。

Note

  • ① 在数学中的 π,就是一个常量,其值为 3.1415926 。
  • ② 在生活中,人类的性别只有;其中,也是常量。
  • ③ ...

2.2 常量的分类

  • 在 C 语言中的变量的分类,如下所示:
    • ① 字面量常量。
    • ② 标识符常量:
      • #define 宏定义的标识符常量。
      • const 关键字修饰的标识符常量。
      • 枚举常量。

Note

  • ① 所谓的字面量常量,就是可以直接使用的常量,不需要声明或定义,包括:整数常量、浮点数常量以及字符常量。
  • ② 所谓的标识符常量,就是使用标识符来作为常量名,包括: #define 宏定义的标识符常量、const 关键字修饰的标识符常量、枚举常量。
  • 示例:字面量常量
#include <stdio.h>

int main() {

    1;
    'A';
    12.3;
    "你好";

    return 0;
}
  • 示例:字面量常量
#include <stdio.h>

int main() {

    printf("整数常量 =》%d\n", 1);
    printf("字符常量 =》%c\n", 'A');
    printf("浮点数常量 =》%f\n", 12.3);
    printf("字符串常量 =》%s\n", "你好");

    return 0;
}

2.3 使用 #define 定义常量

  • #define 来定义常量,也叫作宏定义,就是用一个标识符来表示一个常量值,如果在后面的代码中出现了该标识符,那么编译时就全部替换成指定的常量值,即用宏体替换所有宏名,简称宏替换
  • 格式是:
#define 常量名 常量值

Important

  • ① 其实宏定义的常量的执行时机是在预处理阶段,将所有宏常量替换完毕,才会继续编译代码。
  • ② 不要以 ; 结尾,如果有 ; ,分号也会成为常量值的一部分。
  • # define 必须写在 main 函数的外面!!!
  • 常量名习惯用大写字母表示,如果多个单词,使用 _ 来分隔,以便和变量区分。
  • 示例:
#include <stdio.h>

#define PI 3.1415926

int main() {

    double radius = 2.5;

    double area = PI * radius * radius;

    printf("半径为%lf的圆的面积是%.2lf", radius, area);

    return 0;
}

2.4 const 关键字

  • C99 标准新增,这种方式跟定义一个变量是类似的;只不过,需要在变量的数据类型前加上 const 关键字。
  • 和使用 #define定义宏常量相比const 定义的常量有详细的数据类型,而且会在编译阶段进行安全检查,在运行时才完成替换,所以会更加安全和方便。
  • 格式是:
const 数据类型 常量名 = 常量值;
  • 示例:
#include <stdio.h>

const double PI = 3.1415926;

int main() {

    double radius = 2.5;

    double area = PI * radius * radius;

    printf("半径为%lf的圆的面积是%.2lf", radius, area);

    return 0;
}

2.5 枚举常量

  • 格式:
enum 枚举常量 {
    xxx = 1;
    yyy;
    ...
}

Note

  • ① 默认情况下,枚举常量是从 0 开始递增的。
  • ② 也可以在定义枚举常量的时候,自定义它们的值。
  • 示例:
#include <stdio.h>

enum sex {
    MALE = 1,
    FEMALE = 2,
};

int main() {

    printf("%d\n", MALE);
    printf("%d\n", FEMALE);

    return 0;
}
  • 示例:
#include <stdio.h>

enum Sex {
    MALE = 1,
    FEMALE = 2,
};

int main() {
    enum Sex sex;

    printf("请输入性别(1 表示男性, 2 表示女性)");
    scanf("%d", &sex);
    printf("您的性别是:%d\n", sex);

    return 0;
}

2.6 #define 定义常量 VS const 定义常量

2.6.1 概述

  • #defineconst 都可以用来定义常量,但它们的工作方式和应用场景有所不同。

2.6.2 语法和定义方式

  • #define 是一个预处理指令,用来定义宏。在编译时,所有的宏会被预处理器展开为它们定义的值,类似于文本替换。
#define PI 3.14159
  • const是一个编译时常量,用来定义具有类型的常量变量。它是由编译器处理的,并且在运行时仍然可以保留类型信息。
const float PI = 3.14159;

2.6.3 类型检查

  • #define没有类型,它只是简单的文本替换,不会进行类型检查。因此,如果在宏中定义了错误的类型,可能导致编译错误或运行时错误。
#define MAX 10 + 20 // 实际展开后可能是 MAX = 10 + 20而不是 30
  • const具有类型,编译器会进行类型检查。如果定义时类型不匹配,会报编译错误。
const int MAX = 30; // 定义时指定了类型,类型检查严格

2.6.4 作用域

  • #define宏没有作用域的概念,它是在预处理时进行全局替换的。因此,可能会引发意外的替换问题,尤其是在复杂项目中。
#define SIZE 10  // SIZE 可能在其他文件中也被不小心替换
  • const具有作用域,它遵循 C 语言的作用域规则(比如局部作用域、全局作用域)。这使得 const 定义的常量更安全,因为它们只能在指定的范围内使用。
const int SIZE = 10; // 可以局部或全局定义,不会引发冲突

2.6.5 调试

  • #define在调试时,宏常量被替换为字面值,因此调试工具中无法看到它的原始名称,只能看到被替换后的值。
  • const常量在编译后依然存在,因此在调试时可以直接看到常量的名称和它的值,调试体验更好。

2.6.6 内存分配

  • #define宏在预处理阶段替换,不占用内存。
  • const 常量会被分配内存,特别是在全局或静态情况下,但它也可以被优化为编译时常量,有时也不会占用额外的内存。

2.6.7 适用场景

  • #define通常用于定义简单的常量值、条件编译或宏函数(用于文本替换)。适合不需要类型、安全性检查的场合。
  • const用于定义类型安全的常量,适合需要进行类型检查或确保作用域的场合。

2.6.8 总结

  • const 更加安全,尤其是在需要类型检查和局部作用域的时候;而 #define 常用于需要简洁的文本替换或宏定义。const#define 对比的表格,如下所示:
特性 #define const
类型检查 无类型检查 有类型检查
作用域 无(全局替换) 有作用域(局部/全局)
调试支持 差(替换为字面值) 好(保留名称)
内存开销 可能会有
使用场景 宏、条件编译 类型安全的常量

第三章:进制

3.1 概述

  • 计算机的底层只有二进制,即计算机中运算存储所有数据都需要转换为二进制,包括:数字、字符、图片、视频等。

  • 之前,我们也提到现代的计算机(量子计算机除外)几乎都遵循冯·诺依曼体系结构,其理论要点如下:
    • 存储程序程序指令数据都存储在计算机的内存中,这使得程序可以在运行时修改。
    • 二进制逻辑:所有数据和指令都以二进制形式表示。
    • 顺序执行:指令按照它们在内存中的顺序执行,但可以有条件地改变执行顺序。
    • 五大部件:计算机由运算器控制器存储器输入设备输出设备组成。
    • 指令结构:指令由操作码和地址码组成,操作码指示要执行的操作,地址码指示操作数的位置。
    • 中心化控制计算机的控制单元CPU负责解释和执行指令控制数据流。
  • 所以,再次论证了为什么计算机只能识别二进制。

3.2 进制

3.2.1 常见的进制

  • 在生活中,我们最为常用的进制就是十进制,其规则是满 10 进 1 ,即:

  • 在计算机中,常见的进制有二进制八进制十六进制,即:
    • 二进制:只能 0 和 1 ,满 2 进 1 。
    • 八进制0 ~ 7 ,满 8 进 1 。
    • 十六进制0 ~ 9 以及 A ~ F ,满 16 进 1 。

Note

在十六进制中,除了 09 这十个数字之外,还引入了字母,以便表示超过 9 的值。其中,字母 A 对应十进制的 10 ,字母 B 对应十进制的 11 ,字母 C 对应十进制的 12,字母 D 对应十进制的 13,字母 E 对应十进制的 14,字母 F 对应十进制的 15

  • 进制的换算举例,如下所示:
十进制 二进制 八进制 十六进制
0 0 0 0
1 1 1 1
2 10 2 2
3 11 3 3
4 100 4 4
5 101 5 5
6 110 6 6
7 111 7 7
8 1000 10 8
9 1001 11 9
10 1010 12 a 或 A
11 1011 13 b 或 B
12 1100 14 c 或 C
13 1101 15 d 或 D
14 1110 16 e 或 E
15 1111 17 f 或 F
16 10000 20 10
... ... ... ...
  • 二进制和十六进制的关系:十六进制是以 16 为基数的进制系统16 在二进制中表示为 ( 2^4 ),即:一个十六进制可以表示 4 位二进制。

Note

十六进制的范围是0 ~ F 0 ~ 15对应的二进制数的范围是0000 ~ 1111 0 ~ 15

  • 每个十六进制数都可以映射到一个唯一的 4 位二进制数,即:
十六进制 二进制
0 0000
1 0001
2 0010
3 0011
4 0100
5 0101
6 0110
7 0111
8 1000
9 1001
A 1010
B 1011
C 1100
D 1101
E 1110
F 1111

Note

由此可见,每个十六进制数字确实由 4 位二进制数表示。

  • 二进制和八进制的关系:八进制是以 8 为基数的进制系统8 在二进制中表示为 ( 2^3 );即:一个八进制位可以表示 3 个二进制位。

Note

八进制的范围是0 ~ 7 对应的二进制数的范围是000 ~ 111。

  • 每个八进制数位都可以映射到一个唯一的 3 位二进制数,即:
八进制 二进制
0 000
1 001
2 010
3 011
4 100
5 101
6 110
7 111

Note

由此可见,每个八进制数字确实由 3 位二进制数表示。

3.2.2 C 语言中如何表示不同进制的整数?

  • 规则如下:

    • 在 C 语言中,如果是二进制(字面常量),则需要在二进制整数前加上 0b0B
    • 在 C 语言中,如果是八进制(字面常量),则需要在八进制整数前加上 0
    • 在 C 语言中,如果是十进制(字面常量),正常数字表示即可。
    • 在 C 语言中,如果是十六进制(字面常量),则需要在十六进制整数前加上 0x0X
  • 示例:

#include <stdio.h>

int main() {

    int num1 = 0b10100110; // 二进制
    int num2 = 0717563; // 八进制
    int num3 = 1000; // 十进制
    int num4 = 0xaf72; // 十六进制

    printf("num1 = %d\n", num1); // num1 = 166
    printf("num2 = %d\n", num2); // num2 = 237427
    printf("num3 = %d\n", num3); // num3 = 1000
    printf("num4 = %d\n", num4); // num4 = 44914

    return 0;
}

3.2.3 输出格式

  • 在 C 语言中,可以使用不同的格式占位符输出不同进制的整数,如下所示:
    • %d:十进制整数。
    • %o :八进制整数。
    • %x:十六进制整数。
    • %#o :显示前缀 0 的八进制整数。
    • %#x :显示前缀 0x 的十六进制整数。
    • %#X :显示前缀 0X 的十六进制整数。

Caution

C 语言中没有输出二进制数的格式占位符!!!

  • 示例:
#include <stdio.h>

int main() {

    int num = 100;

    printf("%d 的十进制整数: %d\n", num, num); // 100 的十进制整数: 100
    printf("%d 的八进制整数: %o\n", num, num); // 100 的八进制整数: 144
    printf("%d 的十六进制整数: %x\n", num, num); // 100 的十六进制整数: 64
    printf("%d 的八进制(前缀)整数: %#o\n", num, num); // 100 的八进制(前缀)整数: 0144
    printf("%d 的十六进制(前缀)整数: %#x\n", num, num); // 100 的十六进制(前缀)整数: 0x64
    printf("%d 的十六进制(前缀)整数: %#X\n", num, num); // 100 的十六进制(前缀)整数: 0X64

    return 0;
}

3.3 进制的运算规则

3.3.1 概述

  • 十进制的运算规则,如下所示:
    • (针对加法而言)。
    • (针对减法而言)。
  • 二进制的运算规则,如下所示:
    • (针对加法而言)。
    • (针对减法而言)。
  • 八进制的运算规则,如下所示:
    • (针对加法而言)。
    • (针对减法而言)。
  • 十六进制的运算规则,如下所示:
    • 十六(针对加法而言)。
    • 十六(针对减法而言)。

3.3.2 二进制的运算

  • 二进制的加法:1 + 0 = 11 + 1 = 1011 + 10 = 101111 + 111 = 1110

  • 二进制的减法:1 - 0 = 110 - 1 = 1101 - 11 = 101100 - 111 = 101

3.3.3 八进制的运算

  • 八进制的加法:3 + 4 = 75 + 6 = 1375 + 42 = 1372427 + 567 = 3216

  • 八进制的减法:6 - 4 = 252 - 27 = 33307 - 141 = 1467430 - 1451 = 5757

3.3.4 十六进制的运算

  • 十六进制的加法:6 + 7 = D18 + BA = D2595 + 792 = D272F87 + F8A = 3F11

  • 十六进制的减法:D - 3 = A52 - 2F = 23E07 - 141 = CC67CA0 - 1CB1 = 5FEF

3.4 进制的转换

3.4.1 概述

  • 不同进制的转换,如下所示:

  • 在计算机中,数据是从右往左的方式排列的;其中,最右边的是低位,最左边的是高位,即:

3.4.2 二进制和十进制的转换

3.4.2.1 二进制转换为十进制

  • 规则:从最低位开始,将每个位上的数提取出来,乘以 2 的 (位数 - 1 )次方,然后求和。

Note

  • ① 在学术界,将这种计算规则,称为位权相加法
  • 八进制转换为十进制十六进制转换为十进制二进制转换为十进制的算法相同!!!
  • 示例:十进制转十进制

  • 示例:二进制转十进制

3.4.2.2 十进制转换二进制

  • 规则:将该数不断除以 2 ,直到商为 0 为止,然后将每步得到的余数倒过来,就是对应的二进制。

Note

  • ① 在学术界,将这种计算规则,称为短除法连续除2取余法
  • ② 很好理解,只有不断地除以 2 ,就能保证最大的数字不超过 2 ,这不就是二进制(只能有 0 或 1
  • 八进制转换为二进制十六进制转换为二进制十进制转换为二进制的算法相同!!!
  • 示例:十进制转十进制

  • 示例:十进制转二进制

3.4.3 二进制转八进制

  • 规则:从右向左,每 3 位二进制就是一个八进制,不足补 0分组转换法

  • 示例011 101 001 -> 351

3.4.4 二进制转十六进制

  • 规则:从右向左,每 4 位二进制就是一个十六进制,不足补 0分组转换法

  • 示例1110 1001 -> 0xE9

3.5 原码、反码和补码

3.5.1 概述

  • 机器数:一个数在计算机的存储形式是二进制,我们称这些二进制数为机器数。机器数可以是有符号的,用机器数的最高位来存放符号位,0 表示正数,1 表示负数。

Important

  • ① 这里讨论的适用于有符号位的整数int 等。
  • ② 这里讨论的不适用于无符号位的整数unsinged int 等。

  • 真值(数据位):因为机器数带有符号位,所以机器数的形式值不等于其真实表示的值(真值),以机器数 1000 0001 为例,其真正表示的值(首位是符号位)为 -1而形式值却是 129 ,因此将带有符号位的机器数的真正表示的值称为机器数的真值。

Important

  • ① 这里讨论的适用于有符号位的整数int 等。
  • ② 这里讨论的不适用于无符号位的整数unsinged int 等。

3.5.2 原码

  • 原码的表示与机器数真值表示的一样,即用第一位表示符号,其余位表示数值。
  • 规则:
    • 正数的原码是它本身对应的二进制数,符号位是 0 。
    • 负数的原码是它本身绝对值对应的二进制数,但是符号位是 1 。
  • +1 的原码,使用 16 位二进数来表示,就是:
十进制数 原码16位二进制数
+1 0000 0000 0000 0001
  • -1 的原码,使用 16 位二进数来表示,就是:
十进制数 原码16位二进制数
-1 1000 0000 0000 0001

Important

  • ① 按照原码的规则,会出现 +0-0 的情况,即:0000 0000 0000 0001+01000 0000 0000 0001-0显然不符合实际情况。
  • ② 所以,计算机底层虽然存储和计算的都是二进数,但显然不是原码。

3.5.3 反码

  • 规则:

    • 正数的反码和它的原码相同。
    • 负数的反码是在其原码的基础上,符号位不变,其余各位取反。
  • +1 的反码,使用 16 位二进数来表示,就是:

十进制数 原码16位二进制数 反码16位二进制数
+1 0000 0000 0000 0001 0000 0000 0000 0001
  • -1 的反码,使用 16 位二进数来表示,就是:
十进制数 原码16位二进制数 反码16位二进制数
-1 1000 0000 0000 0001 1111 1111 1111 1110

Important

  • ① 按照反码的规则,如果是 +0,对应的原码是 0000 0000 0000 0000那么其反码还是 0000 0000 0000 0000如果是 -0,对应的原码是 1000 0000 0000 0000其反码是 1111 1111 1111 1111显然不符合实际情况。
  • ② 所以,计算机底层虽然存储和计算的都是二进数,但显然不是反码。

3.5.4 补码

  • 规则:

    • 正数的补码和它的原码相同。
    • 负数的补码是在其反码的基础上 + 1 。
  • +1 的补码,使用 16 位二进数来表示,就是:

十进制数 原码16位二进制数 反码16位二进制数 补码16位二进制数
+1 0000 0000 0000 0001 0000 0000 0000 0001 0000 0000 0000 0001
  • -1 的补码,使用 16 位二进数来表示,就是:
十进制数 原码16位二进制数 反码16位二进制数 补码16位二进制数
-1 1000 0000 0000 0001 1111 1111 1111 1110 1111 1111 1111 1111
  • 如果 0 ,按照 +0 的情况进行处理,如下所示:

  • 如果 0 ,按照 -0 的情况进行处理,如下所示:

  • +1-1原码补码的转换过程,如下所示:

Important

  • ① 补码表示法解决了原码反码存在的两种零(+0-0)的问题,即:在补码表示法中,只有一个零,即 0000 0000
  • ②补码使得加法运算减法运算可以统一处理,通过将减法运算转换为加法运算,可以简化硬件设计,提高了运算效率。
  • ③ 计算机底层存储计算的都是二进数的补码。换言之,当读取整数的时候,需要采用逆向的转换,即:将补码转换为原码。正数的原码、反码、补码都是一样的,三码合一。负数的补码转换为原码的方法就是先减去 1 ,得到反码,再按位取反,得到原码(符号位是不能借位的)。

3.5.5 总结

  • ① 计算机底层存储计算的都是二进数的补码。换言之,当读取整数的时候,需要采用逆向的转换,即:将补码转换为原码。
  • ② 正数的原码、反码和补码都是一样的,三码合一。
  • ③ 负数的反码是在其原码的基础上按位取反0 变 1 1 变 0 ),符号位不变;负数的补码是其反码 + 1 。
  • ④ 0 的补码是 0 。
  • ⑤ 负数的补码转换为原码的方法就是先减去 1 ,得到反码,再按位取反,得到原码(符号位是不能借位的)。

3.6 计算机底层为什么使用补码?

  • 加法减法是计算机中最基本的运算,计算机时时刻刻都离不开它们,所以它们由硬件直接支持。为了提高加法和减法的运行效率,硬件电路必须设计得尽量简单。

  • 对于有符号位的数字来说,内存需要区分符号位和数值位:对于人类来说,很容易识别(最高位是 0 还是 1但是对于计算机来说需要设计专门的电路这无疑增加了硬件的复杂性增加了计算时间。如果能将符号位和数值位等同起来让它们一起参与运算不再加以区分这样硬件电路就可以变得非常简单。

  • 此外,加法和减法也可以合并为一种运算,即:加法运算。换言之,减去一个数就相当于加上这个数的相反数,如:5 - 3 相当于 5 +-310 --9相当于 10 + 9

  • 如果能够实现上述的两个目标,那么只需要设计一种简单的、不用区分符号位和数值位的加法电路,就能同时实现加法运算和减法运算,而且非常高效。其实,这两个目标已经实现了,真正的计算机的硬件电路就是这样设计的。

  • 但是,简化硬件电路是有代价的,这个代价就是有符号数在存储和读取的时候都要继续转换。这也是对于有符号数的运算来说,计算机底层为什么使用补码的原因所在。

3.7 补码到底是如何简化硬件电路的?

  • 假设 6 和 18 都是 short 类型,现在我们要计算 6 - 18 的结果,根据运算规则,它等价于 6 +-18。如果按照采用原码来计算,那么运算过程是这样的,如下所示:

Note

直接使用原码表示整数,让符号位也参与运算,那么对于减法来说,结果显然是不正确的。

  • 于是,人们开始继续探索,不断试错,终于设计出了反码,如下所示:

Note

直接使用反码表示整数,让符号位也参与运算,对于 6 +-18来说结果貌似正确。

  • 如果我们将被减数减数对调一下,即:计算 18 - 6 的结果,也就是 18 +-6的结果,继续采用反码来进行运算,如下所示:

Note

  • ① 6 - 186+-18如果采用反码计算结果是正确的但是18 - 618 +-6如果采用反码计算,结果相差 1 。
  • ② 可以推断:如果按照反码来计算,小数 - 大数,结果正确;而大数 - 小数,结果相差 1 。

  • 对于这个相差的 1 必须进行纠正,但是又不能影响小数-大数的结果。于是,人们又绞尽脑汁设计出了补码,给反码打了一个“补丁”,终于把相差的 1 给纠正过来了。那么,6 - 18 按照补码的运算过程,如下所示:

  • 那么,18 - 6 按照补码的运算过程,如下所示:

Important

总结:采用补码的形式正好将相差的 1纠正过来,也没有影响到小数减大数,这个“补丁”非常巧妙。

  • ① 小数减去大数,结果为负,之前(负数从反码转换为补码需要 +1加上的 1 ,后来(负数从补码转换为反码要 -1还需要减去正好抵消掉所以不会受到影响。
  • ② 大数减去小数,结果为正,之前(负数从反码转换为补码需要 +1加上的 1 ,后来(正数的补码和反码相同,从补码转换为反码不用 -1就没有再减去不能抵消掉这就相当于给计算结果多加了一个 1。

补码这种天才般的设计,一举达成了之前加法运算和减法运算提到的两个目标,简化了硬件电路。

3.8 问题抛出

  • 在 C 语言中,对于有符号位的整数,是使用 0 作为正数,1 作为负数,来表示符号位,并使用数据位来表示的是数据的真值,如下所示:
int a = 10;
int b = -10;

  • 但是,对于无符号位的整数而言,是没有符号位和数据位,即:没有原码、反码、补码的概念。无符号位的整数的数值都是直接使用二进制来表示的(也可以理解为,对于无符号位的整数,计算机底层存储的就是其原码),如下所示:
unsigned int a = 10;
unsigned int b = -10;

  • 这就是导致了一个结果就是:如果我定义一个有符号的负数,却让其输出无符号,必然造成结果不对,如下所示:
#include <stdio.h>

char *getBinary(int num) {
    static char binaryString[33];
    int         i, j;

    for (i = sizeof(num) * 8 - 1, j = 0; i >= 0; i--, j++) {
        const int bit   = (num >> i) & 1;
        binaryString[j] = bit + '0';
    }

    binaryString[j] = '\0';
    return binaryString;
}

int main() {

    // 禁用 stdout 缓冲区
    setbuf(stdout, NULL);

    int num = -10;
    printf("b=%s\n", getBinary(num)); // b=11111111111111111111111111110110
    printf("b=%d\n", num);            // b=-10
    printf("b=%u\n", num);            // b=4294967286

    return 0;
}
  • 其实C 语言的底层逻辑很简单C 语言压根不关心你定义的是有符号数还是无符号数,它只关心内存(如果定义的是有符号数,那就按照有符号数的规则来存储;如果定义的是无符号数,那就按照无符号数的规则来存储)。换言之,有符号数可以按照无符号数的规则来输出,无符号数也可以按照有符号数的规则来输出,至于输出结果对不对,那是程序员的事情,和 C 语言没有任何关系。

Important

  • ① 实际开发中,printf 函数中的常量、变量或表达式,需要和格式占位符一一对应;否则,将会出现数据错误的现象。
  • ② 正因为上述的原因很多现代化的编程语言Java 等,直接取消了无符号的概念。但是,很多数据库是使用 C 语言开发的MySQL 等,就提供了创建数据表的字段为无符号类型的功能,即:UNSIGNED(正整数) ,不要感觉困惑!!!
  • ③ 对于 1000 0000 …… 0000 0000 这个特殊的补码,无法按照上述的方法转换为原码,所以计算机直接规定这个补码对应的值就是 -2³¹,至于为什么,下节我们会详细分析。