变量、类型和字面量#

一个例子#

变量、类型和字面量在C语言中处于重要的地位,不过在仔细理解这些概念之前,不妨先看一个例子:

/*var.c*/
#include <stdio.h>

int main(void)
{
    int var;
    var = 10;
    printf("%d\n", a);
    var = 20;
    printf("%d\n", a);
    return 0;
}
10
20

直观的理解这段代码,我们让var“等于”了两个数。第一次是10、第二次是20。 两次的printf()输出似乎也告诉我们这两个var有不一样的数值。这是怎么做到的呢?

我们在这个例子也看到了printf()的一个新用法:打印一个数值。 我们下面会看看到底怎么做。

变量(Variable)#

从数学上来讲,变量是一个“可以变化的量”。譬如 \(y = x + 3\) 中的 \(x\)。 从编程语言的角度来说确实如此。但是对于编程语言,我们还可以从另一个角度理解这个事情:

变量是内存(main memory)中的一块有名字的、固定大小的存储空间。 这块空间可以被程序写入数据,也可以被程序读取数据,这就是它为什么可以变化。

如果你不了解什么是内存……

内存是计算机中一块可以读写数据的储存空间。CPU可以直接读写其中的数据, 故程序的所有数据都会存储在这里1。不同于储存文件的外存,它容量较小, 在断电后数据会消失。但是它具有存取速度快的特性,这点是外存无法比拟的。

C语言中的对象(Objects)

对象是内存中的一块存储空间。对象有其大小、类型、生存期等特性,可以表示一个值。 C语言每一个对象都是由整数个字节(byte)组成。根据这个定义,变量是一个对象。 (注意,这和“面向对象(Object-oriented)”中的“对象”定义有区别。)

变量的声明(declaration)#

在使用变量之前,我们先要进行变量声明。

变量的声明

类型 变量名;

其中,变量名是一个标识符。

上述程序的第四行:

int var;

这一行声明了一个以int作为类型、var作为名称的变量。

这个变量的声明就是告诉编译器: “我们需要在内存中有一个int类型大小的空间,请你在编译时预留这样的一个空间。 并且我们把这个空间叫做var,请你在编译的时候将这个名字翻译成一个内存的地址(Adderss)。”

地址是什么?

内存中的一个位置叫地址。通常是用一个整数表示。有点像门牌号。

标识符(identifier)#

标识符标识了C语言中变量或和其他需要命名的实体,变量名就是一种标识符。

标识符是以大写字母、小写字母、数字和下划线_组成的字符串,且数字不能在开头。 标识符亦不能是关键词。例如,你不能取一个名叫return的变量。

标识符是我们随意取的,如果你把上面程序的var统统改成x,结果不会有什么不同。

Good-practice

给变量取能代表变量意义的名字是一种好习惯。比如某变量表示长度,就给他命名为length

顺带一提,函数名也是标识符。所以我们之前提到的命名约束对函数名也有效。

Attention

C语言对标识符长度没有限制,但是规定了有效起始字符为63个(C99)。 换言之,如果两个变量的起始63个字符一样,那就是一样的标识符。

Attention

尽量不要以_(下划线)开头命名标识符,那是为库和其他内部标识符预留的。

Attention

C语言是大小写敏感(case sensitive)的。如果两个标识符大小写不一样, 那么它们就是不同标识符。如Varvar

标识符的作用域(scope)#

一个标识符有它的作用域。作用域就相当于一个标识符的“作用范围”。 超出了这个作用范围,标识符就失效了。

对于一个变量来说,它的标识符所对应的作用域是: 从这个变量的声明开始,到这个声明所在的块结束

上面程序中我们的标识符var作用域是从声明:

int var;

开始,到main()的右括号结束。

我们现在“做点手脚”,把声明移到别的块中,看看会怎么样。

/*var.c*/
#include <stdio.h>

int main(void)
{
    {
        int var;
        var = 10;
    }
    printf("%d", var);
    return 0;
}

结果是编译器的一个报错。

Error

未定义标识符 "var" (var.c:10)

这是因为var的声明在内部的一个块中,等到下边使用var时,已经不在变量标识符的作用域中了。 编译器找不到var

Attention

同一个作用域中不能重复声明同一个标识符

例如下面的例子:

/*var.c*/

#include <stdio.h>

int main(void)
{
    int var;
    int var;
    var = 10;
    printf("%d", var);
    return 0;
}

会产生一个报错:

Error

“int var”: 重定义 (var.c:8)

这样的代码虽然不会引发上面的错误(因为两个var声明的块不一样,不是同一个变量):

/*var.c*/

#include <stdio.h>

int main(void)
{
    int var;
    var = 10;
    {
        int var;
        var = 20;
        printf("%d", var); /*print 20*/
    }
    return 0;
}

但是程序打印出来是20。因为:

Attention

如果给内部块声明一个和外部块某个标识符一样的标识符掩盖外部块的那个标识符。

类型(Type)#

类型是C语言中一个重要的概念。类型表示了一个值到底要如何存储占多大空间, 以及运算的规则

什么是静态类型(static typed)语言

如果一个语言如果在编译时检查类型,变量声明时必须声明类型,声明后不能做类型改变, 就是静态类型语言。C语言的变量声明是必须带着类型,且声明后不能做类型改变, 所以是静态类型语言。而有些语言声明变量时可以只声明变量名,运行时可以改变类型, 则称为动态(dynamic typed)语言。

相比于动态类型语言,静态类型语言不需要储存类型信息,且也较易检查错误。 但是变量改变的自由度较小。

C语言的类型分为四大类:算术(Arithmetic)类型枚举(Enumerate)类型派生(Derived)类型以及void(空类型)

关于void,C语言中将他视作一个类型占位符。它的意思可以是“无”,又或者是“任意类型”。不过:

Attention

void不能作为变量的类型。

Hint

到目前位置我们见过两个类型:intvoid。前者在我们本章的大量例子中, 都是以它来声明变量的。同时注意到int还是main()的返回类型。 (所以我们可以推出0是一个int类型的值。)void存在于main()的参数列表中, 表示这个函数“没有参数”。

接下来我们详细说说算术类型:

算术(Arithmetic)类型#

算术类型是一类能够执行算术操作的类型。 它们分为两个大类:整数类型和浮点类型。

Attention

所有算术类型名都是关键字

整数(Integer)类型#

顾名思义,整数类型的变量能够存储整数。一个整数类型除了决定它的位宽(占内存位数)以外, 还决定了它是否“有符号”。“有符号”表示这个类型能存储负数,“无符号”表示这个类型只能存储正数和0。 其中,整数类型中最基础的类型是int。它代表一个定长,有符号的整数。 在C标准中它至少有16位,不过现在通常64位的CPU能够支持的int位宽为32位。

整数在计算机内部是如何表示的

整数通常在计算机内部以二进制存储。假设一个长度为 \(N\) 位的无符号整数, 能够存储\([0, 2 ^ N - 1]\)区间内的整数。对于有符号整数,要拿出一位当符号位区分正负。 所以通常能够存储\([-2 ^ {N - 1}, 2 ^ {N - 1} - 1]\)区间内的整数。

int类型前面可以加以下的长度修饰符:shortlonglong long。 其中short intint更短,而long intlong long intint更长。 这些类型中的int也可以不写,直接写成shortlonglong long

还有一种类型我们通常也归为整数类型:字符类型char。字符类型可以储存美国信息交换标准代码 (American Standard Code for Information Interchange, ASCII)表中的任意一个字符, 如aA1+(等。char通常比short int更短。占一个字节,一般是8位。

字符为什么可以视为整数?

ASCII码表规定了127个字符(包括控制字符)与0到127的整数的对应关系,在计算机内部, 这些字符就是以对应的整数存储的。

charintlonglong long都可以前面加unsigned表示同样位宽的无符号整数。 特别的,signed char表示一个字节的有符号整数。

Implementation-defined

char本身是有符号还是无符号则是实现定义的。

Good-practice

尽量不要使用unsigned,除非特殊情况。

Tip

一般情况下,整数常用int作为类型。

以下是整数类型的位宽:

类型 位宽(C标准) 32位机器上通常的位宽 64位机器上通常的位宽
char 至少8 8 8
short 至少16 16 16
int 至少16 32 32
long 至少32 32 32或64
long long 至少64 64 64

(摘自zh.cppreference.com

我需要背下来这个表吗?

不需要。如果你需要整数类型表示值的范围的话,在<limits.h>的头文件中有一些常量供你使用。

类型 最小值 最大值
char SCHAR_MIN SCHAR_MAX
short SHRT_MIN SHRT_MAX
int INT_MIN INT_MAX
long LONG_MIN LONG_MAX
long long LLONG_MIN LLONG_MAX
unsigned char 0 UCHAR_MAX
unsigned short 0 USHRT_MAX
unsigned int 0 UINT_MAX
unsigned long 0 ULONG_MAX
unsigned long long 0 ULLONG_MAX

这些常量会被编译器翻译成为对应类型的值。

但是最好记一下int(32位)的范围:-2147483648 ~ +2147483647 (约为\(2 \times 10 ^ {10}\)

C语言规定了:所有整数类型位宽一定是字节的整数倍。且对于位宽,有: char \(\leq\) short \(\leq\) int \(\leq\) long \(\leq\) long long。 (极端情况下,允许一个机器的字节宽位64位,使得所有上述类型宽位都是64。)

C99标准中,还增加了_Bool类型表示布尔值。它亦可以看作一个整数类型。 它的长度足以存储0和1二值之一。其长度可能是一个字节,而非通常认为的1位。

浮点(Floating Point)类型#

浮点类型能保存浮点值的类型。

浮点值是什么?

通常意义上理解,浮点值可以保存小数。浮点值得名于“浮动的小数点”。 不同于“定点值”只能表示固定整数位长度和小数位长度的情况, 浮点值使用了二进制的科学计数法\(f = a \times 2 ^ p\), 然后存储尾数\(a\)和指数\(p\)(还有符号)。这样可以既可以表示绝对值很小的数 (如\(1.25 \times 2 ^ {-15}\)),也可以表示绝对值很大的数 (如\(2.5 \times 2 ^ {30}\)),只需调整一下指数就可以了。因为小数点不是固定的, 故称浮点值。浮点值的格式标准是IEEE-754。

浮点值一共有三种类型,从小到大分别是float(单精度浮点值)、 double(双精度浮点值)和long double

浮点值还支持-0.0(负零)、INFINITY(正无穷大)、 -INFINITY(负无穷大)和NaN(非数,Not a Number)这四个特殊值,它们有特殊的意义。

Attention

浮点值不是精确值,它们不能用作精确计算。可以尝试使用整数值进行精确计算, 再将它们用处理成小数的表示。

Hint

C标准中除了上述的实浮点类型以外,还有一类复浮点类型,可以表示虚数和复数。 但是C标准允许编译器选择性的支持这些类型,换言之,有些编译器可能不支持。 故在此不做阐述。

使用printf()函数输出数值的字符串表达#

我们在之前使用过printf()打印过字符串。接下来我们展示printf()如何打印数值。

首先我们说说如何给函数传入多个参数。如前面的例子里看到, 我们在括号中用逗号分隔的列表给函数传入多个参数。就像这样:

printf("%d", 12);

还有这样:

printf("%d %d", 12, 14);

跟数学里的多元函数 \(n = f(x, y, z)\) 差不多。

printf()中的第一的参数是一个字符串,我们叫他格式字符串(format string)。 我们在其中写入想要输出的内容,并使用%d代替我们想输出的int类型值。例如:

printf("The answer is: %d", 42);

会输出:

The answer is: 42

这里还有另外一个例子:

/*var.c*/
#include <stdio.h>

int main(void)
{
    int lhs, rhs, ans;
    lhs = 1;
    rhs = 1;
    ans = 2;
    printf("%d + %d = %d", lhs, rhs, ans);
    return 0;
}
1 + 1 = 2

Tip

你可以将同个类型的多个变量放在一起声明。声明时需要将它们用逗号分隔,放在类型后面。

格式字符串中,转换指定(Conversion specifications)是以%起头、以转换指定符结束的串。 它在printf()中并不直接输出,而是拿后面的参数去替代它输出。 转换指定中间则有一系列长度修饰符和其他修饰符。 转换指定符告诉printf()该怎么转换参数,而长度修饰符告诉它参数的长度, 其他修饰符则增添了一些其他信息。

Tip

如果你想直接输出一个%,请使用%%

转换指定符d指定的是一个有符号的十进制(decimal)整数输出, 下面列出了每个长度修饰符在d下对应的类型:

% hh h (无) l ll
d signed char short int long long long

(摘自zh.cppreference.com

譬如要以十进制输出一个long long类型的整数,那就使用%lld代表它。

转换指定符u指定的是一个无符号的十进制整数输出。 它的这五个长度修饰符对应的类型是上面这五种类型的无符号变体。 譬如要以十进制输出一个unsigned long long类型的整数,那就使用%llu代表它。

转换指定符f指定的是一个十进制小数输出, 默认情况下它是6位精度。

转换指定符e指定的是一个十进制科学计数法小数输出。

下面列出了每个长度修饰符在ef下对应的类型:

% (无)或l L
ef double(和float2 long double

(摘自zh.cppreference.com

如果要输出一个char类型的字符,则转换指定是%c2

Attention

在使用printf()时,请确保你除了格式字符串外传入的参数个数, 与格式字符串中转换指定的个数一致的。 且传入参数的类型与转换指定中的要求的类型按顺序一一对应。 如果不这么做,可能会导致不会出现意料中的结果,甚至是程序错误。 (这个问题可能编译器不会帮你检查到)

为何使用printf()有如此麻烦的限制

大多数C语言中的函数有固定的参数个数和类型, 然而prinf()(和其他一些输入输出函数)可以随自己喜好调整参数个数和类型。 由于这层特殊性,编译器(可能)不会帮你检查参数类型和个数,可以“蒙混过关”。 而在运行时,程序不会管类型信息,它会挨个取将固定大小的内存并“解释它”。 由于类型错误,导致取的位置和多少出现偏差,甚至取到别的位置去,解释也会出现偏差。 (不过现代的编译器虽不会报错,但可能会警告你。) 详细了解:变参数函数

赋值运算符(Assignment Operator)#

赋值运算符是我们要接触的第一个运算符。它将一个值赋与一个变量。

赋值表达式

左值 = 表达式

其中,左值是一个可以被修改的左值表达式。表达式必须能被表示成左值的类型。

运算符连接起两个表达式,构成一个新的表达式前面提到过,要让一个表达式成为语句,需要加;

什么是左值(lvalue)和右值(rvalue)

左值是一个对象的标识。凡是可以被赋值的(出现在赋值号左边), 都是左值。 (反之不一定成立:有些左值虽然表示一个对象,但是不可更改。) 而右值不标识对象,不可以被赋值,只能出现在赋值号的右边。

或者从内存的角度来看,左值表示了内存中的一个位置,可以存取, 而右值不是,右值只代表一个值,不能存入。

变量就是一个典型的左值。

左值/右值构成了表达式的值类别(value category),这是和类型独立的两个系统。 换言之,所有类型的值都有可能是左值或者右值。

Attention

=叫做赋值号(赋值运算符),等号通常指的是另外的符号(==)。

变量在赋值时,会将被赋值表达的值写入这个变量,其原有的内容会被清除。 试一下开头那个例子就知道了。

下面的例子给出了两个变量之间的赋值,赋值号=

#include <stdio.h>

int main(void)
{
    int foo, bar;
    foo = 10;
    bar = 20;
    printf("%d %d\n", foo, bar); /*10 20*/
    foo = bar;
    printf("%d %d\n", foo, bar); /*20 20*/
    return 0;
}  

如何交换两个变量之间的值?

一个朴素的想法是把一个变量赋给另一个变量。 但是把哪个变量赋给另一个变量都会导致原来的值丢失。这时候我们只需要一个临时变量, 存放将要被覆盖的值,再通过这个变量把值取回来就可以了。 下面是一个例子:

#include <stdio.h>

int main(void)
{
    int foo, bar, temp;
    foo = 10;
    bar = 20;
    printf("%d %d\n", foo, bar); /*10 20*/
    temp = foo;
    foo = bar;
    bar = temp;
    printf("%d %d\n", foo, bar); /*20 10*/
    return 0;
}

字面量(Literal)#

字面量,就是值用字面直接写出来的一个量。比如说20之类的。相较而言, 变量的值并不是用字面写出来的。字面量没有办法更改,是一种常量(constant)。

Attention

所有常量都不能被赋值。如果你尝试这么做,编译器检查出来并会报错。

整数字面量#

整数字面量

  1. 十进制数-整数后缀

  2. 0八进制数-整数后缀

  3. 0x十六进制数-整数后缀 0X十六进制数-整数后缀

其中,

  1. 十进制数是符合正则表达式:[1-9][0-9]*的串。
  2. 八进制数是符合正则表达式:[0-7]+的串。
  3. 十六进制数是符合正则表达式:[0-9 A-F a-f]+的串。

整数后缀可选,只能由:

  • 无符号后缀:uU或无,
  • 长度后缀lLllLL或无。

拼合而成。

整数字面量表示一个整数常量。注意到:

  • 八进制整数字面量0开始
  • 十六进制整数字面量0x/0X开始
  • 十进制整数字面量不由上面两种作为开始

在不指定任何后缀的时候,编译器会从int开始,挑选一个位宽最小, 范围恰能涵盖到这个值的类型,作为这个字面量的类型。也就是说,编译器会依次尝试以下类型:

  1. int
  2. unsigned int
  3. long
  4. unsigned long
  5. long long
  6. unsigned long long3

如果指定了后缀u(或U),则编译器只会寻找其中的无符号类型。 如果指定了后缀l(或L),则编译器只会long开始找。 如果指定了后缀ll(或LL),则编译器只会long long开始找

换言之,一个指定了llu后缀的整数字面量一定是unsigned long long类型的3

以下是一些整数字面量的例子:

42
017
0xFF
123456u
147L
0x1234ABCDEFll

浮点数字面量#

浮点数字面量

  1. 整数.小数-浮点后缀

    • 十进制尾数e指数-浮点后缀

    • 十进制尾数E指数-浮点后缀

    • 十六进制指示符-十六进制尾数p指数-浮点后缀

    • 十六进制指示符-十六进制尾数P指数-浮点后缀

其中,

  1. 中的整数小数至少选择其中之一。二者都是符合正则表达式:[0-9]+的串。
  2. 中的十进制尾数是一个拥有语法:

    整数.小数

    的串,整数小数至少选择其中之一,或者单独由整数构成。 二者都是符合正则表达式:[0-9]+的串。 指数是符合正则表达式:(-|+)?[0-9]+的串。

  3. 中的十六进制指示符0x0X十六进制尾数是一个拥有语法:

    整数.小数

    的串,整数小数至少选择其中之一,或者单独由整数构成。 二者都是符合正则表达式:[0-9 A-F a-f]+的串。 指数是符合正则表达式:(-|+)?[0-9]+的串。

浮点后缀可选,只能选自:fFlL

浮点数字面量表示一个浮点数常量。注意到:

  • 十进制浮点数字面量至少拥有小数点.或指数部分(Ee开始)中的一个。 (否则就是一个整数字面量
    • 当只拥有小数点的时候,至少拥有整数部分或小数部分中的一个,如1..1
  • 十六进制浮点数字面量0x/0X开始必须有指数部分(Pp开始)

十进制浮点数字面量的尾数会转换为一个十进制小数,指数部分表示了这个小数要乘以10的多少次幂。 合起来是一个科学计数法表示的小数。如1.5e3表示\(1.5 \times 10 ^ 3\)。指数是整数,可以为负。

十六进制浮点数字面量的尾数会转换为一个十六进制小数, 指数部分表示了这个小数要乘以2的多少次幂。 如0x1.8p1表示\((1 + \frac{8}{16}) \times 2 ^ 1 = 3\)4

以下是浮点后缀所对应的类型,不同于整数字面量,你需要显示地指定类型:

后缀 类型
fF float
double
lL long double

Attention

由于浮点数的二进制表示法,十进制的浮点数值不一定能够准确的存储。如0.1等。 通常某些不能完美转换的值会变为二进制表示的邻近较大或较小值

Hit

有一些特殊的浮点数值不能通过浮点数字面量表示。这些值在<math.h>中有常量供使用:

名称 含义
HUGE_VALF 过大而溢出float的值
HUGE_VAL 过大而溢出double的值
HUGE_VALL 过大而溢出long double的值
INFINITY 表示正无穷的值
NAN float类型的“非数”值

以下是一些浮点数字面量的例子:

1.0
0.1
1.
.5
1e0
.2e-5f
0x1p0
0x1.fP5l

字符字面量#

字符字面量

'字符'

其中,字符表示(基本源字符集的) 除了'或者\的一个字符,或者转义序列

不同于字符串字面量,字符字面量表示一个字符。 用''(一对单引号)包围。 某些字符用转义序列表示,比如'\n'表示换行符。

我们通常把字符字面量赋给一个char5变量。 在一般情况下字符字面量与char型兼容。

Attention

数字字符和对应的数字本身不相等。例如'0'0不相等。前者表示的整数值是48 (在ASCII中)。

初始化(Initalization)#

我们可以在变量声明时,就给它赋一个值,称之为初始化

声明与初始化

类型 声明序列;

其中,声明序列是由声明项组成的,由逗号,分隔的列表。声明项具有以下语法:

标识符 = 表达式

以下代码片段展现了一个初始化的例子:

int var = 0;
int foo = 1, bar = 2;

直观感受的话,这就是将声明和赋值表达式整合到一起。 上面的代码片段中我们声明了varfoobar三个int型变量。并初始化他们的值为0、1和2。

Good-practice

声明变量同时初始化是一种好习惯。

Attention

取一个没有被初始化的变量、也没有被赋值过的变量的值,其结果是不确定的。

当然,初始化能比赋值做更多的事情。

“常”类型限定符#

常类型限定符const用来加在类型前面,表示该类型的常限定变量。如:

const int a = 1;

含有const限定类型的变量无法被赋值。但是它们是左值。当然, 它们可以被(也应该被)初始化。如果尝试对常限定变量赋值,会有编译器报错。如:

/*const.c*/
int main(void)
{
    const int i = 3;
    i = 4;
    return 0;
}

编译器会报错:

Error

表达式必须是可修改的左值 (const.c:5)

Good-practice

比起直接用字面量(如:12等)来表达常数, 使用其他方式(如前文提到的常限定变量变量)来给常数指定一个名字更加好(如:months in year)。 这种方式有以下优点:

  • 可以清楚地知道常数的代表的含义。
  • 便于整体替换和修改(如将月数从12全部改为10)。

当然,常限定变量有以下的不足:

  • 它的作用范围和变量相同,只有范围内能使用。
  • 它是左值,意味着它可能在内存中占有空间(而字面量是右值,可以不占有)。
  • 有些地方不能使用常限定变量变量。

我们之后还会介绍其他的命名常量的方式。

本章练习#

Exercise

在一个程序中,声明两个足够存储17592236376080和17867114283024的变量, 交换他们的值并输出。要求使用以下的格式:

first: 第一个值, second: 第二个值

first: 17867114283024, second: 17592236376080
#include <stdio.h>
int main(void)
{
    long long first_var = 17592236376080ll;
    long long second_var = 17867114283024ll;
    long long temp = first_var;
    first_var = second_var;
    second_var = temp;
    printf("first: %lld, second: %lld", first_var, second_var);
    return 0;
}

  1. 准确的来说,程序的所有数据都会存储在“虚拟内存”中。 现代计算机操作系统可能会将一部分暂时不用的数据写到外存中,要用的时候再调回来。 这结合了外存存储空间大和内存速度快的两方优点,给程序营造出一个“又快又大的内存”。 

  2. 这有赖于默认参数提升。 

  3. 若编译器支持扩展整数类型,则可能为更大的类型。 

  4. 浮点数在计算机内部由二进制的尾数和指数表示,所以十六进制小数更贴合计算机的表示。 但是本例不妨直接写成3.0,何必为难自己呢? 

  5. 虽然字符字面量的的准确类型是int。