程序结构与设计优化
1. 程序设计基础原则
在程序设计中,松耦合和高内聚是两个重要的原则。松耦合意味着接口组件之间的关联尽可能松散,这样在修改程序行为时,不会引发系统的连锁反应,使程序更易于维护。高内聚则是将程序逻辑划分为独立且功能相关的组件,这样便于对组件进行推理和测试,能构建出更稳定、少 bug 的系统。
2. 代码复用
代码复用是指实现一次功能后,在程序的不同部分重复使用该功能,而无需重复编写代码。代码重复会导致意外行为、可执行文件过大和维护成本增加等问题。函数是最基本的可复用功能单元,对于可能多次使用的逻辑,可将其封装为函数。若功能差异较小,可创建参数化函数以实现多种用途。
例如,虽然可以通过简单的 for 循环来计算以空字符结尾的字符串的长度,但使用 C 标准库中的 strlen 函数更易于维护。因为其他程序员熟悉这个函数,更容易理解其功能。此外,复用现有功能可减少临时实现中的行为差异,便于全局替换为性能更好或更安全的算法。
在设计功能接口时,需要在通用性和特定性之间取得平衡。特定于当前需求的接口可能简洁有效,但需求变化时难以修改;通用接口虽能适应未来需求,但可能在可预见的需求中显得繁琐。
3. 数据抽象
数据抽象是一种可复用的软件组件,它明确分离了抽象的公共接口和实现细节。公共接口包含数据类型定义、函数声明和常量定义,通常放在头文件中;实现细节和私有实用函数则隐藏在源文件或与公共接口头文件分开的头文件中。这种分离允许在不破坏依赖该组件的代码的情况下更改实现细节。
头文件通常包含组件的函数声明和类型定义。例如,C 标准库的 头文件提供了与字符串相关功能的公共接口, 提供了线程实用函数。这种逻辑分离具有低耦合和高内聚的特点,便于访问特定组件,减少编译时间和名称冲突的可能性。
数据抽象应尽量自包含,即包含其使用的头文件,否则会给用户带来负担并泄露实现细节。源文件实现头文件声明的功能或特定于应用程序的逻辑。例如,若有一个 network.h 头文件描述网络通信的公共接口,可能会有一个 network.c 源文件来实现网络通信逻辑。
集合是数据抽象的一个很好的例子,它将基本功能与实现或底层数据结构分离。集合可以用多种数据结构实现,如扁平数组、二叉树、有向图等。数据结构的选择会影响算法的性能,例如,对于需要良好查找性能的大量数据,二叉树可能是更好的抽象;对于固定大小的少量数据,扁平数组可能更合适。分离集合数据抽象的接口和底层数据结构的实现,可在不修改依赖集合接口的代码的情况下更改实现。
4. 不透明类型
数据抽象与隐藏信息的不透明数据类型结合使用时最为有效。在 C 语言中,不透明(或私有)数据类型使用不完整类型表示,如前向声明的结构体类型。不完整类型描述了一个标识符,但缺乏确定该类型对象大小或布局所需的信息。隐藏内部数据结构可防止程序员编写依赖于可能变化的实现细节的代码。
以下是一个实现集合的不透明类型的示例:
// collection.h
typedef struct collection * collection_type;
// function declarations
extern errno_t create_collection(collection_type *result);
extern void destroy_collection(collection_type col);
extern errno_t add_to_collection(
collection_type col, const void *data, size_t byteCount
);
extern errno_t remove_from_collection(
collection_type col, const void *data, size_t byteCount
);
extern errno_t find_in_collection(
const collection_type col, const void *data, size_t byteCount
);
// --snip--
// collection_priv.h
struct node_type {
void *data;
size_t size;
struct node_type *next;
};
struct collection_type {
size_t num_elements;
struct node_type *head;
};
用户只包含外部的 collection.h 文件,而实现抽象数据类型的模块还包含内部定义的 collection_priv.h 文件,这样可保持 collection_type 数据类型的实现私有。
5. 可执行文件与库
编译器的最终输出是目标代码,链接阶段将程序中所有翻译单元的目标代码链接在一起形成最终的可执行文件,可执行文件可以是用户可运行的程序、库、设备驱动程序或固件映像等。链接允许将代码分离到不同的源文件中独立编译,有助于构建可复用组件。
库是不能独立执行的可执行组件,需将其合并到可执行程序中。通过在源代码中包含库的头文件并调用声明的函数来调用库的功能。C 标准库就是一个例子,只需包含其头文件,而无需直接编译实现库功能的源代码。
库分为静态库和动态库。静态库将其机器代码或目标代码直接合并到最终的可执行文件中,通常与程序的特定版本相关联。在链接时合并静态库,可对其内容进行进一步优化,未使用的库代码可从最终可执行文件中剥离。动态库是没有启动例程的可执行文件,可与可执行文件一起打包或单独安装,但在可执行文件调用其提供的函数时必须可用。许多现代操作系统会将动态库代码加载到内存中一次,并在所有需要它的应用程序之间共享。
使用动态库的好处是可以在应用程序部署后更换不同版本的库,而无需重新编译应用程序,但也存在被恶意替换或版本不兼容的风险。静态库执行速度可能稍快,因为目标代码包含在可执行文件中,便于进一步优化。总体而言,使用动态库的好处通常大于缺点。
6. 链接
链接是控制接口是公共还是私有,并确定两个标识符是否引用同一实体的过程。C 语言提供了三种链接类型:外部链接、内部链接和无链接。具有外部链接的标识符在整个程序中引用同一个函数或对象;具有内部链接的标识符仅在包含声明的翻译单元内引用同一实体;无链接的声明在每个翻译单元中是唯一的实体。
链接可以显式声明或隐式声明。在文件作用域声明实体时,若未显式指定 extern 或 static,则默认具有外部链接。函数参数、未使用 extern 存储类说明符声明的块作用域标识符或枚举常量没有链接。
以下是不同链接类型的示例:
// 示例 1:显式链接
static int i; // i 具有显式内部链接
extern void foo(int j) {
// foo 具有显式外部链接
// j 没有链接,因为它是参数
}
// 示例 2:隐式链接
// foo.c
void func(int i) { // 隐式外部链接
// i 没有链接
}
static void bar(); // 内部链接,与 bar.c 中的 bar 不同
extern void bar() {
// bar 仍然具有内部链接,因为初始声明为 static;此 extern 说明符无效
}
// 示例 3:显式链接
// bar.c
extern void func(int i); // 显式外部链接
static void bar() { // 内部链接;与 foo.c 中的 bar 不同
func(12); // 调用 foo.c 中的 func
}
int i; // 外部链接;与 foo.c 或 bar.c 中的 i 不冲突
void baz(int k) { // 隐式外部链接
bar(); // 调用 bar.c 中的 bar,而不是 foo.c 中的 bar
}
公共接口中的标识符应具有外部链接,以便可以从其翻译单元外部调用;实现细节的标识符应声明为内部链接或无链接(前提是不需要从其他翻译单元引用)。常见的做法是在头文件中声明公共接口函数(可使用或不使用 extern 存储类说明符),并在源文件中以类似方式定义这些函数。在源文件中,所有实现细节的声明应显式声明为 static,以保持其私有性。
7. 简单程序结构示例
为了学习如何构建复杂的实际程序,我们开发一个简单的程序来判断一个数是否为质数。该程序由一个包含测试功能的静态库和一个为该库提供用户界面的命令行应用程序组成。
7.1 用户界面
首先,我们定义一个打印命令行帮助文本的函数:
// 打印命令行帮助文本
static void print_help() {
puts("primetest num1 [num2 num3 ... numN]\n");
puts("Tests positive integers for primality.");
printf("Tests numbers in the range [2-%llu].\n", ULLONG_MAX);
}
该函数将命令的使用信息打印到标准输出。
7.2 参数转换
由于命令行参数是以文本形式传递的,我们定义一个实用函数将其转换为整数值:
// 将字符串参数 arg 转换为无符号长整型值,并通过 val 引用返回
// 如果参数转换成功返回 true,失败返回 false
static bool convert_arg(const char *arg, unsigned long long *val) {
char *end;
// strtoull 返回带内错误指示;调用前清除 errno
errno = 0;
*val = strtoull(arg, &end, 10);
// 检查调用返回哨兵值并设置 errno 的失败情况
if ((*val == ULLONG_MAX) && errno) return false;
if (*val == 0 && errno) return false;
if (end == arg) return false;
// 如果到这里,参数转换成功
// 但是,我们只允许大于 1 的值,所以拒绝 <= 1 的值
if (*val <= 1) return false;
return true;
}
该函数接受一个字符串参数作为输入,并使用输出参数报告转换后的参数。它使用 strtoull 函数将字符串转换为无符号长整型值,并正确处理转换错误。由于质数的定义不包括 0、1 和负数,该函数将这些值视为无效输入。
我们使用 convert_arg 函数在 convert_cmd_line_args 函数中循环处理所有命令行参数:
static unsigned long long *convert_cmd_line_args(int argc,
const char *argv[],
size_t *num_args) {
*num_args = 0;
if (argc <= 1) {
// 没有提供命令行参数(第一个参数是正在执行的程序的名称)
print_help();
return nullptr;
}
// 我们知道用户可能传递的最大参数数量,所以分配一个足够大的数组来保存所有元素。减去程序名称本身。如果分配失败,将其视为转换失败(调用 free(nullptr) 是安全的)
unsigned long long *args =
(unsigned long long *)malloc(sizeof(unsigned long long) * (argc - 1));
bool failed_conversion = (args == nullptr);
for (int i = 1; i < argc && !failed_conversion; ++i) {
// 尝试将参数转换为整数。如果无法转换,将 failed_conversion 设置为 true
unsigned long long one_arg;
failed_conversion |= !convert_arg(argv[i], &one_arg);
args[i - 1] = one_arg;
}
if (failed_conversion) {
// 释放数组,打印帮助信息并退出
free(args);
print_help();
return nullptr;
}
*num_args = argc - 1;
return args;
}
如果任何参数转换失败,该函数将调用 print_help 函数向用户报告正确的命令行用法,并返回空指针。它负责分配足够大的缓冲区来保存整数数组,并处理所有错误情况,如内存不足或参数转换失败。如果函数成功,它将返回一个整数数组给调用者,并将转换后的参数数量写入 num_args 参数。返回的数组是动态分配的,不再使用时必须释放。
7.3 质数测试算法
判断一个数是否为质数有多种方法,我们使用 Miller-Rabin 质数测试算法:
static unsigned long long power(unsigned long long x, unsigned long long y,
unsigned long long p) {
unsigned long long result = 1;
x %= p;
while (y) {
if (y & 1) result = (result * x) % p;
y >>= 1;
x = (x * x) % p;
}
return result;
}
static bool miller_rabin_test(unsigned long long d, unsigned long long n) {
unsigned long long a = 2 + rand() % (n - 4);
unsigned long long x = power(a, d, n);
if (x == 1 || x == n - 1) return true;
while (d != n - 1) {
x = (x * x) % n;
d *= 2;
if (x == 1) return false;
if (x == n - 1) return true;
}
return false;
}
bool is_prime(unsigned long long n, unsigned int k) {
if (n <= 1 || n == 4) return false;
if (n <= 3) return true;
unsigned long long d = n - 1;
while (d % 2 == 0) d /= 2;
for (; k != 0; --k) {
if (!miller_rabin_test(d, n)) return false;
}
return true;
}
is_prime 函数接受两个参数:要测试的数字 n 和测试次数 k。k 值越大,结果越准确,但性能会变差。我们将该算法和 is_prime 函数放在一个静态库中,is_prime 函数将作为库的公共接口。
7.4 主函数实现
最后,我们将这些实用函数组合成一个程序:
int main(int argc, char *argv[]) {
size_t num_args;
unsigned long long *vals = convert_cmd_line_args(argc, argv, &num_args);
// 后续可添加对 vals 数组中每个值进行质数测试并输出结果的代码,以及释放 vals 内存的代码
// 例如:
if (vals != nullptr) {
for (size_t i = 0; i < num_args; ++i) {
if (is_prime(vals[i], 5)) {
printf("%llu is probably prime.\n", vals[i]);
} else {
printf("%llu is not prime.\n", vals[i]);
}
}
free(vals);
}
return 0;
}
主函数使用固定次数的 Miller-Rabin 测试,并报告输入值是可能为质数还是肯定不是质数。它还负责释放 convert_cmd_line_args 函数分配的内存。
通过这个简单的程序示例,我们可以看到如何将程序划分为不同的组件,利用代码复用和数据抽象等原则,构建出易于维护和扩展的程序。
总结
本文介绍了程序设计中的多个重要概念,包括松耦合和高内聚原则、代码复用、数据抽象、不透明类型、可执行文件与库、链接以及如何构建一个简单的质数测试程序。这些概念和技术对于编写高质量、可维护和可扩展的程序至关重要。在实际编程中,应根据具体需求合理运用这些原则和技术,以提高程序的性能和可靠性。
流程图
graph TD;
A[开始] --> B[打印帮助信息];
B --> C{是否有命令行参数};
C -- 否 --> B;
C -- 是 --> D[分配内存];
D --> E{内存分配是否成功};
E -- 否 --> B;
E -- 是 --> F[循环转换参数];
F --> G{参数转换是否成功};
G -- 否 --> H[释放内存];
H --> B;
G -- 是 --> I[进行质数测试];
I --> J[输出测试结果];
J --> K[释放内存];
K --> L[结束];
表格
| 概念 | 说明 |
|---|---|
| 松耦合 | 接口组件之间关联松散,修改程序行为时减少连锁反应 |
| 高内聚 | 将程序逻辑划分为独立且功能相关的组件,便于推理和测试 |
| 代码复用 | 实现一次功能后在不同部分重复使用,避免代码重复 |
| 数据抽象 | 分离公共接口和实现细节,便于维护和修改 |
| 不透明类型 | 隐藏内部数据结构,防止依赖实现细节的代码 |
| 静态库 | 将机器代码合并到可执行文件中,与程序特定版本相关 |
| 动态库 | 可在应用程序部署后更换版本,需注意安全和兼容性 |
| 链接 | 控制接口公共性和标识符引用关系,有外部、内部和无链接三种类型 |
程序结构与设计优化(续)
8. 综合分析与最佳实践
为了更清晰地理解上述各个概念在实际编程中的应用,我们可以通过一个综合的视角来分析它们之间的关系和相互作用。以下是一些基于前面内容总结的最佳实践:
- 模块化设计 :将程序划分为多个独立的模块,每个模块具有高内聚性,只负责单一的功能。例如,在质数测试程序中,将参数转换、质数测试和用户界面等功能分别封装在不同的函数或模块中,使得每个部分的功能清晰明确,易于维护和扩展。
- 合理使用库 :根据项目需求选择合适的静态库或动态库。对于那些不经常变化且需要高性能的功能,可以考虑使用静态库;而对于可能需要频繁更新或共享的功能,动态库是更好的选择。例如,在开发一个大型应用程序时,可以将一些通用的工具函数封装成动态库,供多个模块共享使用。
- 控制链接类型 :合理使用链接类型可以有效地控制程序的可见性和作用域。公共接口的标识符应具有外部链接,以便在不同的模块中调用;而实现细节的标识符则应使用内部链接或无链接,避免全局命名空间的污染。例如,在编写一个库时,将公共函数的声明放在头文件中,并使用外部链接,而将内部使用的辅助函数使用内部链接。
9. 性能优化与权衡
在程序设计过程中,性能优化是一个重要的考虑因素。不同的设计选择会对程序的性能产生不同的影响,因此需要在各个方面进行权衡。
- 代码复用与性能 :代码复用可以减少代码的重复,提高开发效率,但有时可能会引入一定的性能开销。例如,使用函数调用进行代码复用会带来函数调用的开销,特别是在频繁调用的情况下。因此,在性能敏感的场景中,需要谨慎考虑代码复用的方式。
- 静态库与动态库的性能 :静态库将代码直接合并到可执行文件中,执行速度可能会稍快一些,因为避免了动态加载的开销。而动态库需要在运行时加载,可能会有一定的性能损失,但它具有更好的灵活性和可维护性。在选择使用静态库还是动态库时,需要根据具体的应用场景进行权衡。
- 算法选择与性能 :不同的算法具有不同的时间复杂度和空间复杂度,选择合适的算法对于提高程序的性能至关重要。例如,在质数测试中,使用 Miller - Rabin 算法比简单的暴力枚举算法具有更好的性能,特别是对于大数值的测试。
10. 错误处理与健壮性
在程序设计中,错误处理是确保程序健壮性的重要环节。一个好的程序应该能够正确处理各种可能出现的错误情况,避免程序崩溃或产生不可预期的结果。
-
参数转换错误处理
:在质数测试程序中,参数转换是一个容易出错的环节。通过在
convert_arg和convert_cmd_line_args函数中进行错误处理,当参数转换失败时,程序能够及时输出帮助信息并退出,避免了后续操作的错误。 -
内存管理错误处理
:动态内存分配可能会失败,因此在使用
malloc等函数进行内存分配时,需要检查返回值是否为nullptr。在质数测试程序中,convert_cmd_line_args函数在内存分配失败时,会释放已分配的内存并输出帮助信息,确保程序的健壮性。
11. 实际应用案例分析
为了更好地理解上述概念在实际项目中的应用,我们可以分析一个简单的实际应用案例。假设我们要开发一个文件管理系统,该系统需要实现文件的创建、删除、读取和写入等功能。
- 模块化设计 :将文件管理系统划分为多个模块,如文件操作模块、用户界面模块和错误处理模块。每个模块负责不同的功能,例如文件操作模块负责文件的实际操作,用户界面模块负责与用户进行交互,错误处理模块负责处理各种可能出现的错误。
- 代码复用 :可以将一些通用的文件操作函数封装成函数库,供不同的模块复用。例如,将文件的读取和写入函数封装成一个静态库,在需要进行文件操作的模块中直接调用这些函数。
- 数据抽象 :使用数据抽象来隐藏文件操作的实现细节,提供一个简洁的公共接口。例如,定义一个文件对象,通过该对象的方法来进行文件的创建、删除等操作,而用户不需要关心文件操作的具体实现。
12. 总结与展望
通过对程序设计中的各个重要概念的介绍和分析,我们可以看到,合理运用松耦合、高内聚、代码复用、数据抽象等原则和技术,能够构建出高质量、可维护和可扩展的程序。在实际编程中,需要根据具体的需求和场景,灵活运用这些原则和技术,不断优化程序的性能和健壮性。
未来,随着计算机技术的不断发展,程序设计也将面临新的挑战和机遇。例如,随着人工智能和大数据的兴起,程序需要处理的数据量越来越大,对程序的性能和可扩展性提出了更高的要求。因此,我们需要不断学习和掌握新的技术和方法,以适应不断变化的需求。
流程图
graph TD;
A[开始开发文件管理系统] --> B[设计模块化结构];
B --> C[实现代码复用];
C --> D[应用数据抽象];
D --> E[进行错误处理];
E --> F[测试与优化];
F --> G[部署与维护];
G --> H{是否有新需求};
H -- 是 --> B;
H -- 否 --> I[结束];
表格
| 程序设计方面 | 要点 | 示例 |
|---|---|---|
| 模块化设计 | 划分独立模块,高内聚低耦合 | 文件管理系统划分为文件操作、用户界面和错误处理模块 |
| 代码复用 | 避免代码重复,提高开发效率 | 封装通用文件操作函数为静态库 |
| 数据抽象 | 分离接口和实现,提供简洁公共接口 | 定义文件对象进行文件操作 |
| 性能优化 | 权衡不同设计选择对性能的影响 | 选择合适的算法和库类型 |
| 错误处理 | 确保程序健壮性,处理各种错误情况 | 在参数转换和内存分配时进行错误检查 |
通过上述内容,我们对程序设计的各个方面有了更深入的理解,希望这些知识能够帮助你在实际编程中设计出更好的程序。
超级会员免费看

被折叠的 条评论
为什么被折叠?



