随着其广泛使用,许多常见的实践和惯例已经发展,以帮助避免C程序中的错误。这些同时也证明了良好的软件工程原理在语言中的应用以及C的局限性。尽管很少使用普遍的,有些是有争议的,但每个都有广泛的用途。
动态多维数组
尽管使用malloc可以轻松创建一维数组,并且使用内置语言功能可以轻松创建固定大小的多维数组,但动态多维数组更加棘手。创建它们有许多不同的方法,每种方法都有不同的权衡。创建它们的两种最流行的方法是:
- 它们可以作为单个内存块分配,就像静态多维数组一样。这要求阵列是矩形的(即较低尺寸的子阵列是静态的并且具有相同的尺寸)。缺点是声明指针的语法起初对程序员来说有点棘手。例如,如果有人想创建3列和int数组行的行,人会做
int (* multi_array )[ 3 ] = malloc (rows * sizeof (int [ 3 ]));
(注意,这里multi_array是指向3个int的数组的指针。)由于数组指针的可互换性,您可以像静态多维数组一样对其进行索引,即multi_array [5] [2]是第6行和第3列的元素。
- 可以通过首先分配指针数组,然后分配子数组并将其地址存储在指针数组中来分配动态多维数组。(这种方法也称为Iliffe向量)。访问元素的语法与上面描述的多维数组相同(即使它们的存储方式非常不同)。这种方法的优点是能够制作不规则的阵列(即具有不同尺寸的子阵列)。但是,它也使用更多空间并需要更多级别的间接索引,并且可能具有更差的缓存性能。它还需要许多动态分配,每个动态分配都很昂贵。
在某些情况下,多维数组的使用最好能够作为一组结构来处理。在用户定义的数据结构可用之前,常用的技术是定义一个多维数组,其中每列包含有关该行的不同信息。初学者程序员也经常使用这种方法。例如,二维字符数组的列可能包含姓氏,名字,地址等。
在这种情况下,最好定义一个包含存储在列中的信息的结构,然后创建一个指向该结构的指针数组。当给定记录的数据点数可能不同时(例如专辑中的曲目),尤其如此。在这些情况下,最好为包含相册信息的相册创建结构,并为相册中的歌曲列表创建动态数组。然后可以使用指向相册结构的指针数组来存储该集合。
- 另一种创建动态多维数组的有用方法是手动展平数组和索引。例如,大小为x和y的二维数组具有x * y个元素,因此可以通过创建
int dynamic_multi_array [ x * y ];
索引比以前稍微复杂一些,但仍然可以通过y * i + j获得。然后,您可以使用
static_multi_array [ i ] [ j ]; dynamic_multi_array [ y * i + j ];
更多维度的更多示例:
int dim1 [ w ]; int dim2 [ w * x ]; int dim3 [ w * x * y ]; int dim4 [ w * x * y * z ]; dim1 [ i ] dim2 [ w * j + i ]; dim3 [ w * (x * i + j )+ k ] //索引是k + w * j + w * x * i dim4 [ w * (x * (y * i + j )+ k )+ l ] / / index是w * x * y * i + w * x * j + w * k + l
请注意,w *(x *(y * i + j)+ k)+ l等于w * x * y * i + w * x * j + w * k + l,但使用的操作更少(参见Horner方法))。它使用与通过dim4 [i] [j] [k] [l]访问静态数组相同的操作数,因此不应该使用更慢。
使用此方法的优点是数组可以在函数之间自由传递,而不需要在编译时知道数组的大小(因为C将其视为一维数组,尽管仍然需要传递维度的一些方法),并且整个数组在内存中是连续的,因此访问连续元素应该很快。缺点是首先很难习惯如何索引元素。
构造函数和析构函数
在大多数面向对象的语言中,对象不能由希望使用它们的客户端直接创建。相反,客户端必须要求类使用称为构造函数的特殊例程来构建对象的实例。构造函数很重要,因为它们允许对象在其整个生命周期内强制执行有关其内部状态的不变量。在对象的生命周期结束时调用的析构函数在对象拥有对某些资源的独占访问权的系统中很重要,并且希望确保它释放这些资源以供其他对象使用。
由于C不是面向对象的语言,因此它没有内置的构造函数或析构函数支持。客户端显式分配和初始化记录和其他对象的情况并不少见。但是,这会导致错误,因为如果对象未正确初始化,对象上的操作可能会失败或行为不可预测。更好的方法是创建一个创建对象实例的函数,可能需要初始化参数,如下例所示:
struct string { size_t size ; char * 数据; }; struct string * create_string (const char * initial ) { assert (initial != NULL ); struct string * new_string = malloc (sizeof (* new_string )); if (new_string != NULL ) { new_string - > size = strlen (initial ); new_string - > data = strdup (初始); } return new_string ; }
同样,如果留给客户端正确销毁对象,它们可能无法这样做,导致资源泄漏。最好有一个总是使用的显式析构函数,例如:
void free_string (struct string * s ) { assert (s != NULL ); 免费(s - > 数据); ' / *结构所拥有的免费记忆* / '' free (s ); '' / *释放结构本身* / '' }
将析构函数与#Nulling释放指针结合使用通常很有用。
有时隐藏对象的定义以确保客户端不会手动分配它是有用的。为此,在源文件(或用户不可用的私有头文件)中定义结构而不是头文件,并在头文件中放入前向声明:
结构 字符串; struct string * create_string (const char * initial ); void free_string (struct string * s );
Nulling释放指针
如前所述,在free()
调用指针之后,它变成了悬空指针。更糟糕的是,大多数现代平台在重新分配之前无法检测何时使用此类指针。
一个简单的解决方案是确保在释放后立即将任何指针设置为空指针:
免费(p ); p = NULL ;
与悬空指针不同,当空指针被取消引用时,许多现代体系结构将出现硬件异常。此外,程序可以包括空值的错误检查,但不包括悬空指针值的错误检查。为确保在所有位置完成,可以使用宏:
#define FREE(p)做{free(p); (p)= NULL; } while(0)
(要查看宏以这种方式编写的原因,请参阅#Macro约定。)此外,当使用此技术时,析构函数应将其传递的指针清零,并且必须通过引用传递它们的参数以允许此操作。例如,这里是#Constructors和析构函数的析构函数更新:
void free_string (struct string ** s ) { assert (s != NULL && * s != NULL ); 免费((* s )- > 数据); ' / *结构所拥有的免费记忆* / '' FREE (* s ); '' / *释放结构本身* / '' * s = NULL ; '' / *零参数* / '' }
不幸的是,这个习惯用法不会对可能指向释放内存的任何其他指针做任何事情。出于这个原因,一些C专家认为这种习语是危险的,因为它会产生一种虚假的安全感。
宏约定
因为C中的预处理器宏使用简单的令牌替换,所以它们容易出现许多令人困惑的错误,其中一些可以通过遵循一组简单的约定来避免:
- 尽可能围绕宏参数放置括号。这确保了,如果它们是表达式,则操作顺序不会影响表达式的行为。例如:
- 错误:
#define square(x) x*x
- 更好:
#define square(x) (x)*(x)
- 错误:
- 如果它是单个表达式,则在整个表达式周围放置括号。同样,这避免了由于操作顺序而导致的意义变化。
- 错误:
#define square(x) (x)*(x)
- 更好:
#define square(x) ((x)*(x))
- 危险,记住它逐字取代文本。假设您的代码
square (x++)
在宏调用之后将x递增2
- 错误:
- 如果宏生成多个语句或声明变量,它可以包装在do {…} while(0)循环中,没有终止分号。这允许宏像任何位置中的单个语句一样使用,例如if语句的主体,同时仍然允许在宏调用之后放置分号而不创建空语句。必须注意任何新变量都不会掩盖宏参数的部分内容。
- 错误:
#define FREE(p) free(p); p = NULL;
- 更好:
#define FREE(p) do { free(p); p = NULL; } while(0)
- 错误:
- 如果可能,避免在宏内使用两次或更多宏参数; 这会导致包含副作用的宏参数出现问题,例如赋值。
- 如果宏将来可能被函数替换,考虑将其命名为函数。
猜你想读:《C编程.高级C》2.预处理器指令和宏