编程实践中C语言的一些常见细节(转载)
对于C语言,不同的编译器采用了不同的实现,并且在不同平台上表现也不同。脱离具体环境探讨C的细节行为是没有意义的,以下是我所使用的环境,大部分内容都经过测试,且所有测试结果基于这个环境获得,为简化起见,省略了异常处理。我不希望读者死记硬背这些细节,而是能在自己的平台上进行实验从而获得对应的结果。另外,本文仅仅关注于C,可能会考虑C++的表现,但在C++和C#环境下的编译器所获得的看似C代码而实不同的结果不作为参考。基础的东西比如“函数参数传值”、“转义字符”、“else的最近配对”、“case的下落(fall through)”、“符号常量NULL代表常量0”、“restrict关键字”、“使用%p输出指针”、“const的指针常量和常量指针”等本文不会重复。
了解这些细节并在自己的平台上进行实验并不是鼓励你去写模棱两可、过于依赖平台和实现的代码(除非有非这么做不可的必要),而是对这种代码有鉴别能力和理解能力,尽量避免和修正。
另外,在其他平台上的不同行为欢迎列出,但不会对原文中实现相关、机器相关的细节的具体表现进行补充说明。
如有错误,恳请指正。由于目前时间有限,可能不能及时回复,请谅解。
编译器:gcc 4.4.3,默认无任何编译选项。
编译环境:Ubuntu10.04LTS,32x86
标准:默认为ISO C99的GNU方言,不使用任何-std=选项
以下是该环境中man gcc的部分结果:
-std=
Determine the language standard. This option is currently only supported when compiling C or C++.The compiler can accept several base standards, such as c89 or c++98, and GNU dialects of those standards,
such as gnu89 or gnu++98. By specifying a base standard, the compiler will accept all programs following
that standard and those using GNU extensions that do not contradict it. For example, -std=c89 turns off
certain features of GCC that are incompatible with ISO C90, such as the "asm" and "typeof" keywords, but
not other GNU extensions that do not have a meaning in ISO C90, such as omitting the middle term of a "?:"
expression. On the other hand, by specifying a GNU dialect of a standard, all features the compiler
support are enabled, even when those features change the meaning of the base standard and some strict-
conforming programs may be rejected. The particular standard is used by -pedantic to identify which
features are GNU extensions given that version of the standard. For example -std=gnu89 -pedantic would
warn about C++ style // comments, while -std=gnu99 -pedantic would not.A value for this option must be provided; possible values are
...
gnu89
GNU dialect of ISO C90 (including some C99 features). This is the default for C code.gnu99
gnu9x
GNU dialect of ISO C99. When ISO C99 is fully implemented in GCC, this will become the default. The name gnu9x is deprecated....
gcc4.4.3是2010年发布的,编译器所采用标准的判断来自于最后一行。
另外,为了进行对照,个别实例会使用Clang进行补充。
主要参考资料:
为了不因不同标准而导致混淆,对于参考资料的引用都将注明出处的简称。
1.The C Programming Language 2nd edition,《C程序设计语言(英文版·第2版)》,Brian W. Kernighan & Dennis M. Ritchie 著,以下简称K&R
此书被誉为C语言圣经。第2版针对的是1988年的ANSI C,因此并没有一些后续C标准的变化细节(The C Programming Language 英文维基)。
2.C: A Reference Manual 5th edition,《C语言参考手册(原书第五版)》,Samuel Harbison III & Guy L. Steele Jr.著,徐波译,机械工业出版社,以下简称CARM
优秀的案头参考手册。涵盖了传统C、C89、C89修正案1和C99(此书译者序)。 遗憾的是中文版没有英文版的术语索引(Index)。
3.ISO/IEC 9899:1999,以下简称C99或C99标准
由于使用的编译器和环境而作为权威的参考。
一、编程细节:
细节1:printf的参数必须使用\n换行(newline)而不是在参数里使用回车。
来源:K&R 1.1,P7
printf("hello,world ");
结果:
编译器Error。
细节2:printf使用了格式化控制符%d但没有对应参数
来源:某公司面试题
printf("%d\n");
结果:
编译器warning: too few arguments for format
运行时显示一个随机值。
对照:
使用clang,提示 1 diagnostic generated.
运行时总是显示0。
分析:
K&R提到,如果参数不够,会FAIL。C99则把这认定为未定义行为(可参见C99标准中的fprintf部分,它的行为与printf类似)。
相关:
%s对于" "和""的处理演示。我不确定是否实现相关或者未定义行为。
printf("%s|\n%s|\n"," ","");
输出:
|
|
细节3: getchar()返回值是int,而非char;兼谈char型是否有符号及EOF的值
分析:
(c =getchar())!=EOF常用于判断输入是否结束,而char的范围不一定能容纳EOF,因此用int接收返回值。
C99:char用于存放基本执行字符集(basic execution character set)时,其值应(is guaranteed to)为正(但0字符应(shall)在基本执行字符集,似乎有点冲突,或许shall可以作为“可以”?)。其他存放于char的字符的值由实现定义。
EOF具体的值在<stdio.h>中定义,但具体数值不重要,只要和char不同即可(K&R)。C99标准将其实现为一个int型负值的宏。
有的实现将EOF定义为-1,这对char是unsigned时和上面的要求相同。有的编辑器将char实现为signed char(如gcc4.4.3),在这种情况下或许使用char型也可以接受getchar()的返回值,但可移植性就不如用int更好。你可以在自己的环境里试试char的是否有符号。
char c; c = -1; if (c<0) printf("oops,char is signed.\n"); else printf("char is unsigned.\n");
或者,你也可以使用signed char和unsigned char这样的声明来提高可移植性。(P44,K&R)
至于怎么输入一个无法输入的EOF?试试Ctrl+Z或者Ctrl+D吧,这也是和平台实现相关的。
细节4:i++,++i;副作用side effect
来看看K&R的英文描述:But the expression ++n increments n before its value is used, while n++ increments n after its value has been used.很清晰对不对?
另外,自增和自减运算符只能用于变量,(i+j)++是非法的。
CARM明确说明它们的操作数必须是可修改的左值,可以是任何算术类型或指针类型。
细节5:char、short、int和long的精度;float、double、long double
分析:
标准C指定了char至少必须达到8位、short至少为16位、long至少32位、long long至少64位,int是16位还是32位以及前几个的具体精度与机器位数和实现有关,可以在<limits.h>中查看它们的范围。(CARM)
一些具体实现里这些数据类型的精度:将 Linux 应用程序移植到 64 位系统上,如果你之前有记住所有实现中数据长度的雄心壮志,看到这个表也会放弃吧?了解自己常用平台上的即可,而且,要非常熟悉。对自己平台都不了解,空谈标准、大小关系,没什么意思。(我以前犯过这个错误)
另外,short和long后面的int可以省略。(K&R)
float、double、long double的大小是实现定义的,它们可能是3种、2种或者同1种类型(K&R)。
细节6:C中到底有没有bool型
分析:
C99标准提供了宏bool,它将被展开为_Bool。使用这个类型以及true和false需要<stdbool.h>的支持。其大小与实现相关,我的环境中测试的结果是1个字节。
使用这个宏的好处是,再也不用自己#define TRUE 1等等这样定义了。
当然,如果你遇到了一些死板的笔试题问你C是否有bool型?并且,恰好是单选、同时其他选项无比正确、明摆着在诱拐你选择这一项,那只好舍弃节操委曲求全地说“没有”了。
细节7:逻辑求值中||和&&的终止条件
分析:
从左往右,一旦整个表达式结果可得即停止运算(K&R)。即一系列||中有一个为真时,后续则不再计算,&&则相反。
顺便提一下它们的结合性都是从左到右,而&&高于||。(CARM)
我就不在这里刻意地构造复杂的&&和||表达式来考验自己和诸位读者的能力了。为了代码可读性,实践中我也不会刻意地把逻辑表达式弄得太复杂,看情况加括号便是。
细节8:函数定义中,如果返回值类型为int,那么它可以被省略。(K&R,已测)
细节9:extern变量
分析:
(K&R)
在函数“外部”定义的变量,定义时不需要加extern关键字。如果函数需要使用,需要一个显式或隐式的extern的声明。
简而言之,一种用法是在函数内使用extern声明;
另一种是将变量定义在源文件的所有函数之前,这时函数中使用这个变量时就不需要再进行声明,这只适用于单一文件。
多文件时,最好把各个文件都会用到的外部变量写入.h文件,并进行头文件包含,这时函数内使用外部变量可以省略extern声明。
请注意定义和声明的区别。前者指变量被创建或分配空间的位置(the place where the variable is created or assigned storage),后者是陈述变量特性但不分配空间的代码中的地方。
细节10:strlen()不计算‘\0‘。(K&R)
细节11:枚举名必须不同,但值可以相同。(K&R)
细节12:取模%不能用于float和double。负数运算时,/的截取方向和%的符号取决于机器,其上溢和下溢时采取的动作也取决于机器。(K&R)
细节13:>、>=、<、<=比==和!=高一级。
细节14:常用的c + ‘a‘ - ‘A‘这种大小写转换等类似形式在ASCII中是适用的,但在EBCDIC编码中是不适用的。(K&R)
细节15:移位运算
<<和>>的两个操作数都是整数,并且右操作数应该是(must be)非负的。(K&R)
事实上,C99表示,如果右操作数为负,或者移位的位数大于数据的位数,是未定义行为。更详细的规定:
对于E1<<E2,如果E1是无符号型,那么结果是E1 * 2E2,当超过该类型最大值时取模;如果E1是有符号型且非负,并且E1 * 2E2可以在该类型中表示,那么它就是结果,其它情况下则是未定义行为。
对于E1>>E2,如果E1是无符号型或者E1有符号且非负,那么结果是E1除以2的E2次幂的整数除法结果;如果E1有符号且为负值,结果值是实现定义的。
细节16:取反的好处——更独立于字长
分析:
为取得x的最低六位,与x &~077相比,x &0177700假定x是16位的,可移植性显然不如前者。
细节17:赋值表达式相当于自带括号,即 x *= y+1相当于x = x*(y+1),而非x = x *y +1。赋值语句的值是左分量的值。(K&R)
细节18:三目表达式expr1 ?expr2 :expr3 的求值顺序和表达式的值与类型
分析:(K&R)
先计算,expr1 ,非0时计算expr2 ,并作为表达式的值;为0时计算expr3并作为表达式的值。
表达式的值的类型由expr2和expr3二者的类型共同决定,其转换规则与一般的不同类型值进行运算的转换规则一致。
细节19:求值顺序与副作用
分析:
C并没有指定一个运算符两边运算数的计算顺序(&& , || , ?:以及‘,‘除外),即类似于x = f()+g()的表达式中,f()和g()的计算顺序未知先后。(K&R)另外,这里的‘,‘不是函数参数声明中的‘,‘,前者由左向右计算,后者不保证运算顺序。(K&R)
同样地,参数的计算顺序也是未知的,比如printf("%d %d\n",++n,power(2,n));它的具体结果和编译器有关。(K&R)
对于第二条,如果你以关键词“printf” "参数压栈"进行搜索,会发现广为流传的说法“printf参数压栈从右向左”。
副作用(side effect)——作为表达式的副产品,改变了变量的值。a[i]=i++,数组的下标是新值还是旧值,不同的编译器有不同的解释。标准明确规定了所有变元的副作用必须在该函数调用前生效,但对于上文printf的解释没有什么好处。(K&R)
不过我还是在自己的平台上测试了一下:
int i,j,k; i=j=6; k=2; printf("%d %d %d\n",i++,--j,k+=j);
输出:6 5 8
细节20:switch () ... case ...语句中,switch后必须是整数表达式,case 后必须是整型常量或者常量表达式。(C99)
细节21:无参数的函数,其声明的参数表请用(void),有参数就说明它们。直接用func()进行声明只是为了与较老的程序兼容,这会导致函数参数检查被关闭,最好不要这么做。(K&R)
分析:
以下代码运行无误(CARM):
int f() { printf("in f()\n"); return 0; } main(){ f(1,2); return 0; }
补充:
C++中int f()声明等价于int f(void)。
细节22:对于一个return值类型为double的函数func(),使用int a = (int) func()可以屏蔽warning。(K&R)
补充:
下面这两种编程实践哪个更好?
int*sieve = malloc(sizeof(int)*length); //case 1 int *sieve = (int *)malloc(sizeof(int)*length);//case 2
比较信服的答案是,第一种更好:http://stackoverflow.com/questions/605845/do-i-cast-the-result-of-malloc
-
- void会自动转换为所需类型;
- 如果忘记包含<stdlib.h>这会隐藏一个导致崩溃的bug;
- 如果指针类型比较复杂而不仅仅是int*,会导致该行过长,降低了可读性;
前后进行了重复,一般情况下是不好的。
关于第一条,K&R提到,Any pointer can be cast to void* and back again without loss of information。
关于第四条,K&R还有一例可证:yyval[yypv[p3+p4] + yypv[p1+p2]] += 2要强于yyval[yypv[p3+p4] + yypv[p1+p2]] = yyval[yypv[p3+p4] + yypv[p1+p2]] + 2。虽然你在第一次编码时可以用复制粘贴的方式保证前后一致,但如果其中有错误,或者要进行修改,那么你要付出两倍的工作量。
细节23:C99支持变长数组,即运行时才决定大小的数组。
scanf("%d",&n); int array2[n];
更多细节:
(CARM)
使用typedef定义变长数组时,只求值一次。
/*假定此时n=5*/ typedef int[n] vector; n+=1; vector a; //a的容量是5 int b[n]; //b的容量是6
变长数组可以作为函数参数类型,但其长度参数必须先于数组名出现。
void f(int r,int c, int a[r][c]) //OK void f(int a[r][c],int r,int c) //WRONG
细节24:static声明将变量或函数的作用域限制为它们出现的文件的其余部分。(K&R)
分析:
不要与C++中的static搞混,后者除了这种功能,还用于修饰静态成员变量/函数。(我的这个叙述存疑)
细节25:register只用于修饰自动变量和函数形参。(K&R)同时,register是函数参数中唯一可以出现的存储类指定符。
细节26:未显式初始化时,外部变量和静态变量都被初始化为0,而自动变量与寄存器变量中的值未定义,即“垃圾”。前两者必须用常量表达式初始化。(K&R)
细节27:数组初始化时,如果初始化符比数组容量小,未指定的元素在作为外部变量、静态变量、自动变量时被初始化为0。(K&R)
细节28:取地址运算符&只能用于内存中的对象(变量和数组元素),不能对表达式、常量或寄存器变量进行操作。(K&R)
细节29:标准要求main函数参数表中argv[argc]为null指针。(K&R、C99)
细节30:struct point *pp,可以用(*pp).x访问它的成员。(不仅限于pp->x) (K&R)
细节31:sizeof()不能用于#if,但可以用于#define。(K&R)
补充:
(CARM)
细节32:联合union的大小要足以容纳其最大的成员,但具体的大小是取决于实现的。联合只能用第一个成员类型初始化。(K&R)联合的尾部可能需要进行填充。(CARM)
细节33:字段(bit-fields)几乎所有属性都取决于实现;字段不是数组,也没有地址,不能使用&运算符。(K&R)
测试:
对字段使用&编译器直接报Error。
细节34:scanf使用%c读取下一个字符(缺省为1)存入指定位置。通常不跳过空白符(空格、制表符、换行符)。为读入下一个非空白符,使用%1s。
细节35:不确定输入格式时的一个小技巧(K&R)
while (getline(line, sizeof(line)) > 0) { if (sscanf(line, "%d %s %d", &day, monthname, &year) == 3) printf("valid: %s\n", line); /* 25 Dec 1988 form */ else if (sscanf(line, "%d/%d/%d", &month, &day, &year) == 3) printf("valid: %s\n", line); /* mm/dd/yy form */ else printf("invalid: %s\n", line); /* invalid form */ }
原理:
scanf函数使用完了格式输入串或当一些输入无法与控制说明相匹配时,就停止运行,并返回成功匹配和赋值的输入项的个数。
以下部分来自于我读CARM时的笔记,重要性个人认为不如前35条。
细节36:如果不发生溢出,整型常量的值总是非负数;如果前面出现符号则是对常量的一元运算符,不是常量的一部分;浮点型常量同理。
细节37:多字符常量,含义由实现定义。
细节38:标准C允许对包含相同字符的两个字符串型常量使用同一存储空间。如果在只读内存中分配,则下面赋值会产生错误。
测试:
#include <stdio.h> char *string1,*string2; int main() { string1 = "abcd"; string2 = "abcd"; if (string1==string2) printf("Strings are shared.\n"); else printf("Strings are not shared.\n"); string1[0] = ‘1‘; if(*string1==‘1‘) printf("Strings writable\n"); else printf("Strings are not writable\n"); return 0; }
输出:
Strings are shared.段错误
另外可以看出,字符串常量返回的是地址:char *string1 = "abcd".
细节39:单字符常量在C中是int型,而C++是char型。
测试:
#include <stdio.h> main() { printf("sizeof(‘a‘):%d\n",sizeof(‘a‘)); }
结果:
//.c结尾,gcc编译
sizeof(‘a‘):4
//.cpp结尾,g++编译
sizeof(‘a‘):1
细节40:struct的指定初始化(C99新增)
分析:
struct S {int a;float b;char c[4]}; struct S s1 = {.c="abc"}; /* {0,0.0,"abc"}*/
细节41:标准C中,可以用void *作为通用对象指针,但没有通用函数指针。
分析:后者的区别在于,下面被注释掉的代码无法通过编译,而剩余部分无误。
#include <stdio.h> int f1(int a) { return 1; } int f2(double b) { return 2; } int main() { //void *p; //p = f1; //printf("%d\n",p(0)); //p = f2; //printf("%d\n",p(0)); int (*p1)(int); int (*p2)(double); p1 = f1; printf("%d\n",p1(0)); p2 = f2; printf("%d\n",p2(0)); return 0; }
细节42:结构不能比较相等性。如果需要,请逐个成员比较。
细节43:typedef名称不能与其他类型说明符一起使用
typedef long int bigint; unsigned bigint x; /*invalid*/
但是可以与类型限制符一起使用
const bigint x; /*OK*/
细节44:结构类型定义或联合类型定义中类型说明符的每一次出现都引入一个新的结构类型或联合类型。
分析:以下x、y、u的类型各不同,但u和v类型相同。
struct {int a;int b;} x; struct {int a;int b;} y; struct S {int a;int b;} u; struct S v;
细节45:如果结构和联合表达式是左值,则直接成员选择表达式的结果为左值(只有函数返回的结构和联合值才不是左值)。
关于左值,请见第二部分。
细节46:如何避免放弃值的警告?
下列是虽然有效但可能引起警告消息的语句:
extern int g(); g(x); //the result of g is discarded x+7; //Addition has no defined side effects x + (a*=2);// "+"is discarded
为避免放弃值的警告,可以将其转化为void类型以表示故意要放弃这个值:
extern int g(); (void)g(x); //the result of g is discarded (void)(x+7); //Addition has no defined side effects
细节47:C99不再允许main省略返回值类型。
测试:gcc4.4.3使用-std=c99,提示warning: return type defaults to ‘int’
细节48:求值的顺序与寻常双目转换,以下两个表达式并不等价
(1.0+ -3) +(unsigned)1;//Result is -1.0 1.0 +(-3 + (unsigned)1);//Result is large
分析:
求值时会进行寻常双目转换,规则如下
细节49:当源和目的地址有公共存储空间时
以下函数的行为是未定义的
strcat,strncat,wcscat,wcsncat
strcpy,strncpy,wcscpy,wcsncpy
memcpy,memccpy
以下函数可以正常工作
memmove,wmmove
memmove“像”是借助了一块临时存储区,实际上它的实现不需要。
细节50:两字符串相等时,strcmp()返回0。因此if(!strcmp(s1,s2))表示两字符串相等时的条件。
细节51:逗号表达式的值是它的右操作数的值,即r = (a,b,...,c);等价于a;b;...r=c;
细节52:如果一个顶层声明具有类型限制符const,但没有显式的存储类别,在C中被认为是extern,C++则认为是static。
二、容易被忽视的定义
1.文本流(text stream)
一系列被分割成几行的字符序列。每行有0个或多个字符,以换行符(newline)结束。 (K&R、C99同,后者原文:A text stream is an ordered sequence of characters composed into lines , each line consisting of zero or more characters plus a terminating new-line character.)
2.对象和左值
(来自CARM)
对象(object)是一块内存区域,可以读取它的值或者向它存储数据。左值(lvalue)是一种表达式,可以读取或修改它所引用的对象。只有左值表达式可以作为赋值操作符的左操作数,不属于左值的表达式有时称为右值(rvalue),因为它只能出现在赋值操作符的右边。左值可以是对象或不完整类型,但不能是void类型。
说明:
下面的语句是没有任何问题的,尽管以前从未想过。
int a; (a) = 1;
3.序列点
这里直接是C99的相关解释
Accessing a volatile object, modifying an object, modifying a file, or calling a function
that does any of those operations are all side effects ,which are changes in the state of
the execution environment. Evaluation of an expression may produce side effects. At
certain specified points in the execution sequence called sequence points , all side effects
of previous evaluations shall be complete and no side effects of subsequent evaluations
shall have taken place.
以及所有的序列点总结(C99附录C)
The following are the sequence points described in 5.1.2.3:
— The call to a function, after the arguments have been evaluated (6.5.2.2).
— The end of the first operand of the following operators: logicalAND&& (6.5.13);
logical OR||(6.5.14); conditional ? (6.5.15); comma , (6.5.17).
— The end of a full declarator: declarators (6.7.5);
— The end of a full expression: an initializer (6.7.8); the expression in an expression
statement (6.8.3); the controlling expression of a selection statement (ifor switch)
(6.8.4); the controlling expression of a whileor dostatement (6.8.5); each of the
expressions of a for statement (6.8.5.3); the expression in a return statement
(6.8.6.4).
— Immediately before a library function returns (7.1.4).
— After the actions associated with each formatted input/output function conversion
specifier (7.19.6, 7.24.2).
— Immediately before and immediately after each call to a comparison function, and
also between any call to a comparison function and any movement of the objects
passed as arguments to that call (7.20.5).
三、补充
1.再谈未定义行为
本来是想搞一个未定义行为总收集的,但无奈实在太多,时间有限,只能作罢。有兴趣寻根问底的可以去查阅C99或最新的C11标准的附录J.2。下面收集了一些探讨常见未定义行为的文章链接,有兴趣可以去研究下:
在表达式求值时,如果发生了什么意外情况,比如1/0,这在数学上就没有解释,或者求值结果不在对应类型所能表示范围内( 1 + INT_MAX就是这种情况,两个int类型数据相加应该得到一个int类型的值,但现在这个值却超出了int类型的表示范围),那么这个表达式究竟是什么意思,C语言说它不知道。
...
这句话的意思是说,在相邻两个序点(sequence point)之间,同一个数据对象的值最多可以通过表达式求值改变一次。
http://www.cnblogs.com/pmer/archive/2013/01/02/2842516.html
再比如,两个int类型数据相加,其前提条件是结果必须在int类型可以表示的范围之内,否则就成了一种未定义行为。
http://www.cnblogs.com/pmer/archive/2012/01/16/2324058.html
指针可以与整数做加、减运算是有前提的。前提之一是这个指针必须是指向数据对象(Object)。例如:
int i
&i这个指针可以+0、+1。但是指向函数的指针或指向void类型的指针没有加减法运算。
前提之二是这个指针必须指向数组元素(单个Object视同一个元素的数组)或指向数组最后一个元素之后的那个位置。例如:
int a[2]
&a[0]、&a[1]、&a[1]+1(即a、a+1、a+2)这些指针可以进行加减法运算。
第三,指针进行加减法运算的结果必须也指向数组元素或指向数组最后一个元素之后的那个位置。例如,对于指向a[0]的指针a,只能+0、+1、+2,对于a+2这个指针,只能-0、-1、-2。如果运算结果不是指向数组元素或指向数组元素最后一个元素之后的位置的情况,C语言并没有规定这种运算行为的结果是什么,换句话说这是一种未定义行为(Undefined Behavior,后面简称UB)。
http://www.cnblogs.com/pmer/archive/2012/05/18/2507971.html
2.C99标准新增了哪些重要特性?
习惯于使用只支持老标准的编译器的读者不妨看看,这些新特性有的还是挺方便的。更不用说C11已经发布很长时间了。
3.关于二维数组
这个比较容易让人迷惑,旧作一篇供参考:二维数组作为函数参数传递剖析(C语言)(6.19更新第5种)
四、后记
写了几年程序,接触了一些语言;回顾下,还是C用得最多,也最熟悉。临近找工作,回顾下之前系统看过几遍的K&R以及CARM,希望能及时扫除盲点,也希望本文能对C语言的使用者有所帮助。书中还有很多优秀代码、细致的说明和程序设计思想,不过限于篇幅,以及与主题关系不大,只好割爱,建议有空一定要好好读读。