1-07基本数据类型

发布时间:2024年01月09日

一、概述

C语言的基本数据类型(也叫内置数据类型)在日常编程中被频繁使用,本章我们主要简单地介绍下 C 语言的基本数据类型。

基本数据类型主要包括:整数类型,浮点数类型和字符类型,其中字符类型也可以看作是整数类型中的一种。

所以你可以认为C语言的基本数据类型只有两类:

  1. 整数类型
  2. 浮点数类型

二、sizeof运算符

sizeof运算符用于确定特定数据类型或对象的大小(以字节为单位)。使用sizeof可以帮助确保代码的可移植性,因为在不同的编译器或体系结构上,数据类型的大小可能会有所不同。

它大体上有三种使用场景:

  1. 类型名:你可以直接查询一个数据类型的大小。

    sizeof(int);
    
  2. 变量名:你可以查询一个已定义的变量的大小,而不必显式指出它的类型。

    sizeof(a);  // a是某个任意类型的变量
    
  3. **表达式:**当对表达式使用sizeof时,用于测量表达式结果类型的大小。此时表达式的结果具体是什么并不会被计算。

    sizeof(2 + 3.5);
    

sizeof组成的表达式的结果是一个size_t类型,这是一个类型的别名,它定义了一个**“和平台以及编译器相关的”**无符号整数类型。

下面代码示例,演示了sizeof的一些使用:

代码块 1. sizeof代码示例

int i, j;

sizeof(10);
sizeof(i);
sizeof(i + j);
sizeof i

在一个典型的 32-bit 和 64-bit 的机器上,上面三个表达式的值都为 4,因为int被设计为占用4个字节内存空间。

值的注意的是,我们经常在VS的监视窗口使用sizeof运算符。

三、整数类型

整数类型又可以分为两大类:

  1. 有符号整数。默认情况下,C 语言的整数类型都是有符号的,即在不加任何修饰符的情况下就是一个有符号整数,但C语言也允许程序员带上**"signed"关键字**明确表示该整数类型是有符号的(但一般没有必要,也不建议这么做)。
  2. 无符号整数。若要声明为无符号整数,则需要明确添加**"unsigned"关键字**。

C语言(C99)目前允许的整数类型有以下:

代码块 2. C99提供的整数类型

short (int)
unsigned short (int)
int 
unsigned (int)  
long (int)
unsigned long (int)
long long (int)
unsigned long long (int)

C 语言整数类型的取值范围可能根据机器的不同而不同,但是有两条所有编译器都必须遵循的原则:

  1. 首先,C 标准规定了整数类型的最小字节长度:short(2)、 int(2)、long(4)、long long(8)
  2. 其次,C 标准规定了各个整数类型的字节长度满足下面的关系:short <= int <= long <= long long

下表是64位处理器,在Linux平台机器上,整数类型的常见取值范围:

表 1. 64位Linux平台机器上整数类型的取值范围

类型字节长度最小值最大值
short2-3276832767
unsigned short2065535
int4-2 147 483 6482 147 483 647
unsigned int404 294 967 295
long8-9 223 372 036 854 775 8089 223 372 036 854 775 807
unsigned long8018 446 744 073 709 551 615
long long8-9 223 372 036 854 775 8089 223 372 036 854 775 807
unsigned long long8018 446 744 073 709 551 615

注意:

无符号数意味着最高位也充当数值位,所以它没有负数,但是表示的正数范围就更大了。

1. 整数字面值

C 语言允许使用十进制 (Decimal),八进制 (Octal) 或者十六进制 (Hex) 来书写整数字面值。

  1. 十进制字面值包含数字 0~9,但是不能以 0 开头。如:15, 255, 32767。
  2. 八进制字面值包含数字 0~7,而且必须以 0 开头。如:017, 0377, 077777。
  3. 十六进制字面值包含数字 0~9 和字母 af(或AF),而且总以 0x (或0X)开头。如:0xf, 0xff, 0x7fF。

注意:整数字面值也是有类型的!

代码中最常见的十进制整数字面值,默认情况下是int类型的,如果它超出了 int 的表示范围,那么它的类型是 long 和 long long 中能表示该字面值的 “最小” 类型。

对八进制和十六进制整数字面值来说,整数的字面值首先尝试被视为 int 类型,如果超出 int 范围,依次尝试 unsigned intlongunsigned longlong longunsigned long long,直到找到可以容纳该字面值的最小类型。

所以在有必要的情况下,我们可以通过一系列的操作,使得整数字面值改变它的默认类型。比如:

如果要指明某个整数字面值为一个 long 类型,只需要在字面值最后面加字母 L (禁止加小写的L)

15L, 0377L, 0x7fffL

如果要指明整数字面值是 long long 类型,我们需要在后面加 LL:

15LL, 0377LL, 0x7fffLL

如果要指明整数常量是无符号的,我们需要在后面加字母 U (或 u):

15U, 0377U, 0x7fffU

L、LL还可以和 U 结合使用,如:

0xffffffffUL, 0x12345678ULL

当然,这些字母组合时,先后顺序是没有影响的。

注意:尽管可以在程序中使用各种整数字面值,但为了代码的可读性,建议仅在需要时使用八进制和十六进制,并明确注释其用途。

2. 读/写整数

当你在C语言中使用scanf和printf函数来读写整数类型数据时,需要明确整数的类型和相应的转换说明书写格式。

关于转换说明,大家可以自行查看文档格式化输入和输出回顾。

其中四个基本的整数转换说明符:

  1. d:有符号十进制整数
  2. u:无符号十进制整数
  3. o:无符号的八进制整数
  4. x(X):无符号的十六进制整数

再组合上长度修饰符:

  1. h:表示short类型
  2. l:表示long类型
  3. ll:表示long long类型

代码示例

声明一个无符号的int整数变量,就可以用以下代码进行读写:

代码块 3. 读写整数代码示例1

unsigned int n;

scanf("%u", &n);    // 读无符号十进制整数
printf("%u\n", n);    // 打印无符号十进制整数
scanf("%o", &n);    // 读无符号八进制整数
printf("%o\n", n);    // 打印无符号八进制整数 
scanf("%x", &n);    // 读无符号十六进制整数
printf("%x\n", n);    // 打印无符号十六进制整数

如果想要读写short类型,只需要在d、u、o、x说明符前加长度修饰符h:

代码块 4. 读写整数代码示例2

short n;

scanf("%hd", &n);   // 读十进制short整数
printf("%hd\n", n);   // 打印十进制short整数

如果想要读写long类型,只需要在d、u、o、x说明符前加长度修饰符l(若是long long类型,则加ll):

代码块 5. 读写整数代码示例3

long n;

scanf("%ld", &n);   // 读十进制long类型整数
printf("%ld\n", n);   // 打印十进制long整数
  1. 当使用scanf读取数据时,确保使用&运算符取得变量的地址。
  2. 调用printf函数打印数据时,不要忘记用换行符"\n"刷新缓冲区。

3. 整数类型能够执行的操作

对于整数类型能够执行的操作,我相信大家都再熟悉不过了。它可以进行以下操作:

  1. 算术运算,比如加减乘除和取余。
  2. 比较运算,比如等于(==)、大于小于等。
  3. 赋值运算,比如赋值(=)、扩展赋值(+=、-=等)。
  4. 位运算,左移右移等。

其中,和同为数值类型的浮点类型相比,位运算是整数类型比较独特的操作,需要记住。

4. 整数类型编码 (拓展)

整数类型分为两类:

  1. 无符号整数
  2. 将所有的二进制位都作为数值位,在内存中它的编码表示就是这个整数的二进制表示,非常简单。
  3. 取值范围就是[ 0, 2该整数类型位长-1],比如unsigned short一般占2个字节,位长是16,它的取值范围就是[0, 65535]
  4. 有符号整数,关于有符号整数在内存中的存储表示,可以直接参考文档补充_有符号整数。这部分内容属于《计算机组成原理》的一部分,建议每一位C程序员都要掌握。

有符号整数规则的总结

如果你实在懒得看文档,那么也请至少记住以下结论,会非常有用:

  1. 原码
    1. 其有效数字是该数绝对值的二进制表示。
    2. 最高位是符号位,如果是负数符号位是1,正数符号位是0。
    3. 有效数值和最高位之间如果有空隙,加0。
    4. 最高位是符号位,其它位都是数值位。
  2. 反码
    1. 正数的反码与原码相同。
    2. 负数的反码是对其原码逐位取反,但符号位除外。
  3. 补码
    1. 正数的补码与原码相同。
    2. 负数的补码是在其反码的基础上再加1。

计算机当中存储整数都是以补码的形式存储的,目的是为了统一加减法为加法,降低CPU设计的复杂性,提高性能。

补码有以下两个重要的特性(假设x是一个n位整数):

  1. x + (-x) = 1, 000…02进制补码(其中0一共有n个,高位的1溢出被舍掉) = 0
  2. x + (~x) = 111…12进制补码(其中1一共有n个) = -1

一道简单的思考题:

对于一个有符号整数10110101补,求它的相反数的二进制表示(求补码)。

四、浮点数类型

C 语言提供了三种浮点数类型,对应三种不同的浮点数格式:

  1. float
  2. double
  3. long double

这三种浮点型如何选择呢?

  1. 当对精度要求不高时 (比如只有一位小数的运算时),我们可以使用 float 类型。它最节省空间。
  2. 大多数情况下,我们都会使用 double 类型;
  3. 在极少数对精度要求非常高的情况下,才会使用 long double。

1. 浮点数的特点

C 语言标准并没有明确说明 float、 double、long double 类型究竟采用什么方式进行存储,不同的平台确实可以使用不同的方式存储浮点数。

但是现代计算机一般都采用IEEE 754标准作为浮点数的存储规范,也就是说 IEEE 754 是事实上的标准,C语言中的浮点数也不例外。

那么什么是IEEE754标准呢?

限于课程的进度,课堂上就无法给大家详细讲解了,请自行参考文档补充_IEEE754标准了解学习。IEEE754标准浮点数也是《计算机组成原理》的基础内容,你可以通过它稍微领略一下计算机组成原理的魅力。

当然,不管你是否熟知这个标准,我们都要给大家简要介绍一下IEEE754标准浮点数的特点:

  1. 浮点数本质上是二进制的科学计数法表示,所以浮点数可以用较小的存储空间,表示非常大和非常小的数。

    很简单的道理,比如:1 * 10-100 这个数就非常小,1 * 10100这个数就非常大。

  2. 浮点数的精度是有限的。浮点数都只能保证在一个有限的有效数字内,浮点数是比较准确的。

下面的表格就描述了遵循 IEEE 标准的浮点数的特征:

表 2. IEEE 标准的浮点数特征表

类型所占空间(一般情况)大致取值范围精度
float4个字节大约 ±3.403E38基本保证6位有效数字精确
double8个字节大约 ±1.798E308基本保证15位有效数字精确

注:

  1. 表格中给出的大致取值范围是非常不准确的,浮点数存在规格化和非规格化的区别,所以它们的取值范围是不能用连续区间表示的,但为了简单起见就直接这么写了。
  2. long double 类型没有显示在此表中,因为它的长度可能随机器的不同而变化,最常见的是 80 位和 128 位。它往往取值范围比 double 类型更广,精度也更高。但某些平台也存在long double等同于double的情况。

2. 浮点数的不精确是绝对的

注意:浮点数的不精确是绝对的!!!

有些同学看到浮点数有精度限制,比如float可以**“基本保证6位有效数字精确”**,那么是不是我只要在6个有效数字内使用float就能保证数据准确呢?

答案当然不是,浮点数的不精确是绝对的,即便是在有效数字内。

这主要是因为某些十进制小数,转换成浮点数用二进制存储,会出现无限循环的情况,此时有限位数的浮点数必然是无法精确表示无限循环小数的!

比如十进制小数0.1转换成二进制表示:

十进制正小数(0.开头)转换成二进制,先用小数部分乘以2,取结果的整数部分(必然是1或者0):

  1. 然后小数部分继续乘2
  2. 直到小数部分为0,或者已经达到了最大的位数
  3. 最终的结果(0.开头)正序排列

0.1 * 2 = 0.2 —> 取整数部分0

0.2 * 2 = 0.4 —> 取整数部分0

0.4 * 2 = 0.8 —> 取整数部分0

0.8 * 2 = 1.6 —> 取整数部分1

0.6 * 2 = 1.2 —> 取整数部分1

0.2 * 2 = 0.4 —> 取整数部分0

0.4 * 2 = 0.8 —> 取整数部分0

0.8 * 2 = 1.6 —> 取整数部分1

所以十进制小数0.1转换成二进制小数是:0.000110011001100…(1100四位无限循环)

于是我们可以得出结论:

很多十进制小数转换成二进制时,会存在循环小数的情况。那么有限有效数字的浮点数就不能准确表示这个数字了,那些超出表示位数的数据就被截断丢失了,数据失真,精度丢失。这种小数并不少见,所以浮点数的不精确是绝对的。

总之:

浮点数执行的浮点数运算,只是在广泛的数字范围上较为精确而快速的近似运算,当你选择使用浮点数进行运算时,那就意味着数据准确对你而言已经不重要了,也不可能了。

所以不要尝试使用浮点数进行精确的小数运算,不过好在C语言也极少在需求精确小数运算的场景中使用。

3. 几道浮点数精度问题的练习题

读下列代码,思考它们的输出是什么以及原因。

代码块 6. 浮点数练习题1

// 定义不同类型的浮点数
float a = 0.1f;
double b = 0.1;
long double c = 0.1L;

// 打印这些值,但保留更多的位数
printf("float: %.20f\n", a);
printf("double: %.20lf\n", b);
printf("long double: %.20Lf\n", c);

代码块 7. 浮点数练习题2

float a = 0.1f;
double b = 0.1;
printf("%d\n", a == b);

float c = 0.1F;
// 中间有10个0
float d = 0.100000000001F;
printf("%d\n", c == d);

4. 浮点数字面值

在C语言中,浮点数字面值常量的书写方式比较灵活,有多种方式。例如,下面都是 57.0 的有效表示方式:

57.0

57.0e0

57E0

5.7e1

5.7e+1

.57e2

570.e-1

也就是说,在C语言中,浮点数的字面值常量必须包含:

  1. 小数点
  2. (或者)指数,即E或者e,表示乘以10的xx次方。

默认情况下,浮点数字面值常量都是 double 类型。如果想要改变类型:

  1. 如果需要表明以单精度方式存储,可以在末尾加字母 F 或 f。如 57.0F
  2. 如果需要以 long double 方式存储,则在后面加 L 或 l(最好不要用小写L),如 57.0L

5. 读/写浮点数

在前面,我们已经知道可以使用转换说明符 %f 来读写 float 类型的数据。读写 double 和 long double 类型所需的说明符与 float 略有不同。参考下面的代码:

读写 double 类型的值时,需要在 f 前面添加字母 l。

代码块 8. 读写double类型浮点数

double d;

scanf("%lf", &d);
printf("%lf", d);

代码块 9. 读写long double类型浮点数

long double ld;

scanf("%Lf", &ld);
printf("%Lf", ld);

6. 浮点数类型能够执行的操作

浮点数类型能够执行的操作大体上和整数类型是一样的,只不过少了一个位运算

五、字符类型

所谓字符,指的是像字母,汉字、标点等类似的符号。那么字符型,就是用来存储表示单个字符的数据类型。

很明显,计算机只能存储二进制数据,不可能也没能力直接存储一个符号(字符)。

计算机中存储字符,实际是通过存储一个整数值来映射对应的字符。所以编程语言中的字符类型普遍都可以作为一个整数类型对待。

比如:

现在我规定一个整数值97对应字符"a",那么计算机中存储整数97,在字符类型中就表示存储字符"a"。

于是我们就有了以下概念和结论:

  1. 一个用来映射对应字符的整数值,称之为该字符的**“编码值”**。
  2. 如果用一张表格来存储所有字符和编码值的映射关系,这就是编码表

所以我们了解一门编程语言的字符类型,最重要的就是弄清楚它所基于的编码表。

1. C语言的字符类型

C语言的字符类型,和大多数编程语言的字符类型一致,都是用于存储单个字符。

在绝大多数机器平台下,char只占用1个字节的内存空间,和整数类型一样,它同样有无符号和有符号的区别:

  1. 无符号char类型(unsigned char),它可以表示存储编码值范围在[0, 255]内的字符。同时你可以把它当成此范围内的一个整数类型。
  2. 有符号char类型(signed char),它可以表示存储编码值范围在[-128, 127]内的字符。同时你可以把它当成此范围内的一个整数类型。

注意:

不同平台,直接写char作为类型的默认有无符号是不同的,所以为了跨平台考虑,严谨的C语言代码应该使用unsigned charsigned char来表示char类型。当然一般的代码是不用如此的,现代多数编译器平台的char类型默认是有符号的。

知道了上述概念后,那么C语言的char类型的编码集是什么呢?

很可惜,C标准也没有规定这一点,不同平台可以选择不同的编码表。但是作为世界上最早和使用最广泛的编码表——ASCII码表,C语言的char类型总是兼容它的。

ASCII码表中的字符需要1个字节来存储,但只使用7位,最高位事实上是空的,所以它一共可以存储128个字符,编码值在[0, 127]范围内。

关于ASCII码表,可以参考本小节的末尾附录: ASCII码表

大体上我们记住以下几个特殊的字符,在ASCII码表中的编码值即可:

‘’ = 0 ----- 编码值为0的字符是一个绝对空字符

’ ’ = 32 ----- 空格字符编码值为32

‘0’ = 48 ----- 数字0的编码值是48

‘A’ = 65 ------ 字母A的编码值是65

‘a’ = 97 ------ 字母a的编码值是97

由于C语言自身char类型设计的局限性,尽量不要使用char类型去表示和操作非ASCII码表中的字符,这会带来很多麻烦。

2. 字符字面量

C语言中的字符字面量非常简单,在代码中用单引号’'引起来的字符就是一个字符字面值常量。比如:

代码块 10. 字符字面量-演示代码

char ch;

ch = 'a';
ch = 'A';
ch = '0';
ch = ' ';

除此之外,像转义序列比如’\n’也被视为字符字面量的一种,下面我们一起来学习一下C语言中的转义序列。

3. 转义序列

什么是转义序列?

  1. 转义序列,也叫转义字符,就是用反斜杠(\)加上一个特殊的字符(串)用来表示一个全新的字符。
  2. 因为这个全新的字符已经和原先字符(串)的意思大不相同,所以整体称之为转义序列。

转义序列有啥用呢?

  1. 实际上转义序列可以用来表示任何一个普通字符,但普通的字符直接书写给出即可,使用转义序列的意义不大。
  2. **转移序列最重要的用途是,表示那些无法用键盘直接输入的控制字符。**比如在ASCII码表中,那些换行,翻页等等字符。

下面表格罗列了C语言常见的一些转义序列及其含义:

表 3. C语言常见转义序列及其含义

作用转义序列作用转义序列
响铃\a垂直制表符\v
退格\b反斜杠\
换页\f问号?
换行\n单引号
回车\r双引号"
水平制表符\t

大多数同学可能都会好奇一个问题:回车(‘\r’)和换行(‘\n’)不是一回事吗?

确实,它们确实不是一回事:

  1. 转义字符中的回车,含义来源于早期的打字机设备,它表示将打印头(光标)移动到行首,它和换行没什么关系。
  2. 转义字符中的换行表示结束某一行,转而从下一行的开头开始。

这里需要注意的一个细节就是,不同平台的换行符的不同,带来的文本编辑的跨平台问题。比如:

  1. Windows:文本编辑中使用"\r\n"作为换行符
  2. Linux/Mac平台:文本编辑中使用"\n"作为换行符

后续我们讲Linux还会给大家重点强调这一点。

除了上述转义序列,我们还可以使用以下两种方式,自由的表示不同编码值的字符:

  1. 八进制转义序列,即"\ooo",其中"ooo"是一个三位八进制数。
    1. "ooo"默认即是八进制数,不需要以0开头。
    2. 由于char类型的限制,‘\ooo’最大取’\377’,而不是’\777’。
    3. 也就是说,这种方式可以表示编码值在[0, 255]范围内的字符。
  2. 十六进制转义序列,即"\xhh…“,其中”\x"是固定形式,"hh…"是一个十六进制数,不限制长度,但常用的是两位十六进制数。
    1. "hh…"取两位时,也就是’\FF’,这种方式同样可以表示编码值在[0, 255]范围内的字符。
    2. 虽然理论上不限制长度,但一般不建议超出两位,因为它的结果是不可预测的。

参考下列代码示例:

代码块 11. 转义序列-演示代码

printf("\x61\n"); // 十六进制61也就是十进制97, 此转义序列表示字符a
printf("\141\n"); // 八进制141也就是十进制97, 此转义序列表示字符a

// 转义序列可以整体作为一个字符字面值
printf("%c\n", '\x61');

这种方式总体不常用,了解即可。

4. 字符类型能够执行的操作

C语言的char类型在绝大多数时候,可以直接作为整数类型进行处理。(当然,下面我们会讲不能作为整数处理的情况)

比如:

直接将char类型当成整数类型进行赋值和算术运算:

代码块 12. char类型操作-演示代码1

char ch;
int i;

i = 'a';        /* int i的取值是97 */
ch = 65;        /* ch表示存储编码值为65的字符,也就是A */
ch = ch + 1;      /* 编码值+1,此时ch表示字符B */
ch++;         /* 编码值++,此时ch表示字符C */

// 遍历所有大写字母
for (ch = 'A'; ch <= 'Z'; ch++) {
// ....
}

代码块 13.

char c1 = 'A', c2 = 'B';
printf("%d\n", c2 > c1);  // 结果是1,说明c2大于c1,也就是字符B大于A

这种设定,使得排在ASCII码表后面的字符就越大,因为编码值越大。这种排序的规则,被称为**“字符的字典顺序”**,这是一个非常有用的概念。(后面课程会用到)

当然诸如位运算这样整数可以进行的运算,char类型也可以执行。

最后,我们还是要注意:

虽然字符可以直接作为整数处理,但还是尽量在处理字符时使用char类型,而不是滥用char类型,以至于写出一些很奇葩的代码:

'a' * 'b' / 'c';   /* 这个表达式的结果是96,但明显过于奇葩,不要这么用 */

下面,我们将介绍char类型作为字符类型,可以进行的一些独特的操作。

1. 字符处理函数

现在我提出一个需求:

给你一个字符(任意字符),在确定它是小写字母的前提下,将它转换成对应大写字母。

怎么做呢?

利用char类型本质存储编码值的特点,你可能会想到下面的实现:

代码块 14. 手动实现将小写字母转换成大写-参考代码

char ch = 'a';
if (ch >= 'a' && ch <= 'z'){
  // 是小写字母
  ch = ch - 'a' + 'A';
}

这个实现是OK的,但实际上并没有必要。因为C语言标准库,提高了一系列字符处理函数,其中就包括此功能。直到调用标准库函数实现功能,不仅代码更简洁,可读性也会更好。

下面我们来看一下常用的字符处理函数。

首先,使用字符处理函数,需要包含头文件<ctype.h> 。

这些字符处理函数,又可以分为两大类:

  1. 检查字符类型函数
  2. 字符大小写转换函数

这些函数的声明如下:

代码块 15. 常用字符处理函数的声明

// 检查字符类型函数
int isalnum(int c);     /* Is c alphanumeric? 检查字符c是否是一个字母或数字 */

int isalpha(int c);     /* Is c alphabetic? 检查字符c是否是一个字母 */

int isblank(int c);     /* 检查字符c是否是一个空白字符(只包括空格、制表)*/
int isspace(int c);     /* 检查字符c是否是一个空白字符(包括空格、制表、换行、换页等)*/

int isdigit(int c);     /* Is c a decimal digit? 检查字符c是否是一个十进制整数 */

int islower(int c);     /* Is c a lower-case letter? 检查字符c是否是一个小写字母 */
int isupper(int c);     /* Is c an upper-case letter? 检查字符c是否是一个大写字母 */

int ispunct(int c);     /* Is c punctuation? 检查字符c是否是一个标点符号 */

int isxdigit(int c);    /* Is c a hexadecimal digit? 检查字符c是否是一个十六进制数 */

// 字符大小写转换函数
int tolower(int c);   // 如果字符 c 是大写字母,则转换为对应的小写字母;否则,返回原字符。
int toupper(int c);   // 如果字符 c 是小写字母,则转换为对应的大写字母;否则,返回原字符。

这些函数都不要死记硬背,你只需记住C语言的标准库提供了这些功能就可以了。

到了实际需要用的时候,如果记不起具体的函数名和调用方式,也没有关系。只要你还大体上记得有这个函数能实现xx功能,就可以通过查询的方式很快找到并使用它。

这里推荐一个比较权威的,查询C语言标准语法、库的网站:C++ Reference

2. 读/写字符

和整数类型一样,你也可以选择使用 scanf 和 printf 读/写字符数据,使用的转换说明是"%c"。

例如以下代码:

代码块 16. scanf 和 printf 读/写字符数据-参考代码

char ch;

scanf("%c", &ch); 
printf("%c", ch); 

但需要注意的是:

scanf函数在读字符时,不会跳过前面的空白字符。如果需要跳过前面的空白字符,则要在转换说明符 %c 前面加一个空格:

scanf(" %c", &ch);  

scanf格式串中的空格意味着"跳过零个或着多个空白字符"。

总体上而言,对char类型字符数据进行读/写,我们更推荐使用以下两个标准库函数。即getchar和putchar函数!

首先,这两个库函数的使用需要先包含头文件<stdio.h>,它们都是用于处理单个字符操作的函数。

putchar函数用于将单个字符打印到显示器上,其函数的声明如下:

int putchar(int c);

其作用是向标准输出缓冲区(stdout)写入一个字符,并且直接返回该字符作为返回值,如果写入过程发生意外错误,该函数会返回EOF(End of File)

这就意味着此字符会暂存缓冲区,直到缓冲区满、遇到换行符或者程序结束等场景时,缓冲区刷新,内容才会显示在屏幕上。

一个putchar函数调用的示例如下:

代码块 17. putchar函数调用-演示代码

char ch = 'A';
putchar(ch);  // 将字符A写入到标准输出缓冲区中
putchar('\n');  // 换行,刷新缓冲区

此函数调用不需要任何参数,它会从**标准输入缓冲区(stdin)**中读取一个字符,并且把读取到的字符作为返回值返回。如果已经读取到流(数据源)的末尾或者发生意外错误,此函数会返回EOF。

一个getchar函数调用的示例如下:

代码块 18. getchar函数调用-演示代码

int ch;  // 注意这里是int类型,以便可以存储EOF
printf("Enter a character: ");
ch = getchar();  // 读取一个字符
printf("You entered: ");
putchar(ch);  
putchar('\n');

注意:

  1. getchar函数在读字符时,仍然不会自动跳过空白字符。
  2. putchar 和 getchar 函数是针对char类型字符处理专门优化的函数,它们的效率要高于 printf 和 scanf,处理char数据请优先使用它们。

一个重要的惯用法

在编程中,惯用法是指一种常用的编码模式或表达方式,类似汉语中的成语,是程序员前辈们的智慧结晶。惯用法往往代表了处理某种场景的最佳手段。

在C语言中,我们经常需要从流中读取数据,比如getchar函数就是从标准输入流(stdin, 缓冲区)中读取数据,为了确保将流中的数据读完,我们经常会使用以下惯用法:

代码块 19. 循环读取流中数据-惯用法

int ch;   // 使用int来存储getchar的返回值,因为它可能返回EOF
int count = 0;  // 统计总字符数
while ((ch = getchar()) != '\n') {  // 读取到流中的换行符就结束循环
  count++;
  printf("第%d次读到的字符是%c\n", count, ch);
}
printf("输入的字符一共有%d个\n", count);

注意:

  1. EOF在大多数平台下实际上就等于-1,但它并不是任何字符,只是一个表示流已经读到末尾的标记、特殊值。所以不应该用char类型作为getchar()函数的返回值,一般我们习惯使用int类型作为函数返回值。
  2. ch = getchar()赋值运算符组成的赋值表达式是有值的,就是"="右边的表达式的取值,也就是getchar()函数的返回值。
  3. 在读文件流时,只需要把"!="的条件从换行符改成EOF,就是一个通过循环将文件数据读取完的惯用法。(后面文件流会讲)

实际上,不仅是C语言,C++和Java也有几乎一模一样的惯用法,只不过是结束读取流的条件不同。

3. 课堂练习题

编写一个程序实现以下需求:

  1. 用户键盘录入一整行字符数据,包含数字和字母。
  2. 当用户按下回车键后结束数据录入。
  3. 请统计用户输入的一整行字符数据中,数字和字母分别有多少个。

这个题目就用到了我们上面提到的惯用法,也会用到两个上面提到的字符处理函数。

参考代码如下:

代码块 20. char类型课堂练习-参考代码

int ch;
int letters = 0;  // 字母的数量
int nums = 0; // 数字的数量

printf("请输入一行字符,包含数字和字母,按回车结束:\n");

while ((ch = getchar()) != '\n') {
  if (isalpha(ch)) {  // 检查是否为字母
    letters++;
  }else if (isdigit(ch)) {  // 检查是否为数字
    nums++;
  }
}

printf("字母的数量: %d\n", letters);
printf("数字的数量: %d\n", nums);

以上。

六、C语言的数据类型转换

C语言的不同类型之间是允许进行数据类型转换的,这些类型转换可以分为两大类:

  1. 隐式类型转换(自动类型转换):由编译器自动处理和完成类型转换,程序员无需进行任何额外操作,类型转换自动完成。
  2. 强制类型转换(显式类型转换):由C程序员通过强制类型转换的语法,手动实现类型的转换。

下面我们先讨论隐式类型转换,再讨论强制类型转换。

1. 隐式类型转换

在C语言代码中,隐式类型转换主要发生在以下场景中:

1.赋值运算中的隐式类型转换

当给变量赋值时,如果赋值表达式右边值的类型和左边变量的类型不匹配,则赋值表达式右边值的类型会被隐式转换为左边变量的类型。

参考下列代码:

代码块 21. 赋值运算中的隐式类型转换-参考代码

int a = 10.1;   // 隐式类型转换,数据截断
char b = 97.9f;   // 隐式类型转换,数据截断
double c = 0.1;
float d = c;    // 隐式类型转换,数据精度丢失

printf("%d\n", a);
printf("%c\n", b);

printf("%.20lf\n", c);
printf("%.20f\n", d);

由于C语言在赋值过程中的这种隐式类型转换机制,所以在赋值时要避免表达式右值超出左边类型的取值范围,否则你将会得到一个你也搞不清楚取值是什么的变量。

代码块 22. 赋值运算中的隐式类型转换-参考代码2

char a;
int b;
float c;

a = 100000;   // WRONG 超出char范围,隐式转换引发未定义行为 
b = 1.0E20;   // WRONG 超出int范围,隐式转换引发未定义行为
c = 1.0E100;  // WRONG 超出float范围,隐式转换引发未定义行为

将浮点常量赋值给 float 类型变量时,一个好的习惯是在常量后面加字符 f。如:f = 3.14f。如果没有后缀 f,那么字面值常量 3.14 是 double 类型,会引发隐式转换。

2.函数相关的隐式类型转换

在使用函数时,也有两个比较常见的隐式类型转换过程:

  1. 当函数调用时实参类型与形参类型不匹配,传递的实参会被隐式转换为形参类型。
  2. 在使用return表示返回值时,如果return后面的值类型和返回值类型不匹配,也会进行隐式类型转换。

参考下列代码:

代码块 23. 函数相关的隐式类型转换-参考代码

void fun(int a) {
  printf("函数调用传入的参数a是:%d\n", a);
}

float fun2(void) {
  return 0.1; // 隐式类型转换,精度丢失。double --> float
}

int main(void) {
  fun(10.5);  // 函数的形参类型是int,传入浮点数发生隐式类型转换,导致数据截断
  printf("%.20f\n", fun2());

  return 0;
}

实际上,最常见的还是第三种情况:

当不同类型的参数共同组成一个表达式时,为确保表达式最终结果的类型是唯一的,编译器会自动进行隐式类型转换。

这种隐式类型转换又可以细分为两种情况:

  1. 整数提升
  2. 常用算术转换

下面分别讲一下这两种情况。

1. 转换等级(重要)

为了更好的描述转换的规则,C99语言标准给C语言的基本数值类型都规定了一个转换等级,它从高到低依次为:

  1. long double
  2. double
  3. float
  4. long long、unsigned long long
  5. long、unsigned long
  6. int、unsigned
  7. short、unsigned short
  8. char、signed char、unsigned char

实际上这个等级,就是按照表示范围和精度从大到小将所有的数值类型排序。

2. 整数提升

**当表达式中仅有int以及int以下等级的类型参与时,表达式的结果一律是int(或者unsigned int)。**比如:

  1. char + char = int
  2. short + short = int
  3. char + short = int

这是因为在C语言的设计中,转换等级小于int的类型参与表达式运算时,先将它们提升到int(或unsigned int),这种语法设计就叫做"整数提升"。

这么设计的目的也很简单:

因为short和char在参与算术运算时,它们的取值范围都太小了,如果char + char = char成立,那么会经常发生溢出,而溢出往往导致数据失真。

3. 常用算术转换

当多个不同数据类型的操作数共同组成一个算术运算表达式时,表达式的最终结果遵循以下规则:

  1. 如果操作数中有一个是 long double 类型,其余操作数都会被隐式转换为 long double
  2. 否则,如果有一个是 double 类型,其余操作数都会被隐式转换为 double
  3. 否则,如果有一个是 float 类型,其余操作数都会被隐式转换为 float
  4. 否则,如果有一个是 long long 类型,其余操作数都会被隐式转换为 long long
  5. 否则,如果有一个是 long 类型,其余操作数都会被隐式转换为 long
  6. 如果以上类型在表达式中都没有出现,则遵循"整数提升"原则。也就是说结果是int或unsigned int。

这个规则看起来很复杂,其实一句话就总结了:

表达式的最终结果类型是,表达式中取值范围和精度最大的那个操作数的类型。

这种语法设计就叫做"常用算术转换"。

4. 注意事项(重要)

在讲上述表达式相关的隐式类型转换时,我们刻意避开了无符号的整数类型。

实际上C语言有以下明确的语法规定:

同一转换等级的有符号整数和无符号整数一起参与运算时,有符号整数会转换成对应的无符号整数。

也就是说:

unsigned + int = unsigned

unsigned long + long = unsigned long

无符号数在C语言其实是非常麻烦和坑爹的,我们来看两个例子:

无符号整数会带来转换等级的复杂性

你认为:

unsigned + long 结果是什么类型啊?

你可能觉得long类型等级更高,结果应该是long类型。但实际上在VS的MSVC编译器下这个结果类型是unsigned long。

这是因为在VS的MSVC编译器平台下,int和long类型的大小是一致的,都是4个字节。

因此unsigned long被视为转换等级的最高级别,所以结果就是unsigned long。

这说明由于无符号整数的参与,转换等级变得需要考虑平台和编译器等额外因素了,这无疑增加了编程的复杂性。

当然,这个例子最多导致一些类型的误判,不算太坑。但下面的例子就是一个纯粹的**“陷阱”**了。

无符号数参与的类型转换将导致直觉上的逻辑错误

请看以下代码:

代码块 24. 无符号类型转换引发直接逻辑错误-演示代码

int a = -10;
unsigned b = 100;

if (b > a){
  printf("b > a is true.\n");
}else{
  printf("b > a is false.\n");
}

printf("%u\n", (a + b));

请问程序的两行输出都是什么呢?

答:

  1. b > a is false.
  2. 90

这是因为**(为了方便描述,设32位无符号数系统的"模"为N,N = 32位无符号数的最大值 + 1, 即N = 232)**:

  1. 当表达式中同时存在有符号数和无符号数时,有符号数会被隐式转换为无符号数。在这个例子中,a 是一个有符号整数值为 -10,这个数的补码形式是 11111111 11111111 11111111 11110110。当将这个补码解释为无符号整数时,它代表的是一个非常大的正数,具体来说,是N - 10( 即2^32 - 10,实际上这个数是最大值 - 9)。

    注:N - 10是怎么来的呢?

    N是模且系统中没有负数,于是-10就可以转换成模相关的一个加法从而去掉负号,-10在这个系统中就等价于 +(N - 10),于是-10在此系统中变成了一个正数。

  2. 明白上面代码的原因后,那么a + b = 90,应该也是非常容易理解的了:

    1. a转换成无符号数结果是N - 10,那么再加上100,结果就是N - 10 + 100 = N + 90
    2. 但N是系统的模,任何达到或超出模的数值,都会从0开始计算。
    3. 所以N + 90,结果就是90

总之,由于无符号数的类型转换,给程序员带来了极大的麻烦,这是C语言的一个常见"设计陷阱"。

针对无符号数的类型转换,我们给出以下建议:

  1. 在一般的应用级C程序开发中,无符号整数是用不到的,往往只有在底层开发中才会使用它。
  2. 鉴于无符号整数带来的一系列复杂性和坑爹的陷阱,请大家不要在日常代码中使用无符号数。
  3. 假如你有一天能接触到底层开发,有使用无符号整数的需求,也建议不要混合使用无符号整数和有符号整数。

2. 强制类型转换

C语言的隐式类型转换强大且灵活,在正确使用的情况下是非常方便的。但为了更加主动的控制类型转换,在很多时候,我们还需要进行手动的强制类型转换。

强制类型转换的语法非常简单,如下:

(type_name) expression    // type代表想要强转成的类型

你只需要把需要进行强转的表达式(或值)前面加上"(type_name)"即可。

在C语言中,"(type_name)"被视为一元运算符,而一元运算符的优先级是高于二元运算符的。(这意味着强转在一个表达式中往往会优先运算,这是一个非常重要的设计)

搞清楚强转的语法后,下面只要搞清楚它有啥用,或者说在什么场景下会用到它,就算是学会这个语法了。

强转在以下场景中比较常用:

  1. 在发生隐式类型转换的地方使用强制类型转换语法,显式地表明将要发生类型转换。如下列代码:

    代码块 25. 强制类型转换使用场景-演示1

    int a;
    float (int)b = a; // a会隐式类型转换成int,但这里用强转显式标注转换
    

    之所以这么做,目的是为了显式标注,提高代码的可读性。(而且隐式转换是有可能出现错误了,显式标注出来可以让程序员更容易注意到代码中的问题)

  2. 提升表达式结果的精度。一个非常典型的例子就是:一个表达式因所有操作数都是整数,结果会得到一个截断数据的整数,如两个整数相除。在这种情况下就可以使用强转来提升表达式结果的精度,如下列代码:

    代码块 26. 强制类型转换使用场景-演示2

    float result, result2;
    int a = 10;
    int b = 4;
    result = a / b;
    result2 = (float)a / b;   // (float)是一元运算符优先级高于/除号
    

    小数取整。这个比较简单,参考代码如下:

    代码块 27. 强制类型转换使用场景-演示3

    float a = 7.1f;
    int num = (int)a;   // 当然这里本身就会隐式类型转换,但强转标注出来会具有更好的可读性
    
  3. 计算浮点数的小数部分。参考代码如下:

    代码块 28. 强制类型转换使用场景-演示4

    float f = 3.14159, fractional_part;
    fractional_part = f - (int)f;
    
  4. 强转类型转换还经常用于避免数据溢出。比如下列代码:

    代码块 29.

    // 此表达式右值的结果在int范围内不会溢出,所以这里隐式转换就可以了
    long long millisPerDay = 24 * 60 * 60 * 1000; 
    
    // 此表达式右值的结果超出int范围溢出,这里如果只隐式转换就会数据失真,所以需要强转
    long long nanosPerDay = (long long)24 * 60 * 60 * 1000 * 1000 * 1000; 
    

**C语言的强制类型转换语法非常灵活,理论上允许程序员将变量从一种类型转换为任意的另一种类型。**但这种灵活的语法应当谨慎使用,多多思考,避免无意义或不安全的强转导致数据错误或者未定义行为。

七、给类型定义别名

给类型定义别名,是C语言中极其常用和重要的语法。我们可以使用typedef关键字用于为现有的数据类型创建新的名称,即类型别名

它的语法格式如下所示**(定义别名语句一般和预处理指令放在一起,放在文件的开头)**:

typedef 现有类型的名字 别名;

现在解释一下这个语法:

  1. typedef是一个关键字,读作"type define",表示定义类型别名。
  2. 现有类型可以是C语言的基本数据类型,也可以是复合类型如结构体等。
  3. 关于别名的命名规范,首先C语言没有强制的规范去约束别名的命名风格,但基于编程实践,给出以下几点要求:
    1. 别名应该是在原有类型名的基础上,更明确更清晰的表明类型作用的,应该具有好的可读性。“只有起错的名字,没有起错的外号。”
    2. 为了区分类型名和别名,建议别名和类型名采用不同的命名风格。比如类型名采用"下划线式命名风格",那么别名可以采用"驼峰命名风格"。
    3. 在C语言标准库中,常用"_t"作为后缀结尾表明此类型名是一个别名。常见的如:size_tint32_t等。我们也可以模仿采用这种风格,当然关于命名,一切以公司的要求为最高要求。

给某个类型起别名后,就可以在后续的代码中使用这个别名代替原有的类型名,在具体使用时,别名和原类型名完全等价。如下列代码:

代码块 30. 给类型定义别名-演示代码

typedef int E;

// 等价于int作为返回值
E test(void) {}

int main(void) {
  E a = 10; // 等价于 int a = 10;

  return 0;
}

以上。

为什么要给类型定义别名/定义别名的好处是什么

搞清楚定义别名的语法是非常容易的,对于这个语法的学习,我们重点应该是理解:为什么要给类型定义别名/定义别名的好处是什么?

其实不外乎以下三个优点:

  1. 提升代码的可读性。这个很容易理解,不多赘述。原类型名往往是一个通用的称呼,而别名是此场景下的一个精准描述。
  2. 提升代码的扩展性。这一点在后续数据结构阶段会体现的很明显,在后续课程我们将展开讲解这部分内容。
  3. 提升代码的跨平台性移植性。类型别名的语法最重要的用途就是增强代码的跨平台移植性,下面将详细讲一个作用。

类型别名如何提升跨平台性移植性?

我们都知道,C语言由于编译器、平台之间的差异,经常会出现同一个类型,但存储方式不同的情况。比如:

  1. int类型在绝大多数现代桌面平台下,占用4个字节32位内存空间。大多数应用级C程序员接触的int类型,也是4个字节的int类型。
  2. 但是在某些小型机器或者嵌入式平台下,int类型可能就会变成占用2个字节16位内存空间的整数类型。(因为要节省内存空间)

于是代码在跨平台移植时,就会出现以下问题:

int a = 100000;这句代码在32位存储int时没有问题,但如果int变为16位存储,就会出现数据溢出失真的问题。

那如何避免这种情况呢?

只需要在移植代码后,使用一个32位的整数类型来存储这个a就可以了。那么具体可以用以下两种方式实现:

  1. 直接把a声明为更大的整数类型(比如long),这样大家都能装得下了,移植时就避免了溢出情况。
  2. 为每一个平台选择最合适的类型。原平台继续用int,新平台使用更大的,比如long类型。

很显然,方式一会带来空间的浪费,性能下降,方式2是更好的选择。

那么如何实现方式二呢?

很简单,使用类型别名。

我们可以在原平台上,将int类型定义别名为BigInteger:

typedef int BigInteger;

在移植后,不需要改变任何其它代码,只需要改变这个别名定义中的int为一个合适的类型即可,比如:

typedef long BigInteger;

这样就可以在几乎不用修改任何额外代码的前提下,将整个代码移植到了新平台。这里也同时体现了定义别名,增强了代码的可扩展性。

在C语言标准库中,有非常多这样为了兼顾平台移植性而被定义的类型别名,比如:

  1. **size_t类型。**此类型在任何平台下,都代表一个无符号的整数类型。在大多数情况下,它被设计为和平台位数一致的存储大小,比如32位平台下,它就是一个32位的无符号整数。size_t非常有用常用,它广泛用于表示那些在逻辑上不会为负数的概念,如:

    1. 数组的长度
    2. 字符串的长度
    3. 某个数据结构中的元素、结点的数量
    4. 内存大小,比如sizeof运算符的结果类型就是size_t

    size_t非常常见常用,要记住这个类型别名。

  2. int8_t, int16_t, int32_t, int64_t: 无论任何平台下,分别表示确切的8、16、32、64位有符号整数。

  3. uint8_t, uint16_t, uint32_t, uint64_t: 无论任何平台下,分别表示确切的8、16、32、64位无符号整数。

总之,为类型定义别名非常有用,请大家要重视这个语法。以上。

八、C语言中的布尔值表示

在数据类型章节的最后,我们一起来看一下C语言中的布尔值表示,它是代码中十分常用的概念。

首先,C语言的基本数据类型中并没有提供类似Java/C++的布尔类型来表示true或者false。

所以,为了表示布尔值C语言规定:

  1. 任何非零值都被视为true(真)
  2. 任何零值都被视为false(假)

注:

整数类型和浮点类型都是数值类型,它们的零值和非零值很容易理解,但C语言还有一个比较特殊的指针类型。在指针类型中:

  1. 零值代表空指针,即NULL
  2. 非零值代表指针指向一片内存区域,即非空针。

这种无布尔类型的设计,在早期为C程序员带来了很大的麻烦,使得程序员经常能写出一些可读性差的丑陋代码。为了改变这一局面,从C99开始,C语言支持了独立的布尔类型_Bool,但它不属于基本数据类型。

为了让程序员更容易地使用布尔类型,C99还提供了一个头文件<stdbool.h>。当你包含这个头文件时,你可以使用以下标识符更好的来使用布尔类型:

  1. bool,实际上就是类型_Bool的别名。建议使用别名来使用该布尔类型,而不是_Bool
  2. true,表示真。实际上就是整数值1。
  3. flase,表示假。实际上就是整数值0。

以上。

关于在C语言中使用布尔值的建议

首先,我们强烈不建议大家直接把一个整数、指针类型变量作为布尔值在代码中直接使用。比如

代码块 31. 用整数值直接代表布尔值使用-演示代码

int a = 0;
if (!a) { // 等价于 a == 0
  printf("a is 0.\n");
}

这样的做法虽然使得代码简洁,但牺牲了很多可读性,使得代码不够直观明确的表达含义,在现代C编程中是比较得不偿失的。(如果把a改成一个指针类型,这个代码将更加丑陋)

所以应该写成以下格式:

if (a  == 0) 

除此之外,我们还建议,当你需要把布尔值作为函数的返回值和形参时,也尽量不要直接用int类型,而是包含头文件使用类型别名bool。

比如:

代码块 32. 使用bool布尔类型作为函数返回值和形参类型-演示代码

#include <stdbool.h>

bool fun(void) {
// ...
  return true;
}

总之,在现代的C编程中,我们更追求更好的可读性和明确性,尤其是当确定代码会在C99标准及以后的编译器平台上运行时,使用布尔类型bool是一个好习惯。

九、附录: ASCII码表

ASCII(American Standard Code for Information Interchange)美国信息交换标准代码),是基于拉丁字母的一套电脑编码系统,主要用于显示现代英语和其他西欧语言。

表格中的每一个十进制ASCII编码值,都映射一个字符。ASCII表是最基本的字符编码表,现在常用的编码表大多是兼容ASCII表的。

表 4. ASCII表参考

ASCII值控制字符ASCII值控制字符ASCII值控制字符ASCII值控制字符
0NUT32(space)64@96
1SOH33!65A97a
2STX34"66B98b
3ETX35#67C99c
4EOT36$68D100d
5ENQ37%69E101e
6ACK38&70F102f
7BEL39,71G103g
8BS40(72H104h
9HT41)73I105i
10LF42*74J106j
11VT43+75K107k
12FF44,76L108l
13CR45-77M109m
14SO46.78N110n
15SI47/79O111o
16DLE48080P112p
17DCI49181Q113q
18DC250282R114r
19DC351383S115s
20DC452484T116t
21NAK53585U117u
22SYN54686V118v
23TB55787W119w
24CAN56888X120x
25EM57989Y121y
26SUB5890Z122z
27ESC59;91[123{
28FS60<92/124|
29GS61=93]125}
30RS62>94^126`
31US63?95_127DEL

不要去尝试记忆ASCII码表,没有任何意义,需要用的时候查表即可。

文章来源:https://blog.csdn.net/weixin_44398687/article/details/135491413
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。