C Traps and Pitfalls 摘记(3)

库函数

C语言中没有定义输入/输出语句,任何一个有用的C程序,都必须调用库函数来完成最基本的输入/输出操作。ANSI C标准毫无疑问地意识到了这一点,因而定义了一个包含大量标准库函数的集合。从理论上说,任何一个C语言实现都应该提供这些标准库函数。ANSI C中定义的标准库函数集合并不完备。例如,基本上所有的C语言实现都包括了执行“底层”I/O的readwrite函数,但是这些函数却并没有出现在ANSI C标准中。

(有关底层文件I/O,请参考这篇文章

返回整数的getchar函数

我们首先考虑下面的例子

1
2
3
4
5
6
7
#include <stdio.h>
int main()
{
char c;
while((c = getchar()) != EOF)
putchar(c);
}

getchar函数在一般情况下返回的是标准输入文件中的下一个字符,当没有输入时返回EOF(一个在头文件stdio.h中被定义的值,不同于任何一个字符)。这个程序乍一看似乎是把标准输入复制到标准输出,实则不然。

原因在于程序中的变量c被声明为char类型,而不是int类型。这意味着c无法容下所有可能的字符,特别是,可能无法容下EOF。这样导致结果存在三种可能:

  1. 某些合法输入的字符在被“截断”后使得c的取值与EOF相同。程序将在文件复制的中途终止。
  2. c根本不可能取到EOF这个值。程序将陷入一个死循环。
  3. 编译器将“截断”的值赋给了c,但是进行比较运算的是cgetchar函数的返回值。这种情况下,是编译器采取的做法出现了问题。但是这种情况下,程序表面上是可以正常工作的,但是并不正确。

缓冲输出与内存分配

C语言提供了两种程序输出方式,一种是即时处理方式,这种会将输出立即将输出展示给用户。但是可能会造成较高的系统负担。有些时候这种即时性的行为不是必要的,可以先暂存起来。而C语言实现通常都允许程序员进行实际的写操作之前控制产生的输出数据量。

这种控制能力一般是通过库函数setbuf实现的。如果buf是一个大小适当的字符数组,那么setbuf(stdout,buf)语句将通知输入/输出库,所有写入到stdout的输出都应该使用buf作为输出缓冲区,直到buf缓冲区被填满或者程序员直接调用fflush,buf缓冲区中的内容才实际写入到stdout中。缓冲区的大小由系统头文件stdio.h中的BUFSIZ定义。

那么自然,我们会写出这样的程序:

1
2
3
4
5
6
7
8
9
#include <stdio.h>
int main()
{
int c;
char buf[BUFSIZ];
setbuf(stdout, buf);
while((c = getchar()) != EOF)
putchar(c);
}

但是,这个程序是错误的,buf缓冲区最后一次被清空在main函数结束之后,作为程序交回控制给操作系统之前C运行时库所必须进行的清理工作的一部分。但是,在此之前buf字符数组已经被释放。

要避免这种情况有两种解决方法:

  1. 将缓冲数组设置为静态数组,尝试添加static关键字或者将其声明完全移动到main函数之外。
  2. 使用malloc动态分配缓冲区,然后在程序中并不主动释放分配的缓冲区。

预处理器

在严格意义上的编译过程开始之前,C语言预处理器首先对程序代码作了必要的转换处理。因此,我们运行的程序实际上并不是我们所写的程序。预处理器使得编程者可以简化某些工作,它的重要性可以由两个主要的原因说明:

  1. 我们也许会遇到这种情况,需要将某个特定数量(比如某个数据表的大小)在程序中出现的所有实例统统加以修改。我们希望能够通过在程序中只改动一处数值,然后重新编译就可以实现。预处理器要做到这一点可以说是轻而易举,即使这个数值在程序中的很多地方出现。我们只需要将这个数值定义为一个显式常量(manifest constant),然后在程序中需要的地方使用这个常量即可。而且预处理器还能够很容易把所有常量定义都集中在一起,这样要找到这些常量也非常容易。
  2. 大多数C语言实现在函数调用时都会带来重大的系统开销。因此,我们也许希望有这样一种程序块,它看上去像一个函数,但却没有函数调用的开销。举例来说,getcharputchar经常被实现为宏,以避免在每次执行输入或者输出一个字符这样简单的操作时,都要调用相应的函数而造成系统效率的下降。

宏并不是函数

老生常谈的问题,我们拿最简单的abs宏来说:

1
#define abs(x) x>0?x:-x

对于一个数来说,表达一切正常,但是如果套上一个表达式呢

1
2
3
abs(a-b);
// 等价于
a-b>0?a-b:-a-b;

这里的-a-b就跟我们期望的-(a-b)不一样了。所以我们最好在宏定义中把每个参数都用括号括起来。同样,整个结果表达式也应该用括号括起来,以防止当宏用于一个更大一些的表达式可能出现的问题。例如:

1
2
3
abs(a)+1;
// 等价于
a>0?a:-a+1;

第二个重要的问题是要确保宏中的参数没有副作用。这里副作用主要指:

  1. 不要进行重复求值,导致效率低下。
  2. 不要进行重复运算,导致越界。

这两种情况都需要我们密切关注,将已经求好的值再放入宏中。

宏不是语句

编程者有时会试图定义宏的行为与语句类似,但这样做的实际困难往往令人吃惊!以assert宏为例,它的参数是一个表达式,如果该表达式为0,就使程序终止执行,并给出一条适当的出错消息。把assert作为宏来处理,这样就使得我们可以在出错信息中包括有文件名和断言失败处的行号。

那么接下来我们要开始尝试定义assert宏:

1
#define assert(e) if (!e) assert_error(__FILE__,__LINE__)

因为考虑到宏assert的使用者会加上一个分号,所以在宏定义中并没有包括分号。
但是如果将其放在if-else结构中,那么else的部分就会错误匹配,我们需要改进我们的定义。

我们可能可以试着加上大括号,把宏整体括起来,就能避免else的错误匹配,改进后的宏为:

1
#define assert(e) {if (!e) assert_error(__FILE__,__LINE__); }

但是这个定义,在上面那个例子中,会带来一个新的问题,如果那个例子如下:

1
2
3
4
5
6
7
8
9
10
if(condition)
assert(condition1);
else
assert(condition2);
// 会被展开为

if(condition)
{if (!condition1) assert_error(__FILE__,__LINE__); };
else
{if (!condition2) assert_error(__FILE__,__LINE__); };

花括号尾的分号是一个语法错误。如果要解决这个问题,一个办法是对assert的调用后面都不再跟一个分号,但是这样的用法显得有些“怪异”。

下面给出宏assert的正确定义,它很不直观,但是利用了||运算符对两侧操作数依次顺序求值的性质(短路运算)。

1
2
#define assert(e) \
((void)((e) || assert_error(__FILE__,__LINE__)))

可移植性缺陷

除法运算时发生的截断

假如我们让a除以b,商为q,余数为r

1
2
q = a / b;
r = a % b;

这里,不妨假定b 大于 0。

我们希望abqr之间维持怎样的关系呢?

  1. 最重要的一点,我们希望$ q \times b + r = a $,因为这是定义余数的关系。
  2. 如果我们改变$ a $的正负号,我们希望这会改变$ q $的符号,但这不会改变$ q $的绝对值。
  3. 当$ b > 0 $时,我们希望保证$ r >= 0 $且$ r < b $。例如,如果余数用于哈希表的索引,确保它是一个有效的索引值很重要。

这三条性质是我们认为整数除法和余数操作所应该具备的。很不幸的是,它们不可能同时满足。

例如,$3 / 2$,商$ 1 $,余数$ 1 $,此时,第1条性质得到了满足。但是$ \left( -3 \right) / 2 $的值应该是多少呢?如果要满足第2条性质,答案应该是$ -1 $,但是如果是这样,余数就必定是$ -1 $,这样第3条性质就无法满足。

因此,C语言或者其他语言在实现整数除法截断运算时,必须放弃上述三条原则中的至少一条。大多数程序设计语言选择放弃了第3条,而改为要求余数与被除数的正负号相同。大多数C编译器在实践中也都是这样做的。

然而,C语言的定义只保证了第1条性质,以及当$ a \geq 0 $且$ b > q $时,保证$ \left| r \right| < \left| b \right| $以及$ r \geq 0 $,后面部分的保证与第2条性质或者第3条性质比较起来,限制性要弱得多。

C Traps and Pitfalls 摘记(2)

连接

一个C语言程序可能是由多个分别编译的部分组成的,这些不同部分通过一个通常叫做连接器(也叫连接编辑器,或载入器)的程序合并成一个整体。因为编译器一般每次只处理一个文件,所以它不能检测出那些需要一次了解多个源程序文件才能察觉的错误。而且,在许多系统中连接器是独立于C语言实现的,因此如果前述错误的原因是与C语言相关的,连接器对此同样束手无策。

某些C语言实现提供了一个称为lint的程序,可以捕获到大量的此类错误,但遗憾的是并非全部的C语言实现都提供了该程序。如果能够找到诸如lint的程序,就一定要善加利用,这一点无论怎么强调都不为过。

什么是连接器

C语言中的一个重要思想就是分别编译(Separate Complication),即若干个源程序可以在不同的时候单独进行编译,然后再恰当的时候整合到一起。连接器就是起到把若干个C源程序合并成一个整体的作用。连接器不理解C语言,然而它却能理解机器语言和内存布局。那么,编译器把C源程序“翻译”成对连接器有意义的形式,这样连接器就能够“读懂”C源程序。

典型的连接器把由编译器或汇编器生成的若干个目标模块,整合成一个被称为载入模块或可执行文件的实体,该实体能够被操作系统直接执行。其中,某些目标模块是直接作为输入提供给连接器的;而另外一些目标模块则是根据链接过程的需要,从包含有类似printf函数的库文件中取得的。

连接器通常把目标模块看成是一组外部对象(external object)组成的。每个外部对象代表着机器内存中的某个部分,并通过一个外部名称来识别。因此,程序中的每个函数和每个外部变量,如果没有被声明为static,就都是一个外部对象。某些C编译器会对静态函数和静态变量的名称做一定改变,将它们也作为外部对象。由于经过了“名称修饰”,所以它们不会与其他源程序文件中的同名函数或同名变量发生命名冲突。

大多数连接器都禁止同一个载入模块中的两个不同外部对象拥有相同的名称。然而,在多个目标模块整合成一个载入模块时,这些目标模块可能就包含了同名的外部对象。连接器的一个重要工作就是处理这类命名冲突。

处理命名冲突的最简单方法就是干脆完全禁止。对于外部对象是函数的情形,这种做法当然正确,一个程序如果包含两个同名的不同函数,编译器根本就不应该接受。而对于外部对象是变量的情形,问题就变得有些困难了。不同的连接器对这种情况有着不同的处理方式。

那么以上信息大概能让我们了解连接器是如何工作了:

  1. 连接器输入是一组目标模块和库文件。
  2. 连接器输出是一个载入模块。
  3. 连接器读入目标模块和库文件,同时生成载入模块。
  4. 对每个目标模块中的每个外部对象,连接器都要检查载入模块,看看是否已有同名的外部对象。
  5. 如果没有同名的外部对象,连接器将该外部对象添加到载入模块中。
  6. 如果有同名的外部对象,连接器要开始处理命名冲突。

除了外部对象之外,目标模块中还可能包括了对其他模块中的外部对象的引用。例如,一个调用了函数printf的C程序所生成的目标模块,就包括了一个对函数printf的引用。可以推测得出,该引用指向的是一个位于某个库文件中的外部对象。在连接器生成载入模块的过程中,它必须同时记录这些外部对象的引用。当连接器读入一个目标模块时,它必须解析出这个目标模块中定义的所有外部对象的引用,并作出标记说明这些外部对象不再是未定义的。

声明与定义

1
int a;

如果这条声明语句出现在所有的函数体之外,那么它就被称为外部对象a的定义。这个语句说明了a是一个外部整型变量,同时为a分配了存储空间。因为外部对象a并没有被明确指定任何初始值,所以它的初始值默认为0。C编译器有责任以适当方式通知连接器,确保未指定初始值的外部变量被初始化为0。

1
extern int a;

这条语句不是对a的定义。这个语句仍然说明a是一个外部整型变量,但是因为它包括了extern关键字,这就显式地说明了a的存储空间是在程序的其他地方分配的。从连接器的角度来看,上述声明是一个对外部变量a的引用,而不是对a的定义。因为这种形式的声明是对一个外部对象的显式引用,即使它出现在一个函数的内部,也仍然具有同样的含义。

每个外部对象都必须在程序某个地方进行定义。因此,extern的指定外部对象必然要有对应的定义。但是如果有多个同一个外部对象名称的定义,将会出现什么样的情形呢?

例如在一个源文件出现:

1
int a = 7;

另一个源文件中出现:

1
int a = 9;

这种情况下,实际结果与系统有关。不同系统可能有不同的处理方法。严格的规则是:每个外部变量只能够定义一次。如果外部的变量的多个定义各指定一个初始值,大多数系统都会拒绝接受该程序。

形参、实参与返回值

如果一个函数在被定义或声明前被调用,那么它的返回类型就默认为整型。例如下面整个程序可以正确的被连接:

1
2
3
4
5
6
7
8
9
int main()
{
printf("%d", func());
return 0;
}
int func()
{
return 1;
}

但是如果func函数的返回值变成了非int型,而是char之类,那么当连接器将调用命令和func函数连接时就会得出错误的结果。

然后我们再来看形参与实参。C语言中形参与实参匹配的规则稍微有一点复杂。ANSI C允许程序员在声明时指定函数的参数类型:

1
double square(double);

根据这个声明,square(2)是合法的:整数2将会被自动转换成双精度类型,跟double square((double)2)double(2.0)一样。

如果一个函数没有floatshortchar类型的参数,在函数声明中完全可以省略参数类型的说明。即在ANSI C下,像这样声明square也是可以的:

1
double square();

这样做依赖于调用者能够提供数目正确且类型恰当的实参。这里的“恰当”并不意味着“等同”:float类型的参数会自动转换为double类型,shortchar类型的参数会自动转换为int类型。但是反过来,省略float等类型的参数不能够正确通过。

检查外部类型

假如我们有一个C程序,它由两个源文件。一个文件中包含外部变量n的声明:

1
extern int n;

另一个文件中包含外部变量n的定义:

1
long n;

这里假定两个语句都不在任何一个函数体内,因此n是外部变量。

这是一个无效的C程序,因为同一个外部变量名在两个不同的文件中被声明为不同类型。然而,大多数C语言实现却不能检测出这种错误。编译器对这两个不同的文件分别进行处理,这两个文件的编译时间可以相差好几个月。因此,编译器在编译一个文件时,并不知道另一个文件的内容。连接器可能对C语言一无所知,因此它也不知道如何比较两个n的定义中的类型。当这个程序编译或运行时,可能会发生的情况:

  1. C语言编译器足够“聪明”,能够检测到这一类型冲突。编译者将会得到一条诊断消息,报告变量n在两个不同的文件中被给定了不同的类型。
  2. C语言实现对int类型的数数值与long类型的数值在内部表示上是一样的。在这种情况下,程序很可能正常工作。本来错误的程序因为某种巧合却能够工作。
  3. 变量n的两个实例虽然要求的存储空间的大小不同,但是它们共享存储空间的方式却恰好能够满足这样的条件:赋给其中一个的值,对另一个也是有效的。例如charint
  4. 变量n的两个实例共享存储空间的方式,使得对其中一个赋值时,其效果相当于同时给另一个赋了完全不同的值。在这种情况下,程序将不能正常的工作。

因此,保证一个特定名称的所有外部定义在每个目标模块中都有相同的类型,一般来说是程序员的责任。而且,“相同的类型”应该是严格意义上的相同。

下面的例子将重现这一个陷阱的发生:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// a.h
#include <stdio.h>
char a[4];
void func();
// a.c
#include "a.h"
void func()
{
int i;
for(i = 0; i < 4; ++i)
printf("%p\n", a[i]);
}
// main.c
#include <stdio.h>
extern int a;
extern void func();
int main()
{
a = 0x11223344;
func();
return 0;
}

然后按下面的顺序生成例子:

1
2
3
4
5
6
7
8
gcc -c a.c
# 进行预处理, 编译, 不进行链接, 生成的是目标文件(object file)
ar -crv libtest.a a.o
# 使用ar命令将.o文件打包成静态库。静态库文件名的命名规范是以lib为前缀,紧接着跟静态库名,扩展名为.a。
gcc main.c -o main -L. -ltest
# 将静态库加入连接;-L. 是指在当前路径下查找静态库;-ltest 是指链接libtest.a,gcc会在静态库名前加上前缀lib,然后追加扩展名.a得到的静态库文件名来查找静态库文件。
./main
# 调用可执行文件,可以看到a在main()中被当作int解释,func()中被当作char[4]解释,但是它们用的是同一块内存空间。

C Traps and Pitfalls 练习摘记(1)

语法“陷阱”

练习 2-1: C语言运行初始化列表中出现多余的逗号,例如

1
2
int days[] = { 31, 28, 31, 30, 31, 30,
31, 31, 30, 31, 30, 31,};

为什么这种特性是有用的?

答案: 我们可以看出,初始化列表每一个元素后面都跟着一个逗号,这种语法上的相似性,为自动化的程序设计工具提供了方便。亦即当代码是由工具自己编写产生的时候,就不需要花费心思去特别的判定结尾了。


Effective C++ 摘记(1)

条款02 尽量以const, enum, inline 替换#define

当我们进行 #define 的时候,代码在预处理器的时候就已经被替换了,于是记号名称有可能没有进入记号表(Symbol table)内,于是你应用此常量但获得一个编译错误信息的时候,错误信息可能只会提及到被替换后的值。这时的错误跟踪会十分浪费时间,故此时,我们应该用一个常量来替换宏。

定义常量指针时,因此有必要将指针声明为const,但是同时你应该也要保证指针指向之物不变,此时应该用两个const限定。如
const char* const authorName = "Scott Meyers"
但是同时,string对象比 char*-based指针更加合适,故应该使用
const std::string authorName("Scott Meyers")

其次,在class类内声明一个静态成员常量,例如:

1
2
3
4
5
class Tuple {
private:
static const int GroupSize = 5;
int value[GroupSize];
}

然而你所看到的是GroupSize的声明式而非定义式。通常C++要求你要用的所有东西提供一个定义式,但如果它是个class专属常量又是static且为整数类型(int/char/bool),则需要特殊处理。
只要你不取他的地址,你可以声明并使用它们而无须提供定义式。但是若你一定要取某个class成员常量的地址或者编译器坚持要看到一个定义式(通常发生在较老的编译器上),就需要提供一个这样的式子:

1
const int Tuple::GroupSize;

这个式子应该放在实现文件而非头文件,由于class常量在声明时已经获得了初值,因此定义时不可以再设初值。

但是有些老旧的编译器不允许static成员在其声明式获得初值,或者所谓”in-class 初值设定”也只能对整数常量进行。此时我们可以在实现文件中定义其初值。

1
const int Tuple::GroupSize = 5;

但是此时我们想要像上面Tuple类中value数组利用GroupSize的值进行分配内存初始化的操作是非法的。因为此时GroupSize值是不明确的,此时要使用“the enum hack”的补偿做法。其理论基础是:一个属于枚举类型(“enumerated type”)的数值可权充整数类型被使用

1
2
3
4
5
class Tuple {
private:
enum { GroupSize = 5 };
int value[GroupSize];
}

C Traps and Pitfalls 摘记(1)

词法“陷阱”

我们把符号(token)表示为程序的一个基本信息单元。每个符号都蕴含着一些信息,可能是数据,可能是操作。编译器中把程序(我认为是源程序代码)分解成一个一个符号的部分,称为词法分析器

一个例子:

1
if (x > big) big = x;

把上述符号分隔开,每行一个,我们可以得到

1
2
3
4
5
6
7
8
9
10
if
(
x
>
big
)
big
=
x
;

那么C语言如何处理一些令人产生疑惑的语句呢?书中(引用一)的第一章介绍了一种方法。

词法分析中的“贪心法”

C语言中分为单字符符号如 /、*、和 =,还有多字符符号如 /* 和 == 。

当C编译器读入一个字符 ‘/‘ 后跟又跟了一个字符 ’*‘,那么编译器就必须做出判断:是将其作为两个分别的符号对待,还是合起来作为一个符号对待。

C语言对这个问题的解决方案可以归纳为一个简单的规则:每一个符号应该包含尽可能多的字符。也就是说,编译器将程序分解成符号的方法是,从左到右一个字符一个字符读入,如果该字符可能组成一个符号,那么再读入下一个字符,判断已经读入的两个字符组成的字符串是否可能是一个符号的组成部分;如果可能,继续读入下一个字符,重复上述判断,直到读入的字符组成的字符串已不再可能组成一个有意义的符号。这个处理策略有时被称为“贪心法”或者”大嘴法”。

需要注意:除了字符串与字符常量,符号的中间不能嵌有空白(空格符、制表符和换行符)。
a---ba -- - b是一样的,但与a - -- b的含义不同。

这里有个表现其存在的例子,y = x/*p可以理解为是x除以p指针指向的值,将其所得的商赋给y,但是/*被编译器理解为一段注释的开始,编译会不断的读入字符,直到*/出现为止。这样,这个语句会变成y = x

老版本的C语言支持=+来代表+=的含义,即a=-1会被理解为a =- 1,亦即a = a - 1,此时你的原意可能是a = -1

整型常量

如果一个整型常量的第一个字符是数字0,那么会被编译器看作八进制数,意义也就变了。有些编译器还允许8和9在定义8进制数的时候出现,这种处理方法在ANSI C标准禁止这种用法,就不细究了。但是我们要注意的是在某些情况下,我们需要对齐,进行了前补零,此时的意义就不是照我们原来所想的了。

字符与字符串

printf函数的第一个参数原型是一个const char*,但是某些C编译器对函数参数并不进行类型检查,我们可能将双引号引起的字符串无意间写成单引号引起的字符串。此时会在程序运行的时候产生难以预料的错误,而不会给出编译器诊断信息。
单引号引起的字符串,大多数C编译器会理解为一个整数值,而其值,是由里面的每一个字符所代表的整数值按照特定编译器实现中的定义的方式组合得到。

在 Borland C++ v.5.5 和 LCC v3.6 中,做法是忽略多余的字符,以第一个字符的整数值作为其代表的整数值。
在 Visual C++ 6.0 和 GCC v2.95 中,做法是依次用后一个字符覆盖前一个字符,最后得到的整数值即最后一个字符的整数值
在 MSVC 15.0 中,做法是所有字符的整数值从左到右顺序链接起来得到的整数值,例如`123`字符串的整数值为3224115,即31 32 33

语法”陷阱“

运算符优先级判定

对于程序员书写表达式来说,优先级问题肯定是无法回避的问题,虽然用添加括号可以避免过于复杂的情况,不用花费过多精力去理解上。但其实可以将运算符恰当分组,理解各组运算符之间的相对优先级,提高我们的编程效率。

  1. 优先级最高的是() [] -> .,它们不是真正意义上的运算符,但是它们自左向右的结合,这是很自然的。
  2. 其次是各类单目运算符! ~ ++ -- + - (type) * & sizeof,它们都是自右向左的结合,这意味着我们可以写出*p++这种语法,会被编译器理解为*(p++),也是很自然的。但是要注意的是*p()是对一个函数p求值后取内容。
  3. 其次是各类双目算式以及移位运算符,在这其中* / %的级别最高,+ -其次,然后是<< >>,这意味着x<<1 + 3会被理解成x<<(1 + 3),实际编程中这种错误常常出现。它们都是自右向左的结合。
  4. 再之后是双目关系以及逻辑运算符。它们都是自左向右的结合。同时,我们可以写出a < b == c < d,这个意义是ab的大小关系是否和cd的大小关系一样。
  5. 最后是三目运算符和赋值运算符。它们是自右向左的结合,比较特殊。故我们可以写出a += b = c这种写法,被编译器理解为a += (b = c)
  6. 逗号运算符是优先级最低的运算符。

switch穿透

C语言switch的穿透特性,既是它的优势所在,也是它的一大弱点。如果程序员有意略去break语句,可以表达其他方式很难方便加以实现的程序控制结构。反之,如果忘记填上break语句,则会造成一些难以理解的程序行为。当我们要特意略去break语句时,应该在对应case部分的末尾填上注释,方便以后维护。

语义“陷阱”

指针与数组

指针和数组这两个概念在C语言中密不可分,理解其中一个概念必须同时也要理解另一个概念。
C语言中的数组值得注意的地方有以下两点:

  1. C语言中只有一维数组,而且数组的大小必须在编译期就作为一个常数确定下来(除去VLA)。然而,C语言中数组的元素可以是任何类型的对象,当然也就可以是另一个数组。这样,“仿真”出一个多维数组不是难事。
  2. 对于一个数组,我们只能够做两件事:确定该数组的大小,以及获得指向该数组下标为0的元素的指针。其他有关数组的操作,哪怕它们看上去是以数组下标进行运算的,实际上都是通过指针进行的。

一旦我们彻底弄懂了这两点以及它们隐含的意思,那么理解C语言的数组运算就十分简单,我们分两种情况讨论。

  1. 当数组被当作sizeof的操作数
    sizeof这个运算符会计算操作数所占用的空间,当数组被当作操作数时,sizeof会如实计算这个数组所占用的内存空间,例如 求取int a[2][3]内存空间,即sizeof(a),会得到2 * 3 * sizeof(int)的结果。

  2. 当数组不是用作sizeof的操作数
    这种情况下,数组总是会被转换成一个指向该数组的起始元素的指针(我称这种行为叫做数组的指针退化),那么此时再使用sizeof去操作这个变量,得到的其实是指针的所占用的内存空间大小。

作为参数的数组声明

在c语言中,我们没有办法可以将一个数组作为函数参数直接传递。如果我们使用数组名作为参数,那么数组名会立即被转换为指向该数组第一个元素的指针。例如,

1
2
char hello[] = "hello";
printf("%s\n", hello);

我们声明了hello是一个字符数组,但是在printf中,我们将该数组作为参数时传递给函数时,会被像上文一样,“数组总是会被转换成一个指向该数组的起始元素的指针”。
因此,将数组作为函数参数毫无意义。所以在C语言中会将作为参数的数组声明转换为相应的指针声明。例如,下面两种函数的声明写法是等效的。

1
2
int strlen(char s[]);
int strlen(char *s);

但是注意,在使用extern来声明一个全局变量时,要注意数组和指针的不同,例如:

1
2
extern char *hello;
extern char hello[];

这两者声明的并不是同一个变量。

求值顺序

C语言中的某些运算符总是以一种已知的、规定的顺序来对其操作数进行求值,而另外一些则不是这样。例如在a < b && c < d这个表达式中,C语言的定义中说明了a < b应当首先被求值,如果a确实小于b,此时再对c < d求值,否则,则表达式肯定为假,就无需再对c < d求值了。
这个例子揭示了一种运算类型,也是经常所说的:短路运算。亦即当二元逻辑运算符的前半部分可以确定整个表达式的值时,就无需再对后半部分求值了。
短路运算会导致一些问题,例如当某个变量的变化依赖于表达式的后半部分时,因为短路运算,导致变量的变化可能会有不预期的变化。在实际编程时,首先要避免此类代码的出现,但当这种逻辑简单明了时,也需要添加注释,以便维护。例如常见的:

1
2
if(y != 0 && x/y > tolerance)
complain();

一个典型的避免除零错误的判定,逻辑简单明了,短路运算在这其中发挥了重要的作用,此时的注释是可选的。

此外,C语言中只有四个运算符(&& || ?: ,)存在规定的求值顺序。而其他的所有运算符对其操作数的求值是未定义的。特别的,赋值运算符并不保证任何求值顺序。下面这种从数组x中复制前n个元素到数组y中的做法是不正确的。

1
2
3
i = 0;
while(i < n)
y[i] = x[i++];

上述代码假设y[i]的地址将在i的自增操作执行之前被求值,这一点并没有任何保证!在C语言的某些实现上,有可能在i自增之前被求值,而在另外一些实现上,有可能与此相反。遇到这种情况时,请把自增操作和赋值操作独立分成两个语句。

整数溢出

在C语言中存在两类整数算术运算,有符号运算和无符号运算。在无符号运算中,没有所谓的“溢出”说法。而在有符号整数与无符号整数的混合运算中,有符号整数会被转化为无符号整数,“溢出”也不可能发生。但是当两个操作数都是有符号整数时,“溢出”就有可能发生,而且“溢出”的结果是未定义的。当一个运算的结果发生“溢出”时,作出任何假设都是不安全的。

假定ab是两个非负整型变量,我们需要检查a+b是否会“溢出”。一个想当然的方法是这样的:

1
2
if(a + b < 0)
complain();

这并不能正常运行,当a+b确定发生“溢出”时,所有关于结果如何的假设都不再可靠。例如,在某些机器上,加法运算将设置一个内部寄存器为四种状态之一:正、负、零和溢出。在这种机器上,C编译器完全有理由这样来实现上面的例子:在a+b之后,检查该内部寄存器的标志是否为“负”。当发生“溢出”时,这个内部寄存器的状态是溢出而不是负,那么判定检查就会失败。

一种正确的做法是在相加之前就把ab强制转换为无符号整型:

1
2
if((unsigned)a + (unsigned)b > INT_MAX)
complain();

另外一种不需要用到无符号算术运算的另一种可行方法是:

1
2
if(a < INT_MAX - b)
complain();

快乐的 Linux 命令行 个人笔记(3)

本文对应章节:
探究操作系统

选项和参数

命令名经常会带有一个或多个用来更正命令行为的选项, 更进一步,选项后面会带有一个或多个参数,这些参数是命令作用的对象。所以大多数命令看起来像这样:

1
command -options arguments

大多数命令使用的选项,是由一个中划线加上一个字符组成,例如,“-l”,但是许多命令,包括来自于 GNU 项目的命令,也支持长选项,长选项由两个中划线加上一个字组成。当然, 许多命令也允许把多个短选项串在一起使用。举一个例子,ls -lt,ls 命令有两个选项, “l” 选项产生长格式输出,“t”选项按文件修改时间的先后来排序。
加上长选项 “–reverse”,则结果会以相反的顺序输出:ls -lt --reverse

ls 命令选项

选项 长选项 描述
-a –all 列出所有文件,甚至包括文件名以圆点开头的默认会被隐藏的隐藏文件。
-d –directory 通常,如果指定了目录名,ls 命令会列出这个目录中的内容,而不是目录本身。 把这个选项与 -l 选项结合使用,可以看到所指定目录的详细信息,而不是目录中的内容。
-F –classify 这个选项会在每个所列出的名字后面加上一个指示符。例如,如果名字是 目录名,则会加上一个’/‘字符。
-h –human-readable 当以长格式列出时,以人们可读的格式,而不是以字节数来显示文件的大小。
-l 以长格式显示结果。
-r –reverse 以相反的顺序来显示结果。通常,ls 命令的输出结果按照字母升序排列。
-S 命令输出结果按照文件大小来排序。
-t 按照修改时间来排序。

长格式输出研究

“-l”选项导致 ls 的输出结果以长格式输出。这种格式包含大量的有用信息。

一个 ls -al 的实例:

1
2
3
4
5
6
7
8
drwxr-xr-x    2 root     root          1002 Jun  8  2015 .
drwxr-xr-x 1 root root 0 Jun 8 2015 ..
-rwxr-xr-x 1 root root 12503 Jun 8 2015 apctrl
-rwxr-xr-x 1 root root 2487 Jun 8 2015 askfirst
-rwxr-xr-x 1 root root 8439 Jun 8 2015 ated
-rwxr-xr-x 1 root root 31127 Jun 8 2015 block
-rwxrwxr-x 1 root root 20981 Oct 23 2014 config-vlan
lrwxrwxrwx 1 root root 14 Jun 8 2015 devmem -> ../bin/busybox

长格式列表的字段

字段 含义
-rw-r–r– 对于文件的访问权限。第一个字符指明文件类型。在不同类型之间,开头的“-”说明是一个普通文件,“d”表明是一个目录。其后三个字符是文件所有者的访问权限,再其后的三个字符是文件所属组中成员的访问权限,最后三个字符是其他所有人的访问权限。
1 文件的硬链接数目。
root 文件所有者的用户名。
root 文件所属用户组的名字。
12503 以字节数表示的文件大小。
Jun 8 2015 上次修改文件的时间和日期。
apctrl 文件名。

确定文件类型

随着探究操作系统的进行,知道文件包含的内容是很有用的。我们将用 file 命令来确定文件的类型。我们之前讨论过, 在 Linux 系统中,并不要求文件名来反映文件的内容。然而,一个类似 “picture.jpg” 的文件名,我们会期望它包含 JPEG 压缩图像,但 Linux 却不这样要求它。可以这样调用 file 命令:

1
file filename

当调用 file 命令后,file 命令会打印出文件内容的简单描述。例如使用file picture.jpg

1
picture.jpg: JPEG image data, JFIF standard 1.01

用 less 浏览文本内容

less 命令是一个用来浏览文本文件的程序。纵观 Linux 系统,有许多人类可读的文本文件。less 程序为我们检查文本文件提供了方便。

为什么我们要查看文本文件呢? 因为许多包含系统设置的文件(叫做配置文件),是以文本格式存储的,阅读它们 可以更深入的了解系统是如何工作的。另外,许多系统所用到的实际程序(叫做脚本)也是以这种格式存储的。 在随后的章节里,我们将要学习怎样编辑文本文件以修改系统设置,还要学习编写自己的脚本文件,但现在我们只是看看它们的内容而已。
使用less filename来查看文本文件的内容。
一旦 less 程序运行起来,我们就能浏览文件内容了。如果文件内容多于一页,那么我们可以上下滚动文件。按下“q”键, 退出 less 程序。

less 命令

命令 行为
Page UP or b 向上翻滚一页
Page Down or space 向下翻滚一页
UP Arrow 向上翻滚一行
Down Arrow 向下翻滚一行
G 移动到最后一行
1G or g 移动到开头一行
/charaters 向前查找指定的字符串
n 向前查找下一个出现的字符串,这个字符串是之前所指定查找的
h 显示帮助屏幕
q 退出 less 程序

less 程序是早期 Unix 程序 more 的改进版。less 属于”页面调度器”类程序,这些程序允许以逐页方式轻松浏览长文本文档。 more 程序只能向前翻页,而 less 程序允许前后翻页,此外还有很多其它的特性。

系统漫游

Linux 系统中的目录

目录 评论
/ 根目录,万物起源。
/bin 包含系统启动和运行所必须的二进制程序。
/boot 包含 Linux 内核、初始 RAM 磁盘映像(用于启动时所需的驱动)和 启动加载程序。
/dev 这是一个包含设备结点的特殊目录。“一切都是文件”,也适用于设备。 在这个目录里,内核维护着所有设备的列表。
/etc 这个目录包含所有系统层面的配置文件。它也包含一系列的 shell 脚本, 在系统启动时,这些脚本会开启每个系统服务。这个目录中的任何文件应该是可读的文本文件。
/home 在通常的配置环境下,系统会在/home 下,给每个用户分配一个目录。普通用户只能 在自己的目录下写文件。这个限制保护系统免受错误的用户活动破坏。
/lib 包含核心系统程序所使用的共享库文件。这些文件与 Windows 中的动态链接库相似。
/lost+found 每个使用 Linux 文件系统的格式化分区或设备,例如 ext3文件系统, 都会有这个目录。当部分恢复一个损坏的文件系统时,会用到这个目录。这个目录应该是空的,除非文件系统 真正的损坏了。
/media 在现在的 Linux 系统中,/media 目录会包含可移动介质的挂载点, 例如 USB 驱动器,CD-ROMs 等等。这些介质连接到计算机之后,会自动地挂载到这个目录结点下。
/mnt 在早些的 Linux 系统中,/mnt 目录包含可移动介质的挂载点。
/opt 这个/opt 目录被用来安装“可选的”软件。这个主要用来存储可能 安装在系统中的商业软件产品。
/proc 这个/proc 目录很特殊。从存储在硬盘上的文件的意义上说,它不是真正的文件系统。 相反,它是一个由 Linux 内核维护的虚拟文件系统。它所包含的文件是内核的窥视孔。这些文件是可读的, 它们会告诉你内核是怎样监管计算机的。
/root root 帐户的家目录。
/sbin 这个目录包含“系统”二进制文件。它们是完成重大系统任务的程序,通常为超级用户保留。
/tmp 这个/tmp 目录,是用来存储由各种程序创建的临时文件的地方。一些配置导致系统每次 重新启动时,都会清空这个目录。
/usr 在 Linux 系统中,/usr 目录可能是最大的一个。它包含普通用户所需要的所有程序和文件。
/usr/bin /usr/bin 目录包含系统安装的可执行程序。通常,这个目录会包含许多程序。
/usr/lib 包含由/usr/bin 目录中的程序所用的共享库。
/usr/local 这个/usr/local 目录,是非系统发行版自带程序的安装目录。 通常,由源码编译的程序会安装在/usr/local/bin 目录下。新安装的 Linux 系统中会存在这个目录, 并且在管理员安装程序之前,这个目录是空的。
/usr/sbin 包含许多系统管理程序。
/usr/share /usr/share 目录包含许多由/usr/bin 目录中的程序使用的共享数据。 其中包括像默认的配置文件、图标、桌面背景、音频文件等等。
/usr/share/doc 大多数安装在系统中的软件包会包含一些文档。在/usr/share/doc 目录下, 我们可以找到按照软件包分类的文档。
/var 除了/tmp 和/home 目录之外,相对来说,目前我们看到的目录是静态的,这是说, 它们的内容不会改变。/var 目录存放的是动态文件。各种数据库,假脱机文件, 用户邮件等等,都位于在这里。
/var/log 这个/var/log 目录包含日志文件、各种系统活动的记录。这些文件非常重要,并且 应该时时监测它们。其中最重要的一个文件是/var/log/messages。注意,为了系统安全,在一些系统中, 你必须是超级用户才能查看这些日志文件。

有趣的文件:

  • /boot/grub/grub.conf or menu.lst, 被用来配置启动加载程序。
  • /boot/vmlinuz,Linux 内核。
  • /etc/crontab, 定义自动运行的任务。
  • /etc/fstab,包含存储设备的列表,以及与他们相关的挂载点。
  • /etc/passwd,包含用户帐号列表。

符号链接

在上面 ls -al 的实例中,我们发现了一个信息

1
lrwxrwxrwx    1 root     root            14 Jun  8  2015 devmem -> ../bin/busybox

为何这条信息第一个字符是“l”,并且有两个文件名呢? 这是一个特殊文件,叫做符号链接(也称为软链接或者 symlink )。 在大多数“类 Unix” 系统中, 有可能一个文件被多个文件名所指向。虽然这种特性的意义并不明显,但它真的很有用。

描绘一下这样的情景:

一个程序要求使用某个包含在名为“foo”文件中的共享资源,但是“foo”经常改变版本号。
这样,在文件名中包含版本号,会是一个好主意,因此管理员或者其它相关方,会知道安装了哪个“foo”版本。
这会导致另一个问题。如果我们更改了共享资源的名字,那么我们必须跟踪每个可能使用了 这个共享资源的程序,当每次这个资源的新版本被安装后,都要让使用了它的程序去寻找新的资源名。 这听起来很没趣。

这就是符号链接存在至今的原因。比方说,我们安装了文件 “foo” 的 2.6 版本,它的 件名是 “foo-2.6”,然后创建了叫做 “foo” 的符号链接,这个符号链接指向 “foo-2.6”。
这意味着,当一个程序打开文件 “foo” 时,它实际上是打开文件 “foo-2.6”。 现在,每个人都很高兴。依赖于 “foo” 文件的程序能找到这个文件,并且我们能知道安装了哪个文件版本。
当升级到 “foo-2.7” 版本的时候,仅添加这个文件到文件系统中,删除符号链接 “foo”, 创建一个指向新版本的符号链接。这不仅解决了版本升级问题,而且还允许在系统中保存两个不同的文件版本。
假想 “foo-2.7” 有个错误,那我们得回到原来的版本。 一样的操作,我们只需要删除指向新版本的符号链接,然后创建指向旧版本的符号链接就可以了。

拓展阅读

完整的 Linux 文件系统层次体系标准可通过以下链接找到:
http://www.pathname.com/fhs/

快乐的 Linux 命令行 个人笔记(2)

本文对应章节:
文件系统中跳转

理解文件系统树

类似于 Windows,一个“类 Unix” 的操作系统,比如说 Linux,以分层目录结构来组织所有文件。 这就意味着所有文件组成了一棵树型目录(有时候在其它系统中叫做文件夹), 这个目录树可能包含文件和其它的目录。文件系统中的第一级目录称为根目录。 根目录包含文件和子目录,子目录包含更多的文件和子目录,依此类推。

注意(类 Unix 系统)不像 Windows ,每个存储设备都有一个独自的文件系统。类 Unix 操作系统, 比如 Linux,总是只有一个单一的文件系统树,不管有多少个磁盘或者存储设备连接到计算机上。 根据负责维护系统安全的系统管理员的兴致,存储设备连接到(或着更精确些,是挂载到)目录树的各个节点上。

图1: 由图形化文件管理器显示的文件系统树

大多数人都可能熟悉如图1所示描述文件系统树的图形文件管理器。注 通常这是一棵倒置的树,也就是说,树根在最上面,而各个枝干在下面展开。然而,命令行没有这样的图片,所以我们需要把文件系统树想象成别的样子。

把文件系统想象成一个迷宫形状,就像一棵倒立的大树,我们站在迷宫的中间位置。 在任意时刻,我们处于一个目录里面,我们能看到这个目录包含的所有文件, 以及通往上面目录(父目录)的路径,和下面的各个子目录。我们所在的目录则称为 当前工作目录。我们使用 pwd(print working directory(的缩写))命令,来显示当前工作目录。

当我们首次登录系统(或者启动终端仿真器会话)后,当前工作目录是我们的家目录。 每个用户都有他自己的家目录,当用户以普通用户的身份操控系统时,家目录是唯一允许用户写入文件的地方。

ls

列出一个目录包含的文件及子目录。
默认的使用方法只列出当前目录下的内容,但是实际上 ls 可以列出任一目录的内容。如果你的终端支持色彩,你会发现 ls 用不同的颜色来区分文件的类型,这对我们寻找指定文件很有帮助。

cd

更改工作目录。

输入 cd, 然后输入你想要去的工作目录的路径名。路径名就是沿着目录树的分支 到达想要的目录期间所经过的路线。路径名可通过两种方式来指定,一种是绝对路径, 另一种是相对路径。

绝对路径

绝对路径开始于根目录,紧跟着目录树的一个个分支,一直到达所期望的目录或文件。

相对路径

绝对路径从根目录开始,直到它的目的地,而相对路径开始于工作目录。 为了做到这个(用相对路径表示), 我们在文件系统树中用一对特殊符号来表示相对位置。 这对特殊符号是 “.” (点) 和 “..” (点点)。

符号 “.” 指的是工作目录(或者可以看作当前目录),”..” 指的是工作目录的父目录。

在几乎所有的情况下,可以省略”./”。它是隐含的,例外的情况,包括但不仅包括运行当前目录下一个可执行文件。

cd 快捷键

快捷键 运行结果
cd 更改工作目录到你的家目录。
cd - 更改工作目录到先前的工作目录。
cd ~user_name 更改工作目录到用户家目录。例如, cd ~bob 会更改工作目录到用户“bob”的家目录。

关于文件名的重要规则

  1. 以 “.” 字符开头的文件名是隐藏文件。这仅表示,ls 命令不能列出它们, 用 ls -a 命令就可以了。当你创建帐号后,几个配置帐号的隐藏文件被放置在 你的家目录下。稍后,我们会仔细研究一些隐藏文件,来定制你的系统环境。 另外,一些应用程序也会把它们的配置文件以隐藏文件的形式放在你的家目录下面。
  2. 文件名和命令名是大小写敏感的。文件名 “File1” 和 “file1” 是指两个不同的文件名。
  3. Linux 没有“文件扩展名”的概念,不像其它一些系统。可以用你喜欢的任何名字 来给文件起名。文件内容或用途由其它方法来决定。虽然类 Unix 的操作系统, 不用文件扩展名来决定文件的内容或用途,但是有些应用程序会。
  4. 虽然 Linux 支持长文件名,文件名可能包含空格,标点符号,但标点符号仅限 使用 “.”,“-”,下划线。最重要的是,不要在文件名中使用空格。如果你想表示词与词之间的空格,用下划线字符来代替。过些时候,你会感激自己这样做。

快乐的 Linux 命令行 个人笔记(1)

背景

此书网站:The Linux Command Line ,它是免费的。
此书中文版:快乐的 Linux 命令行
我是跟着中英对照版进行学习,安装了 Ubuntu Server 17.10 的虚拟机作为学习环境,本系列的相当一部分内容都是从原书摘抄修改。

本文对应章节:
什么是 shell

什么是Shell

shell 就是一个程序,它接受从键盘输入的命令, 然后把命令传递给操作系统去执行。几乎所有的 Linux 发行版都提供一个名为 bash 的 来自 GNU 项目的 shell 程序。“bash” 是 “Bourne Again SHell” 的首字母缩写, 所指的是这样一个事实,bash 是最初 Unix 上由 Steve Bourne 写成 shell 程序 sh 的增强版。

终端仿真器

有了 shell 不够,我们需要一个名为终端仿真器的程序来于 shell 沟通。一般它被简单的称为 terminal,但 KDE 使用的是konsole,GNOME 使用 gnome-terminal,但是它们的目的都是相同的,让我们能够访问 shell。

Shell提示符

当 Shell 准备好去接收输入时,他会显示一行提示,提示可能会因为不同的环境会有不同的模样,但是通常的,提示会包括 你的用户名@主机名,紧接着当前工作目录和一个美元符号。
如果提示符的最后一个字符是“#”, 而不是“\$”, 那么这个终端会话就有超级用户权限。 这意味着,我们或者是以 root 用户的身份登录,或者是我们选择的终端仿真器提供超级用户(管理员)权限。

命令历史

如果按下 上箭头 按键,我们会看到刚才输入的命令重新出现在提示符。 这叫命令历史。许多 Linux 发行版默认保存最后输入的500个命令。 按下 下箭头 按键,先前输入的命令就消失了。
我们可以利用上下箭头加快我们输入的效率,这里的上下箭头出现的内容其实是之前输入过的命令,而同时,左右箭头 按键可以帮助我们定位到命令行的期望位置。

简单命令

date

显示当前日期。

1
Tue Jul 31 02:49:03 CST 2018

cal

以日历形式显示当前日期,当前日高亮标出。

1
2
3
4
5
6
7
     July 2018
Su Mo Tu We Th Fr Sa
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31

df

查看磁盘剩余空间的数量。

free

显示空闲内存的数量。

exit

结束终端对话。

幕后控制台

即使终端仿真器没有运行,在后台仍然有几个终端会话运行着。它们叫做虚拟终端 或者是虚拟控制台。在大多数 Linux 发行版中,这些终端会话都可以通过按下 Ctrl-Alt-F1 到 Ctrl-Alt-F6 访问。当一个会话被访问的时候, 它会显示登录提示框,我们需要输入用户名和密码。要从一个虚拟控制台转换到另一个, 按下 Alt 和 F1-F6(中的一个)。返回图形桌面,按下 Alt-F7。

Graphviz上手(2) 常用知识点

批量多边

一对多:
节点文字内容->{节点文字内容 ;节点文字内容 ;节点文字内容 }
如:a->{b;c;d}

多对一:
{节点文字内容 ; 节点文字内容 ; 节点文字内容}->节点文字内容
如:{b;c;d}->a

同样的也有多对多。

颜色名称列表

形状名称列表

设置两节点间边的方向

节点文字内容>节点文字内容[dir = 方向名称]
如:a->b[dir=both]、a–b[dir=back]。

4个系统方向名称:

  1. both(a<->b)
  2. none(a–b)
  3. back(a<-b)
  4. forward(a->b)

有向图中默认为forward,无向图中默认为none。

复杂结构

上图的结构十分复杂, Graphviz 依然可以画出,源码如下:

1
2
3
4
5
6
7
8
digraph structs {
node [shape=record];
struct1 [shape=record,label="<f0> left|<f1> mid\ dle|<f2> right"];
struct2 [shape=record,label="<f0> one|<f1> two"];
struct3 [shape=record,label="hello\nworld |{ b |{c|<here> d|e}| f}| g | h"];
struct1 -> struct2;
struct1 -> struct3;
}

Graphviz 中的 label 标签还支持 Html 格式,上图可以用下面混入 html 的代码画出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
digraph html {
abc [shape=none, margin=0, label=<
<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4">
<TR><TD ROWSPAN="3"><FONT COLOR="red">hello</FONT><BR/>world</TD>
<TD COLSPAN="3">b</TD>
<TD ROWSPAN="3" BGCOLOR="lightgrey">g</TD>
<TD ROWSPAN="3">h</TD>
</TR>
<TR><TD>c</TD>
<TD PORT="here">d</TD>
<TD>e</TD>
</TR>
<TR><TD COLSPAN="3">f</TD></TR>
</TABLE>>];
}

子图

在dot中以cluster开头的子图会被当做是一个新的布局来处理,而不是在原图的基础上继续操作。

上图是个流程图,值得注意的是子图一定用 subgraph 声明,且图名称前面一定要带有 cluster 这个关键词。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
digraph G {
subgraph cluster0 {
node [style=filled,color=white];
style=filled;
color=lightgrey;
a0 -> a1 -> a2 -> a3;
label = "process #1";
}
subgraph cluster1 {
node [style=filled];
b0 -> b1 -> b2 -> b3;
label = "process #2";
color=blue
}
start -> a0;
start -> b0;
a1 -> b3;
b2 -> a3;
a3 -> a0;
a3 -> end;
b3 -> end;
start [shape=Mdiamond];
end [shape=Msquare];
}

如果我们想直接指向子图怎么办,我们要设置 compound 为true,并配合 lhead 或 ltail 来实现。

1
2
3
4
5
6
7
8
9
10
digraph G {
compound=true;
subgraph cluster0 {
a;
}
subgraph cluster1 {
b;
}
a -> b [lhead=cluster1];
}

以图片为节点

节点还可以使用图片,通过在节点中使用 image="image_path" 来设置图片。不过需要注意的是,在使用图片作为节点的时候,需要将本来的形状设置为 none,并且将 label 置为空字符串,避免出现文字对图片的干扰。

python 调用

除了可以使用 dot 文件编写图形外,也可以使用python编写相关的代码,生成图形文件。
安装 python 对应的 graphviz 相应的模块:

1
pip install pygraphviz

一个实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 引用 pygraphviz
import pygraphviz as pgv
# 初始化图类
G = pgv.AGraph()
# 增加节点和边
G.add_node('a') # adds node 'a'
G.add_edge('b','c') # adds edge 'b'-'c' (and also nodes 'b', 'c')
# 设置属性
G.graph_attr['label'] = 'test graphf'
G.node_attr['shape'] = 'circle'
G.edge_attr['color'] = 'red'
# 设置输出的格式
G.layout() # default to neato
G.layout(prog='dot') # use do
# 输出到文件
G.draw('file.png') # write previously positioned graph to PNG file
G.draw('file.ps',prog='circo') # use circo to position, write PS file

引用

  1. https://blog.csdn.net/sd10086/article/details/52979462/
  2. https://www.cnblogs.com/taceywong/p/5439574.html
  3. https://www.jianshu.com/p/5b02445eca1d
  4. https://www.cnblogs.com/liang1101/p/7641984.html

Graphviz上手(1) 画图

背景

Graphviz 是一个由 AT&T 实验室启动的使用 DOT 语言来绘制关系图/流程图的开源工具包。
DOT 语言是一种文本图形描述语言,它提供了一种简单的描述图形的方法。
使用 Graphviz,我们只需要将精力集中在逻辑设计上,而不需要花费大量时间在图形布局的调整上,图形绘制布局都由工具引擎来搞定。也因此,需要精确定位的图形就不适合用 Graphviz 来绘制了。另外,文本代码绘制图形的方式也便于版本管理。

安装

官网下载对应版本,当前最新的稳定版本是 2.3.8 ,已经将近四年年没有更新过。

安装完毕后,windows平台下,将 (安装目录)\Graphviz2.38\bin 加入系统环境变量 PATH 中。

打开输入命令行提示符,输入 dot -V,若出现 dot - graphviz version 2.38.0 (20140413.2041) 说明配置成功,可以开始我们的绘制之旅。

上手

新建一个 learn.dot,输入我们的第一个程序

1
2
3
4
5
6
7
8
9
//图(gragh)
digraph simple_demo {
// 节点(Node)
NodeA[label = "A"]
NodeB[label = "B"]

// 边(edge)
NodeA -> NodeB[label = "This a edge"]
}

命令行下输入

1
dot -Tpng learn.dot -o learn.png

查看 learn.png ,内容如下:

这是我们生成的第一个图片,我们来仔细介绍一下。

digraph

表明这个图是有向图。

_simple_demo

图的名称,一个图必须要有名称。

NodeA/NodeB

节点的内部名称,如果没有指定 label 参数,则会出现在最终图片中。

label

label 是节点的外部名称,用双引号括起。

->

有向边,从 NodeA 指向 NodeB。

上面的简单例子,给我们展示了 Dot 的语法是十分便于人类理解的,十分的自然。同时揭示了 Dot 语言组成的三个要素:图、节点、边。

支持中文

使用dot程序生成带有汉字信息的图片,需要注意两点,否则生成的图片中汉字会被显示为乱码:

  1. 文件应使用UTF-8编码保存,而不是Windows默认的ANSI编码。
  2. 字体应指定为支持汉字的字体,如宋体为SimSun。
  3. 为了让显示方便更优,中文字符串左右两边应该各留一个空格。

常用属性

对于各种结构的通用属性如下:

属性名称 默认值 含义
color black 颜色,颜色设置支持形如red#FF0000两种形式
fontcolor black 文字颜色
fontname Times-Roman 字体
fontsize 14 文字大小
label 显示的标签,支持’\n’换行,对于节点默认为节点名称
penwidth 1.0 线条宽度
style 样式

常用图属性如下:

属性名称 默认值 含义
bgcolor 背景颜色
concentrate false 是否允许多条边有公共部分
nodesep .25 节点之间的间隔(英寸)
peripheries 1 边界数
rank same,min,source, max,sink,设置多个节点顺序
rankdir TB 排序方向 可选值有:TB LR BT RL
ranksep .75 间隔
size 图的大小(英寸),如:”2,2”
labelloc 调整图或子图的标签的上下位置
labeljust 调整图或子图的标签的左右位置
compound false 如果为true,允许边从子图两边接入,配合lheadltail使用

常用节点属性如下:

属性名称 默认值 含义
shape ellipse 形状
sides 4 当shape=polygon时的边数
fillcolor lightgrey/black 填充颜色
fixedsize false 是否让标签影响节点的大小

常用边属性如下:

属性名称 默认值 含义
arrowhead normal 箭头头部形状
arrowsize 1.0 箭头大小
arrowtail normal 箭头尾部形状
constraint true 是否根据边来影响节点的排序
decorate 设置之后会用一条线来连接edge和label
dir forward 设置方向:forward,back,both,none
headclip true 是否到边界为止
tailclip true 与headclip类似
headlabel 边的头部显示的标签
taillabel 边的尾部显示的标签
weight 1 边的权重,越大越粗
lhead compound为true时,lhead用于指定边指向的cluster
ltail 与ltail类似

布局引擎与输出格式

我们在之前的输出文件命令,

1
dot -Tpng learn.dot -o learn.png

其中 learn.dot 是我们的源文件,
-o learn.png 指明了我们输出的目标文件名为 learn.png,
-Tpng 则指明了图片格式用的是png。

其实我们也可以拓展该命令,改为:

1
dot -Kdot -Tpng learn.dot -o learn.png

其中 -Kdot 表示使用 dot 布局。

Graphviz支持几种布局引擎:

  • dot:默认布局方式,主要用于有向图
  • neato:主要用于无向图
  • twopi:主要用于径向布局
  • circo:圆环布局
  • fdp:主要用于无向图
  • sfdp:主要绘制较大的无向图
  • patchwork:主要用于树哈希图(tree map)

Graphviz支持的输出图片格式更是相当的多,常用的有以下几种:

  • pdf
  • gif
  • png
  • jpeg
  • bmp
  • svg(矢量图,一般用与Web,可以用浏览器打开)
  • ps(矢量线图,多用于打印)

更多的输出格式可以浏览 Graphviz 输出格式进行查看。


引用

  1. https://blog.csdn.net/sd10086/article/details/52979462/
  2. https://www.cnblogs.com/taceywong/p/5439574.html
  3. http://www.360doc.com/content/16/0619/00/9482_568900997.shtml