《C专家编程》,即 “Expert C Programming”, 这是一本奇书,作者是彼得.范德林登。虽然成书年代较早,为1994年,但并不影响此书至今仍非常值得一读。
书中除了深入地介绍了一些技术之外,还介绍了一些历史性的知识,以及每一章的末尾还有一个真实的趣味技术小故事。技术部分读来让人觉得脑洞大开,故事部分也常有令人忍俊不禁的趣闻。
本笔记自然不会包含这些趣味技术小故事。有兴趣的读者可以去看看原著。
第一章 C: 穿越时空的迷雾
C 与 UNIX 的历史
1969: Multics 工程失败(通用电气、麻省理工、贝尔实验室)
1969: 一个简易的新型操作系统诞生 (用PDP-7汇编编写)
1970: 更名为 UNIX ,并采用 PDP-11 汇编重写 (所以UNIX系统时间从1970-01-01:00:00:00 算起)
1970: Dennis Ritchie 利用PDP-11的强大性能,创立了 “New B” 语言,即 C 的前身
1972: 可能是3月,更名为C
1972: UNIX 被用C重写了
1983: 美国国家标准化组织(ANSI)成立了C语言工作小组,开始进行C语言标准化工作
1989: 12月,C语言标准草案最终被 ANSI 委员会接受。这就是 ANSI C 标准,也就是 C89 标准
auto 关键字
含义是: 在进入程序块时自动进行内存分配。(这与全局静态分配和在堆上动态分配相区别)
因为这本就是缺省的变量内存分配模式,所以一般程序员根本不用管auto关键字。
这个关键字只对创建符号表入口的编译器设计者有意义。
指针赋值与限定符
问题: const char ** 和 char ** 可以互相赋值吗?
解析:
步骤一、
ANSI C 标准 6.3.16.1 节:
左边指针所指向的类型必须具有右边指针所指向类型的全部限定符。
char * cp;
const char * ccp;
ccp = cp;
以上语句没有问题:
- 左操作数是一个指向有const限定符的char的指针;
- 右操作数是一个指向没有限定符的char的指针
因此,满足 “左边指针所指向的类型必须具有右边指针所指向类型的全部限定符”.
但是,反过来, cp = ccp; 这样就不满足了,就会报错。
(笔者注: 现在的gcc编译器会产生一个warning:
warning: assignment discards ‘const’ qualifier from pointer target type [-Wdiscarded-qualifiers]
)
步骤二、
const float *:
const限定符修饰的是指针所指向的类型,即修饰的是float,而不是修饰指针本身。
const char **:
const 限定符修饰的是 char * , 而不是 char **.
char ** 指向的是 char *, 而 const char ** 指向的是 const char *, 这二者是不同类型的指针,因此是不相容的。
结论:
char ** 和 const char ** 之间不能互相赋值。
(笔者注: 现在的gcc编译器会产生一个warning:
warning: assignment to ‘char **’ from incompatible pointer type ‘const char **’ [-Wincompatible-pointer-types]
或
warning: assignment to ‘const char **’ from incompatible pointer type ‘char **’ [-Wincompatible-pointer-types]
)
第二章 这不是Bug,而是语言特性
定义C函数时,在缺省情况下函数的名字时全局可见的。可以在函数名字前加一个extern关键字,也可以不加,效果是一样的。
如果要限制对这个函数的访问,就必须加 static 关键字。
事实上,很多时候static都忘了被加上,因此很多函数都变成全局可见的了。
这就是 all-or-nothing, 一个符号要么全局可见,要么对其他文件都不可见。
第三章 分析C语言的声明
第四章 数组和指针并不相同
这两章的内容已被收录在拙作 C指针总结 一文中。
此文较重要,包含也并不仅仅是以上2章内容。
第五章 对链接的思考
动态链接是一种 JIT 链接 (Just-In-Time). 这意味着程序在运行时必须能够找到它们所需要的函数库。
链接器通过“把库文件名或路径名植入到可执行文件中”来做到这一点。
这意味着,函数库的路径不能随意移动。比如,如将程序链接到 /usr/lib/libthread.so 库,则不能把该函数库移动到其他目录。
生成动态链接库
笔者注:
前2小节中关于生成动态链接库的命令已经比较老了。不再适用。以下部分是笔者自己的实践。
Step 1. 写下库文件的源代码文件, 以及相应的头文件
mycalc.c
int my_add(int a, int b)
{
return a+b;
}
头文件 mycalc.h 如下:
#ifndef MYCALC_H
#define MYCALC_H
extern int my_add(int, int);
#endif
Step 2. 编译出库文件
gcc -c -fPIC mycalc.c
gcc -shared -o libmycalc.so mycalc.o
第一条命令编译出位置无关代码。
程序运行时,计算机会找文件中指定的内存地址去读取数据,但是当读取一个库文件时,库本身指定的地址必须是与内存地址无关的。此时生成了 mycalc.o
注意 -fPIC 比 -fpic 兼容性更高。
第二条命令是生成库文件。
动态库的扩展名是 .so , 静态库的扩展名是 .a
Step 3. 写出调用者源代码文件
main.c 如下:
#include <stdio.h>
#include "mycalc.h"
int main()
{
int result = 0;
int a = 10;
int b = 20;
result = my_add(a, b);
printf("result = %d\n", result);
return 0;
}
Step 4. 编译链接调用者源码,生成二进制文件
gcc -c main.c -o main.o
gcc main.o -L/home/<some_user>/MyCode -lmycalc -o main
这里通过 -lmycalc
告诉编译器要链接到的函数库。简而言之, “lib"和”.so"被省掉了,本来库文件是 libmycalc.so
.
而这里的 -Lpathname
则告诉编译器在哪里能找到这个动态链接库。
如果不加 -L 选项,则编译器会去一些特殊的位置去查找函数库,如 /usr/lib
.
Step 5. 使用二进制文件
cp main ~
export LD_LIBRARY_PATH=/home/<some_user>/MyCode:$LD_LIBRARY_PATH
~/main
上面设置的环境变量 LD_LIBRARY_PATH
, 就是给应用程序在运行的时候在此指定位置寻找动态链接库用的。
最后,可以用 nm
命令列出函数库所包含的函数。
警惕 Interpositioning
Interpositioning, 也叫 interposing, 指的是: 通过编写与库函数同名的函数来取代库函数的行为。
举个例子,在上述的 main.c 中,若重新定义一个 int my_add(int a, int b) { return a*b; }
, 则同样的编译链接命令所制作出的二进制文件在运行起来后,其行为是后者的 my_add 的乘法行为。
Interpositioning 通常是为了调试或提高效率,但极易引起问题。
因为不仅你自己代码中所有对该库函数的调用都将被自己版本的函数所替代,而且所有调用该库函数的系统调用也将使用这个自定义函数。
产生链接器的报告
ld
命令的 -m
选项,让链接器产生一个报告。这个报告里包括了被 Interpose 的符号的说明。
通常,这会产生一个内存映射或列表,显示在可执行文件的什么地方放入了哪些符号。它同时显示了同一个符号的多个实例,通过查看报告的内容,可以判断是否发生了 Interpositioning.
ldd
命令可以列出可执行文件的动态依赖集,即依赖了哪些动态链接库。
假设上一节中第一次编译出的二进制文件叫做 main, 第二次做了 Interpositioning 后编译出的二进制文件叫做 main2.
对它们分别运行 ldd 命令,结果如下:
$ldd main2
linux-vdso.so.1 (0x00007ffef758d000)
libc.so.6 => /lib64/libc.so.6 (0x00007f08cab3a000)
/lib64/ld-linux-x86-64.so.2 (0x00007f08cb0f7000)
$ldd main
linux-vdso.so.1 (0x00007ffcc7ee5000)
libmycalc.so => /home/c4dev/MyCode/libmycalc.so (0x00007f75f8a58000)
libc.so.6 => /lib64/libc.so.6 (0x00007f75f869d000)
/lib64/ld-linux-x86-64.so.2 (0x00007f75f8e5c000)
可以清楚地看见, main 依赖了 libmycalc.so , 而 main2 没有。
第六章 运行时数据结构
目标文件和可执行文件可以有几种不同的格式。常见的是 ELF 格式 - Extensible Linker Format (可扩展链接器格式), 现在的含义则是 Executable and Linking Format (可执行文件和链接格式)。
不要把UNIX中段的概念和Intel x86架构中段的概念混淆。
在UNIX中,段表示一个二进制文件相关的内容块。
在Intel x86的内存模型中,段表示一种设计的结果。在这种设计中(基于兼容性的原因),地址空间并非一个整体,而是分成一些64KB大小的区域,称为段。
在本书的剩余部分,段这个术语指的都是UNIX的段,即,二进制文件中的内容块。
当在一个可执行文件中运行 size 命令时,它会告诉你这个文件的3个段(文本段,数据段,BSS段)的大小。
$size ./main
text data bss dec hex filename
1795 624 8 2427 97b ./main
检查可执行文件内容的另一种方法是使用nm
命令。
nm ./main
0000000000201038 B __bss_start
0000000000201038 b completed.6998
w __cxa_finalize@@GLIBC_2.2.5
0000000000201028 D __data_start
0000000000201028 W data_start
0000000000000650 t deregister_tm_clones
00000000000006e0 t __do_global_dtors_aux
0000000000200dd0 t __do_global_dtors_aux_fini_array_entry
0000000000201030 D __dso_handle
0000000000200dd8 d _DYNAMIC
0000000000201038 D _edata
0000000000201040 B _end
00000000000007f4 T _fini
0000000000000720 t frame_dummy
0000000000200dc8 t __frame_dummy_init_array_entry
0000000000000954 r __FRAME_END__
0000000000201000 d _GLOBAL_OFFSET_TABLE_
w __gmon_start__
0000000000000814 r __GNU_EH_FRAME_HDR
00000000000005c0 T _init
0000000000200dd0 t __init_array_end
0000000000200dc8 t __init_array_start
0000000000000800 R _IO_stdin_used
w _ITM_deregisterTMCloneTable
w _ITM_registerTMCloneTable
00000000000007f0 T __libc_csu_fini
0000000000000780 T __libc_csu_init
U __libc_start_main@@GLIBC_2.2.5
000000000000072a T main
U my_add
U printf@@GLIBC_2.2.5
0000000000000690 t register_tm_clones
0000000000000620 T _start
0000000000201038 D __TMC_END__
二进制文件的结构
再来看一个程序: test.c
#include <stdlib.h>
char pear[40]; // BSS section
static double peach; // BSS section
int mango = 13; // Data section
static long melon = 2001; // Data section
int main()
{
int i=3, j, *ip;
ip = malloc(sizeof(i));
pear[5] = i;
peach = 2.0 * mango;
free(ip);
return 0;
}
a.out 文件中会有几个section,其中包括: 数据段,文本段(即代码段),BSS段(只记录其大小,而不记录其内容)
BSS段
- 名字的含义是 Block Started by Symbol, 即 “由符号开始的段”。这是早期IBM 704汇编程序的一个伪指令,而UNIX借用了这个名字;
- 事实上,BSS段并不占用目标文件的空间
- 目标文件中只记录 BSS 段的大小而已
- 未经初始化的全局变量和静态变量存放在BSS段
Data段
- 已经初始化的全局变量和静态变量存放在数据段
局部变量,并不存放在目标文件中;它们在运行时创建。
可执行文件 a.out 的结构大致如下:
(笔者注:关于ELF文件的结构,可参见此篇文章)
- Magic Number
- Other contents
- BSS size
- Data section
- Text section
进程的内存布局
a.out 运行在内存中时,该进程的内存布局如下:
进程地址空间的最高内存地址
栈段 (stack segment) - 函数的局部数据
栈向下(向低地址)增长
空洞
堆向上(向高地址)增长
Heap (堆)
空洞
BSS段 (未初始化的全局和静态数据) - 会全部清零
Data段 (初始化了的全局和静态数据)
文本段 (代码段)
未映射区域 - 从地址0开始的几KB空间;不能被访问,任何对其引用都是非法的
进程地址空间的最低内存地址
当函数被调用时发生了什么: 过程活动记录
(笔者注: 关于函数调用过程,也可参考这篇文章)
过程活动记录,英文时 procedure activation record, 也称之为 栈帧, 英文为 stack frame.
当一个函数的 return 语句执行之后,控制将返回到哪里呢?
这个问题的经典机制就是栈中的过程活动记录。
每个函数被调用时,都会产生一个过程活动记录。过程活动记录是一种数据结果,支持过程调用,并记录调用结束后返回调用点所需的全部信息。如下。
过程活动记录包括:
- 局部变量 (local variable)
- 参数 (arguments)
- 静态链接 (static link) (用于上层引用,C语言中不使用)
- 指向先前结构的指针 (笔者注: 这里所指向的正是此函数调用者的“返回地址”)
- 返回地址 (return address)
活动记录内容的描述很具有说明性。结构的具体细节在不同的编译器中各不相同,这些字段的次序可能很不相同,而且可能还存在一个在调用函数前保存寄存器的区域。
头文件 /usr/include/sys/frame.h 描述了过程活动记录在UNIX系统中的样子。(笔者注: 现在的Linux系统中,比如SLES15,CentOS8,似乎并没有这个文件了)
程序运行时,系统维护一个指针(常位于寄存器中),通常称为 fp, 用于提示活动栈结构,它的值是最靠近栈顶部的过程活动记录的地址。
auto关键字
存储类型说明符 auto
关键字在实际中从来用不着。它通常由编译器设计者使用,用于标记符号表的条目。
它表示“在进入该块后,自动分配存储”(与编译时静态分配或在堆上动态分配不同)。
对于普通程序员,auto关键字几乎没有用处,因为它只能用于函数内部。而在函数内部声明的数据缺省就是这种分配。
控制线程
在进程中如何支持不同的控制线程(以前称为“轻量级线程”)呢?
只要为每个控制线程分配不同的栈即可。
每个线程的栈为 1MB(当需要时增长); 而各个线程的栈之间会有一个 red zone page . 如果访问到这里,会触发UNIX系统增加栈的大小,而不会引发失败。
setjmp 和 longjmp
(笔者注: 关于 setjmp 和 longjmp,也可以参见拙作 <<C语言中的setjmp和longjmp>>)
setjmp(jmp_buf j)
必须首先被调用。它表示“使用变量j记录现在的位置。此函数返回0.”longjmp(jmp_buf j, int i)
之后在某处被调用,表示“回到j所记录的位置,让它看上去就像是从原来的 setjmp() 函数返回一样。但是本longjmp函数返回i,以使得代码能够知道它实际上是从longjmp返回的”- 当使用
longjmp
时,j的内容被销毁了。
goto语句不能跳出当前的函数,但是longjmp可以。
用longjmp智能跳回到曾经到过的地方。在执行setjmp的地方仍留有一个过程活动记录。
longjmp所返回的值正是它所接收的第二个参数。由此,setjmp的调用者可以知道这是setjmp的返回,还是某处longjmp的返回。
需要注意的是:
要保证局部变量的值在longjmp过程中一直保持不变,唯一可靠的方法是把它声明为volatile
(这适用于那些值在setjmp执行和longjmp返回之间会改变的值).
setjmp 和 longjmp 在C++中变异为更为普通的异常处理机制 catch 和 throw.
在使用 setjmp 和 longjmp 的任何源文件中,必须包含头文件 <setjmp.h>
.
UNIX中的段
当试图访问当前系统分配给栈的空间之外的空间时,它将产生一个硬件中断,称为页错误(page fault)。
在正常情况下,内核通过向违规的进程发送合适的信号(如段错误)来处理对无效地址的引用。
在栈顶部的下端有一个称为 red zone 的小型区域。如果对这个区域进行引用,并不会产生失败,而是会触发操作系统增加栈的大小。
有用的C语言工具
(笔者注:这里的很多工具都过时了,下面罗列几个比较著名的还没有过时的工具)
- ctags 建立标签文件,帮助vi编辑器加速源文件的检索
- lint C程序静态语法检查
- ldd 打印出可执行文件所需的动态链接库
- nm 打印出目标文件的符号表
第七章 对内存的思考
Intel 80x86系列 以及 内存模型
1970 4位 4004
1972 8位 8008
1974 8位 8080
1978 16位 8086 16位数据总线,20位地址总线=>可访问1MB内存
1983 32位 80286
...
这些处理器都可以附加协处理器,以实现对浮点数的硬件支持。
在 UNIX 中,段就是一块以二进制形式出现的内存;
在 Intel 80x86 内存模型中,段时内存模型设计的结果。在80x86的内存模型中,各处理器的地址空间并不一致(因为要保持兼容性),但它们都被分割成以64KB为单位的区域,每个这样的区域称为段。
在存储方面,所有磁盘制造商都是用十进制数字而不是二进制数来表示磁盘的容量。比如,2GB的磁盘可以存储 2*1e9 字节的数据,而不是 2^31 字节的数据。
虚拟内存
操作系统使得每个进程都以为自己拥有整个地址空间的独家访问权。这个幻觉是通过“虚拟内存”实现的。
内存管理硬件负责把虚拟地址翻译为物理地址,并让一个进程始终运行于系统的真正内存中。
应用程序员只看到虚拟地址,并不知道自己的进程在磁盘和内存之间来回切换。
如果一个进程不会马上运行,比如优先级低或处于睡眠状态,操作系统可以暂时取回所有分配给它的物理内存资源,将该进程的所有相关信息都备份到磁盘上。
在磁盘上有一个特殊的“交换区”,用于保存从内存中换出的进程。交换区的大小一般是物理内存的几倍。只有用户进程才会被换进换出(swap out/swap in).
进程智能操作位于物理内存中的页面。当进程引用一个不在物理内存中的页面时,内存管理单元(MMU)就会产生一个页错误。
内核对此事件作出响应,并判断该引用是否有效。
如果无效,那么内核向进程发出一个 segmentation violation (段违规)的信号。
如果有效,内核从磁盘取回该页,换入到内存中。一旦页面进入内存,进程便被解锁,可以重新运行了。
cache存储器
在 source 和 destination 都使用同一 cache 行的特殊情况下,会导致每次对内存的引用都无法命中 cache,使CPU的利用率大大降低;
因为它不得不等待常规的内存操作完成。
库函数memcpy()经过特别优化以提高性能。它把“先读取一个cache行再对它进行写入”这个循环分解开来,这就避免了上述问题。
数据段和堆
calloc函数与malloc函数类似
realloc函数改变一个指针所指向的内存块的大小,既可以扩大,也可以缩小。它经常将内存复制到别的地方,再将新地址的指针返回给你。
brk和sbrk函数: 调整数据段的大小到一个绝对值(通过某个增量)。
总线错误
core dump: 很早的过去,那时的内存都是由铁氧化物圆环(也就是core,磁芯)制造的。半导体作为内存的主要制造材料的时间已经过去了几十年了,但core这个词仍然被用作内存的同义词。
union {
char a[10];
int i;
}u;
int *p = (int *)&(u.a[1]);
*p = 17; // p中未对齐的地址会引起一个总线错误
笔者注: 这段程序现在没有问题了。请看下面的程序:
#include <stdio.h>
union {
char a[10];
int i;
}u;
int main()
{
int *p = (int *)&(u.a[1]);
*p = 11; // p中未对齐的地址会引起一个总线错误
printf("*p = %d\n", *p);
p = (int *)&(u.a[2]);
*p = 12;
printf("*p = %d\n", *p);
p = (int *)&(u.a[3]);
*p = 13;
printf("*p = %d\n", *p);
p = (int *)&(u.a[4]);
*p = 14;
printf("*p = %d\n", *p);
p = (int *)&(u.a[5]);
*p = 15;
printf("*p = %d\n", *p);
p = (int *)&(u.a[6]);
*p = 16;
printf("*p = %d\n", *p);
return 0;
}
段错误
导致段错误的几个常见原因如下:
- 对一个包含非法值的指针进行解引用
- 对一个空指针解引用
- 在未得到正确的权限时进行访问。例如,对文本段进行写操作。
- 用完了栈空间或堆空间
以下是具体编程时导致段错误的常见原因:
-
坏指针错误:
1.1 指针赋值之前就用它来引用内存
1.2 向库函数传递了一个坏指针
1.3 free一个指针之后,又对齐进行使用 -
改写(overwrite)错误:
2.1 越过数组边界写入数据;
2.2 在动态分配的内存两端之外写入数据;
2.3 改写一些堆管理数据结构。 -
指针释放引起的错误:
3.1 释放同一个内存块2次;
3.2 释放一个指针但该指针获得的内存并非是由malloc分配;
3.3 释放仍在使用中的内存
3.4 释放一个无效的指针
第八章 为什么程序员无法分清万圣节和圣诞节
类型提升
表达式中,比int小的类型会提升为int类型进行计算,float会提升为double型进行计算,然后再对计算结果进行裁剪。
整型提升:
当类型提升发生于整型时,就称为“整型提升”。
“整型提升”规则要求抽象机器把每个变量的值提升为 int 的长度,然后对2个int值执行加法运算,再对运算结果进行裁剪。
如果2个char的加法运算结果不会发生溢出异常,那么在实际执行时只需要产生char类型的运算结果,可以省略类型提升。
类似地,在下列代码中,
float f1, f2;
double d;
f1 = f2 * d;
如果编译器可以确定用float进行运算的结果和转换为double后进行计算的结果一样,那么也可以使用float来进行乘法计算。
C语言中的类型提升:
源类型 提升后的类型
char int
位段(bit-field) int
枚举(enum) int
unsigned char int
short int
unsigned short int
float double
任何数组 相应类型的指针
ANSI C 提到,如果编译器能够保证运算结果一致,那么也可以省略类型提升 – 这通常出现在表达式中出现常量操作数的时候。
qsort函数
qsort函数的原型如下:
void qsort(void * base, size_t nel, size_t width,
int(*compar)(const void *, const void *));
使用举例:
int a[10] = {...};
qsort(a, sizeof(a)/sizeof(a[0]), sizeof(a[0]),
(int (*)(const void *, const void *))intcompare);
第九章 再论数组
什么时候数组与指针相同
-
数组的声明,如
extern char a[];
不能改写成指针形式。当然,数组的定义更不能。 -
函数的参数,如
func(char a[])
,可以改写成指针的形式。 -
表达式中的使用,如
c=a[i];
,可以自行选择使用数组形式或指针形式。
数组下标表达式总是可以改写为带偏移量的指针表达式。(注:指的是 a[5] === *(a+5)
一个通用规则: 当一个数组名出现在一个表达式中时,它会被转换为一个指向该数组第一个元素的指针。
再总结一下,什么时候数组与指针是相同的:
规则1. 表达式中的数组名(与声明不同)被编译器当作一个指向该数组第一个元素的指针
规则2. 下标总是与指针的偏移量相同
规则3. 在函数参数的声明中,数组名被编译器当作指向该数组第一个元素的指针
对于规则1,以下写法皆正确:
int a[10], *p, i=2;
p[i] = 99;
*(p+i) = 98;
*p = 97;
对于规则1的例外: (以下情况,数组名和指针不同,仍代表整个数组)
- 数组名作为
sizeof
的操作数,如sizeof(a)
, 求得的是整个数组的大小(整个数组有多少字节) - 用 & 符号取数组名的地址
(笔者注: 如int a[100];
, 则&a
代表int (*)[100]
, 但数值上仍等同于&a[0]
, 而大小却不同 ) - 数组是一个字符串(或宽字符串)常量初始值。
多维数组
int apricot[2][3][5];
可以使用下列任何一种方法为它在内存中定位:
int (*p)[3][5] = apricot;
- p是一个指针,它指向一个二维数组
- 这个二维数组第一维是3,第二维是5
- p所指向的这个二维数组就是apricot这个三维数组中的第一个二维数组
- apricot 是一个三维数组的名字,就相当于一个指向二维数组的指针
- 这正如一个一维数组的名字,就相当于一个指向数组元素的指针
int (*r)[5] = apricot[i];
- r是一个指针,它指向一个一维数组
- 这个一维数组包含了5个int型数字
- r所指向的这个一维数组就是apricot[i]中的第一个一维数组
int *t = apricot[i][j];
- t本身的含义是一个 int 型指针
- t此时仍表示它所指向的是一个int型数字的地址
- apricot[i][j]是一个一维数组的名字,因此相当于一个int型指针
指针所指向的数组的维数不同,其区别会很大。以上面例子来说明:
r++;
t++;
以上代码使 r 和 t 分别指向它们各自的下一个元素;
但它们各自所增长的步长是不同的:
- r+1后,增长的步长是 5*4 (r指向的是二维数组,但其+1代表增加的步长是整个一维数组的大小)
- t+1后,增长的步长是 4
所以 r+1 后增长的步长是 t+1 后增长的步长的5倍数
笔者注: 以上这一小节都是笔者自己的理解和拓展。应该是比较浅显易懂了吧。
原书有误: 原书指出是3倍,但其实是5倍。 因为 t 是int型指针,增长步长是4, 而r代表一维数组,增长步长为5*4
验证可见下列程序:
#include <stdio.h>
int main()
{
int a[2][3][5];
int (*r)[5] = a[0];
int * t = a[0][0];
printf("r = %p\n", r);
printf("t = %p\n", t);
r++;
t++;
printf("r = %p\n", r); // +20, i.e. 0x14
printf("t = %p\n", t); // +4
return 0;
}
如何对数组进行初始化
对数组的初始化,当然是使用花括号。但注意以下2点:
- 最后一个初始值的后面可以加一个逗号,也可以省略它
- 可以省略最左边的下标(编译器会推断其长度)
- 如果数组的长度比所提供的初始化值的个数要多,剩下的几个元素会自动设置为0
几个例子:
float a[5] = {0.0, 1.0, 2.1, 3.4, 5.6};
float b[] = {1.0, 2.2};
short c[2][3] = {
{10, 11, 12},
{12, 13, 14},
};
int d[][3] = { {0,0,0}, {1,1,1}, };
下面是一种初始化二维字符串数组的方法:
char vvv[][9] = {
"aaa",
"bbccdd",
"abcdef",
"abcd"
};
当然,这样也可以:
char * vvv[] = {
"aaa",
"bbccdd",
"abcdef",
"abcd"
};
注意, 只有字符串常量才可以初始化指针数组,而指针数组不能由非字符串类型直接初始化。
以下代码无法通过编译:
// Wrong!
int * weight[] = {
{1, 2, 3},
{4, 5}
};
第十章 再论指针
char pea[4][6];
pea[i][j] 将被编译器解析为 *(*(pea + i) + j)
- pea是二维数组名,相当于一维数组的指针
- pea + i, 就是第 (i+1) 个一维数组的指针
*(pea+i)
, 就是对一个一维数组的指针解引用,也就是一个一维数组名,也就是这个一维数组第1个元素的地址*(pea+i)+j
,即:第(i+1)个一维数组的第(j+1)个元素的地址- 最后再解引用,就是这个元素的值了
数组和指针作为参数时,是如何被编译器修改的
主要规则如下:
- 若实参为数组的数组,如
char c[8][10]
, 则形参为数组指针, 如char (*)[10]
- 若实参为指针数组,如
char *c[15]
, 则形参为 指针的指针, 如char **c
- 若实参为数组指针或指针的指针,则形参保持不变(即保持和实参形式一致)
像函数传递一个多维数组
这里的一个要点是,最左边的一维的长度需要以额外的参数来提供,而除此之外的其他维的长度,需要在形参中指出。
下面是一个示例程序。(笔者注: 来自笔者的程序)
#include <stdio.h>
// s1是第一维的长度;
// 第二维的长度已经在第一个参数指定
void print2D(int (*p2D)[3], int s1)
{
int i=0, j=0;
for (i=0; i<s1; i++) {
for (j=0; j<3; j++) {
printf("%d ", p2D[i][j]);
}
printf("\n");
}
}
void print2DW3(int array[][3], int s1)
{
int i=0, j=0;
for (i=0; i<s1; i++) {
for (j=0; j<3; j++) {
printf("%d ", array[i][j]);
}
printf("\n");
}
}
int main()
{
int array2D[2][3] = {
{1, 2, 3},
{4, 5, 6}
};
// way-1
int (*p2D)[3] = array2D;
print2D(p2D, 2);
// way-2
// 二维数组的名字,就相当于一个指向一维数组的指针
print2D(array2D, 2);
// way-3
print2DW3(array2D, 2);
return 0;
}
使用指针从函数返回一个数组
示例程序如下:
(笔者注: 来自笔者的程序)
#include <stdio.h>
#include <stdlib.h>
// myfunc 是函数名,它返回一个int型数组,该数组含5个元素
int (*myfunc())[5]
{
int i = 0;
static int (*p)[5] = NULL;
if (p == NULL) {
p = calloc(5, sizeof(int));
for (i=0; i<5; i++) {
(*p)[i] = i + 1;
}
}
return p;
}
void print_array(int array[], int size)
{
int i=0;
for (i=0; i<size; i++) {
printf("%d ", array[i]);
}
printf("\n");
}
int main()
{
int i=0;
int (*p)[5] = myfunc();
print_array((*p), 5);
(*p)[3] = 10;
print_array((*p), 5);
p = myfunc();
print_array((*p), 5); // also changed as static
return 0;
}
第11章 C++不在话下
面向对象编程的特点是继承和动态绑定。
C++所创造的且C语言无法通过正当途径轻松实现的主要新奇玩意就是继承。
嵌套只是把一个类嵌入到另一个类的内部,它并不具有特殊权限,跟被嵌套的类也并没有什么特殊的关系。
嵌套通常用于实现容器类(就是实现一个数据结构的类,如链表,散列表,队列等)。
new和delete操作符:
用于取代malloc和free函数。这2个操作符用起来更方便一些(如能够自动完成 sizeof 计算工作,并会调用合适的构造函数和析构函数)。
new能真正地建立一个对象,而malloc只是分配内存。
编程语言有一个特性,称为正交性(orthogonality)。它是指不同的特性遵循同一个基本原则的程度(也就是学会一种特性有助于学习其他的特性)。
Fortran语言是第一个高级语言,它提供了强大的方法来表示数学公式。Fortran这个名字就是 Formula translation (公式翻译)的意思。
COBOL语言把自己定位在文件处理,数值运算和输出编辑。
- 在C++中,一个内层作用域的结构名,将会隐藏外层空间中相同的对象名; 而在C语言中不会如此。
- 在C++中,字符常量的类型为char,而在C语言中,其类型为int; 也就是说,在C++中,
sizeof('a')
的结果是1,而在C语言中,它的值和sizeof(int)
一样大;
附录A - 程序员工作面试的秘密
(笔者注: 这一章的内容都值得一读且并不过时,不过这里笔者只是摘录了一小部分内容)
自增操作
++x
表示:
1> 取x在内存中的地址; 2> 增加内存中的x的值; 3> 放到寄存器中
x++
表示:
1> 取x在内存中的地址; 2> 放到寄存器中; 3> 增加内存中的x的值
看上去自增操作比+1更有效率,但实际上当代编译器在这方面都做了优化,以至于其实是一样的。
库函数与系统调用
- 函数库调用属于过程调用,都发生在用户空间,开销较小; 系统调用需要切换到内核上下文环境然后再切换回来,开销较大;
- 用
man 3
看函数库调用; 用man 2
看系统调用;
文件描述符与文件指针
系统IO调用有 create(), open(), read(), write(), close(), ioctl()等, 但它们不是ANSI C的一部分,不会存在于非UNIX环境中。如果使用了它们,程序将失去可移植性。
为了确保可移植性,应该使用标准IO库的调用,如 fopen(), fclose(), putc(), fseek()等等。
这些调用都接受一个指向类型为FILE
结构的指针(有时称为流指针)的参数。
FILE
指针指向一个流结构,它在 <stdio.h>
中定义。
文件描述符就是开放文件的每个进程表的一个偏移量。它用于UNIX系统调用中,用于标识文件。
FILE
结构用于表示开放的I/O流(如 hex20938). 它用于ANSI C标准IO库调用中,用于标识文件。
C库函数 fdopen() 可用于创建一个新的FILE结构,并将它与一个确定的文件描述符相关联(可以有效地在文件描述符小整数和对应的流指针间进行转换,虽然它并不在开放文件表中产生一个额外的新条目)。
如何确定一个变量是有符号数还是无符号数
在ANSI C中,char既可以是有符号数,也可以是无符号数,这是由编译器决定的。
当你编写的代码需要移植到多个平台时,知道类型是不是有符号数就非常有用了。
无符号数的本质,就是它永远不会是负数。
有符号数的本质,就是对其最左边的位取补将会改变它的符号。
#define ISUNSIGNED(a) ((a) >= 0 && ~(a) >= 0)
如果宏的参数是一个类型,那么一个方法就是使用类型转换
#define ISUNSIGNED(type) ((type)0 - 1 > 0)
从文件中随机提取一个字符串
读取一个文件,从中随机提取一个字符串。
特殊要求是: 只能按顺序遍历文件一次,并且不能使用表格来存储所有字符串或其位置。
方法:
读入第1个字符串,并将其保存;(如果文件就一个字符串,则选取成功)
读入第2个字符串,按照 50% v.s. 50% 的概率,从2个字符串中选取一个并保存,另一个丢弃;
读入第3个字符串,该字符串给予 33.33% 的概率,上次剩下的字符串基于 66.67% 的概率, 从而选定一个并保存,另一个丢弃;
…
…
读入第N个字符串,该字符串给予 1/N 的概率, 上次剩下的字符串给予 (N-1)/N 的概率, 从而选定一个并保存,另一个丢弃;
(全书终)