《极致C语言》第1章 -- 基本特性

《极致C语言》第1章 – 基本特性

extreme-c-learning-notes ch1

1. 预处理指令

预处理指令是 C 语言的一个功能强大的特性,即在把源代码提交给编译器之前对源代码进行的设计和修改。

预处理的目的是删除预处理指令,并用生成的等效 C 代码替换它们,从而得到提交给编译器的最终源代码。C 预处理器通过一组指令(directives)来控制其行为。

在头文件和源文件中,C 预处理指令都是以 # 开头的代码行。这些行只对 C 预处理器有意义,对 C 编译器没有意义。C 语言中有各种各样的预处理器指令,但其中有一些非常重要,尤其是用于宏定义和条件编译的指令。

宏有很多应用,其中一些如下:

  • 定义一个常量
  • 像函数一样使用,而不是编写 C 函数
  • 循环展开
  • 头文件保护
  • 代码生成
  • 条件编译
  1. 定义一个宏

宏的定义使用 #define 指令。每个宏都有一个名称和一个可能的参数列表,她还有一个值(value)。在预处理阶段中,通过一个名为 “宏展开” 的步骤,将宏的名称(name)替换为值(value)。宏也可以用 #undef 指令来解除定义(undefined)。

ExtremeC_examples_chapter1_1.c

#define ABC 5

int main(int argc, char** argv) {
    int x = 2;
    int y = ABC;
    int z = x + y;

    return 0;
}

宏展开后,得到的可被提交给 C 编译器的代码如下

int main(int argc, char** argv) {
    int x = 2;
    int y = 5;
    int z = x + y;

    return 0;
}

ExtremeC_examples_chapter1_2.c

#define ADD(a, b) a + b

int main(int argc, char** argv) {
    int x = 2;
    int y = 3;
    int z = ADD(x, y);

    return 0;
}

宏展开后,得到的可被提交给 C 编译器的代码如下

int main(int argc, char** argv) {
    int x = 2;
    int y = 3;
    int z = x + y;

    return 0;
}

在代码 xxx1-2.c 中可以看到,宏展开按如下过程执行:实际参数(argument) x 会替换宏中所有形式参数(parameter) a。对于形式参数 b 和它对应的实际参数 y 来说也是一样的。预处理后我们得到替换后的最后结果:x + y 取代了 ADD(a, b);

因为类函数宏可以接受输入参数,所以它们可以模仿 C 函数。换句话说,你可以将常用的逻辑定义为类函数的宏,并使用该宏,而不是使用 C 函数。

宏只在编译之前存在。这意味着,从理论上讲,编译器对宏一无所知。如果你要使用宏而不是函数,这一点非常重要。编译器对函数了如指掌,因为它是 C 语法的一部分,它被拆解并保存在 解析树(parse tree) 中。但是宏只是 C 语言的预处理指令,只有预处理器知道。

宏允许在编译之前 生成(generate) 代码。在 Java 等其它编程语言中,则需要使用 代码生成器(code generator) 来实现这一目的。

现代 C 编译器知道 C 预处理指令。尽管人们普遍认为编译器对预处理阶段一无所知,但实际上并非如此。现代的 C 编译器在进入预处理阶段之前就已经知道源代码了。

example.c

#include <stdio.h>

#define CODE \
printf("%d\n", i)

int main(int argc , char **argv) {
    CODE;
    return 0;
}

使用 gcc 编译以上代码,输出如下:

example.c: In function ‘main’:
example.c:4:16: error: ‘i’ undeclared (first use in this function)
    4 | printf("%d\n", i)
      |                ^
example.c:7:5: note: in expansion of macro ‘CODE’
    7 |     CODE;
      |     ^~~~
example.c:4:16: note: each undeclared identifier is reported only once for each function it appears in
    4 | printf("%d\n", i)
      |                ^
example.c:7:5: note: in expansion of macro ‘CODE’
    7 |     CODE;
      |     ^~~~

可见,编译器生成了一条错误消息,并准确的指向宏定义所在的行。

Tips:

在大多数现代编译器中,可以在编译前查看预处理结果。例如,在使用 gccclang时,可以使用 -E 选项在预处理后转存储代码。

$ gcc -E example.c 

...

# 6 "example.c"
int main(int argc , char **argv) {
    printf("%d\n", i);
    return 0;
}

翻译单元(translation unit)编译单元(compilation unit) 是预处理后准备传递给编译器的 C 代码。

在一个翻译单元中,所有的指令都被包含的文件内容或宏展开替换,并生成一段长的,扁平的 C 代码。

ExtremeC_examples_chapter1_3.c

#include <stdio.h>

#define PRINT(a) printf("%d\n", a);
#define LOOP(v, s, e) for (int v = s; v <= e; v++) {
#define ENDLOOP }

int main(int argc, char** argv) {
    LOOP(counter, 1, 10)
        PRINT(counter)
    ENDLOOP

    return 0;
}

经过预处理后的代码:


...

# 7 "ExtremeC_examples_chapter1_3.c"
int main(int argc, char** argv) {
    for (int counter = 1; counter <= 10; counter++) {
        printf("%d\n", counter);
    }

    return 0;
}

在上述示例代码中我们使用宏来编写算法,然后经过预处理,得到一个功能完善、正确的 C 程序。这是宏的一个重要应用:定义一种新的 特定领域语言(domain specific language,DSL) 用它编写代码。

DSL 作用于项目的不同部分。例如,它们在测试框架(如谷歌测试框架(gtest))中被大量使用,用于编写断言,期望和测试场景。

### 操作符

ExtremeC_examples_chapter1_4.c

#include <stdio.h>
#include <string.h>

#define CMD(NAME) \
char NAME ## _cmd[256] = "";\
strcpy(NAME ## _cmd, #NAME);

int main(int argc, char** argv) {
    CMD(copy)
    CMD(paste)
    CMD(cut)

    char cmd[256];
    scanf("%s", cmd);

    if (strcmp(cmd, copy_cmd) == 0) {
        //...
    }
    if (strcmp(cmd, paste_cmd) == 0) {
        //...
    }
    if (strcmp(cmd, cut_cmd) == 0) {
        //...
    }

    return 0;
}

预处理后的源代码:

# 8 "ExtremeC_examples_chapter1_4.c"
int main(int argc, char** argv) {
    char copy_cmd[256] = "";strcpy(copy_cmd, "copy");
    char paste_cmd[256] = "";strcpy(paste_cmd, "paste");
    char cut_cmd[256] = "";strcpy(cut_cmd, "cut");

    char cmd[256];
    scanf("%s", cmd);

    if (strcmp(cmd, copy_cmd) == 0) {

    }
    if (strcmp(cmd, paste_cmd) == 0) {

    }
    if (strcmp(cmd, cut_cmd) == 0) {

    }

    return 0;
}

如上所示,在宏展开的时候,# 操作符将参数转换为由双引号括起来的字符串形式。例如,在前面的代码中,在参数 NAME 之前使用 # 运算符,在预处理之后,将参数 NAME 变成 copy

## 运算符由不同的含义。他只是将参数与宏定义中的其它元素连接起来,通常形成变量名。

Tips:

当宏定义很长的时候,可以使用 \ (反斜杠)将其分成多行。\ 让预处理器知道其余的定义在下一行。注意 \ 不会被换行符替换,相反,它指示下一行是相同宏定义的延续。

  1. 可变参数的宏

ExtremeC_examples_chapter1_5.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define VERSION "2.3.4"

#define LOG_ERROR(format, ...)\
    fprintf(stderr, format, __VA_ARGS__)

int main(int argc, char** argv) {
    if ( argv < 3 ) {
        LOG_ERROR("Invalid number of arguments for version %s\n.", VERSION);
        exit(1);
    }

    if ( strcmp(argv[1], "- n") != 0 ) {
        LOG_ERROR("% s is a wrong param at index %d for version %s.", argv[1], 1, VERSION);
        exit(1);
    }

    //...

    return 0;
}

在上述代码中,有一个新的标识符:__VA_ARGS__。它是一个指示符,告诉预处理器将其替换为尚未分配给任何形参的所有剩余输入实参。

在上述例子中,当第二次使用 LOG_ERROR 时,根据宏定义,参数 argv[1]1VERSION 是那些没有赋值给任何形参的输入实参。因此,在宏展开时,它将被用来代替 __VA_ARGS__

下面是通过 C 预处理器后的代码

# 10 "ExtremeC_examples_chapter1_5.c"
int main(int argc, char** argv) {
    if ( argv < 3 ) {
        fprintf(stderr, "Invalid number of arguments for version %s\n.", "2.3.4");
        exit(1);
    }

    if ( strcmp(argv[1], "- n") != 0 ) {
        fprintf(stderr, "% s is a wrong param at index %d for version %s.", argv[1], 1, "2.3.4");
        exit(1);
    }

    return 0;
}

使用可变参数宏来模拟循环

ExtremeC_examples_chapter1_6.c

#include <stdio.h>

#define LOOP_3(X, ...)\
    printf("%s\n", # X);

#define LOOP_2(X, ...)\
    printf("%s\n", # X);\
    LOOP_3 (__VA_ARGS__)

#define LOOP_1(X, ...)\
    printf("%s\n", # X);\
    LOOP_2 (__VA_ARGS__)

#define LOOP(...)\
    LOOP_1(__VA_ARGS__)

int main(int argc, char** argv) {
    LOOP(copy paste cut)
    LOOP(copy, paste, cut)
    LOOP(copy, paste, cut, select)

    return 0;
}

预处理后的代码:

# 17 "ExtremeC_examples_chapter1_6.c"
int main(int argc, char** argv) {
    printf("%s\n", "copy paste cut"); printf("%s\n", ""); printf("%s\n", "");
    printf("%s\n", "copy"); printf("%s\n", "paste"); printf("%s\n", "cut");
    printf("%s\n", "copy"); printf("%s\n", "paste"); printf("%s\n", "cut");

    return 0;
}

查看预处理过的代码,会发现 LOOP 宏已经扩展为多条 printf 指令,而不是 for 或者 while 等循环指令。原因很明显,就是因为预处理器不会智能编写 C 代码,它只是用我们给出的指令来替换宏。

使用宏创建循环的唯一方法是将迭代指令作为单独指令挨个放置。这意味着,一个包含 1000 次迭代的简单宏构成的循环将被替换成 1000 条 C 语言指令,并且在最终代码中将不会有任何实际的 C 循环。

这种逐个使用指令,而不把指令放入循环中的方式被称为 循环展开(loop unrolling) 。这种方式的缺点是生成的二进制文件比较大,但有它自己的应用场合。如,在受限的环境中达到较高的性能水平。根据目前的解释,似乎使用宏来展开循环是在二进制文件的大小和性能之间做权衡。

在上述例子中可以看到,在 main 函数中, LOOP 宏的不同用法产生了不同的结果。在第一个用法中,我们传递 copy paste cat,单词之间不带任何逗号。预处理器将其作为单个输入,因此模拟循环只有一次迭代。在第二种用法中,输入 copy, paste, cut,以逗号分隔单词传递。这次,预处理器将它们视为三个不同的实参;因此模拟循有三次迭代。第三种用法中传递了四个值:copy, paste, cut, select,但只有三个被处理了。可见,预处理后的代码与第二种用法完全相同。这是因为次循环宏最多只能处理三个元素,第三个元素之后的额外元素被忽略了。

编译运行:

$ gcc ExtremeC_examples_chapter1_6.c
$ ./a.out
copy paste cut


copy
paste
cut
copy
paste
cut
  1. 宏的优缺点

宏有一个重要的特征。如果你写了一些东西,它们将在编译之前被其他代码所替换,因此,在编译前你将得到一段没有任何模块化的扁平代码。这会导致你的头脑中有模块化的思路,在宏中可能也有,但是在最终的二进制文件中却没有。这正是使用宏会引起设计问题的地方。

软件设计试图将相似的算法和概念打包到几个可管理和可重用的 模块(modules) 中,但宏试图将所有内容线性化和扁平化。因此,当你在软件设计中使用宏作为一些逻辑构建块时,在最终的翻译单元中,有关宏的信息可能在预处理阶段之后丢失。基于此原因,程序架构师和设计师在使用宏时会遵循以下经验法则:

如果宏可以改写成 C 函数,那么应该将宏改写为 C 函数!

从调试的角度来看,也有人说宏是邪恶的。开发人员日常工作的一部分就是使用编译错误来定位 语法错误(syntax error) 。还使用日志(log)和编译警告(compilation warnings)来检测错误(bug)并修复。编译错误和警告都有利于分析错误(bug),它们都是由编译器生成的。

从不同的角度来看,性能问题起始于为设计阶段定义的问题选择合适的算法。下一步通常称为 优化(optimization)性能调优(performance tuning) 。在这个阶段,获得性能等同于让 CPU 以线性和顺序得方式计算,而不是强迫它在代码得不同部分之间跳转。这可以通过修改已经使用得算法或用一些性能更好、通常更复杂得算法替换它们来实现。这个阶段可能会与设计理念相冲突。正如我们之前所说的,设计试图将事物置于一个层次结构中,并使它们称为非线性的,但 CPU 期望操作是线性的,已经获取并准备好被处理。因此,应该针对每个问题分别进行处理和平衡。

条件编译

条件编译是 C 语言的另一个特性。它能让你根据不同的条件得到预处理后的不同源代码。

ExtremeC_examples_chapter1_7.c

#define CONDITION

int main(int argc, char** argv) {

#ifdef CONDITION
    int i = 0;
    i++;
#endif
    int j = 0;
    
    return 0;
}

预处理后的源码:

int main(int argc, char** argv) {


    int i = 0;
    i++;

    int j = 0;

    return 0;
}

如果没有宏定义 CONDITION#ifdef#endif 指令之间的代码就不会出现任何代换。预处理后的代码可能如下:

int main(int argc, char** argv) {

    int j = 0;

    return 0;
}

可以在编译命令中使用 -D 选项来定义宏,将定义传递给编译命令。对于前面的例子,可以定义 CONDITION 宏如下:

gcc -DCONDITION -E ExtremeC_examples_chapter1_7.c

这个特性允许你在源文件之外定义宏。当只有一个源代码,但要为不同的体系结构(如 Linux 或 macOS)编译时,这一点特别有用,因为它们有不同的默认宏定义和库。

#ifndef 的常见用法之一是被用作同 文件保护(header guard) 语句。这条语句防止头文件在预处理阶段被包含两次,可以说几乎每个项目中的所有 C 和 C++ 头文件都把这条语句作为它们的第一条指令。

ExtremeC_examples_chapter1_8.h

#ifndef EXAMPLE_1_8_H
#define EXAMPLE_1_8_H

void say_hello();
int read_age();

#endif

如果不使用 #ifndefendif 指令,还可以使用 #pragma once 指令以保护头文件免受 双重包含(double inclusion) 问题的影响。条件编译指令和 #pragma once 指令的区别在于,尽管几乎所有 C 预处理器都支持 #pragma once,但它不是 C 标准。然而如果需要保证代码的 可移植性(portability) ,最好不要使用它。

2. 变量指针

指向变量的指针(或称为短指针)的概念是 C 语言中最基本的概念之一。在大多数高级编程语言中,难觅指针的踪迹。事实上,它们已经被一些相似的概念所取代。例如 Java 中的引用(reference)。值得一提的是,指针所指向的地址可以直接被硬件使用,但其相似概念(引用)的情况并非如此,因此指针是独树一帜的。

语法

任何类型的指针背后的思想都很简单,它只是保存 内存地址(memory address) 的简单变量。

ExtremeC_examples_chapter1_9.c

int main(int argc, char** argv) {
    int var = 100;
    int* ptr = 0;
    ptr = &var;
    *ptr = 200;

    return 0;
}

在声明指针时必须初始化,这一点非常重要。如果在声明它们时不想存储任何有效的内存地址,可以通过 赋 0 值或 NULL 将指针置空! 而不是让它们保持未初始化的状态。否则可能面临致命的错误!!!

变量指针的算数运算

算数步长(arithmetic step size) ,每个指针都有一个算数步长,它对应着当指针加 1 或减 1 时将移动的字节数。这个算数步长由指针指向的 数据类型(data type) 决定。

在每个平台中,都有一个基本的存储单元,指针存储的都是该内存中基本存储单元的地址。所以,所有指针使用的字节数都应该一样。但这并不意味着它们的算数步长都相等。如前所述,指针的算术步长由其 C 数据类型决定。

例如,一个 int 类型的指针和一个 char 类型的指针使用的字节数是相同的,但是它们算数步长不同。int * 的算数步长通常是 4 字节,而 char * 的算数步长是 1 字节。因此,一个整型指针加 1 使它在内存中向前移动 4 个字节(在当前地址上增加 4 个字节),一个字符类型指针加 1 使它在内存中只向前移动 1 个字节。

ExtremeC_examples_chapter1_10.c

#include <stdio.h>

int main(int argc, char** argv) {
    int var = 1;

    int* int_ptr = NULL;  //使指针置空
    int_ptr = &var;

    char* char_ptr = NULL;
    char_ptr = (char*)&var;
    printf("Before arithmetic: int_ptr: %u, char_ptr: %u\n",
           (unsigned int)int_ptr, (unsigned int)char_ptr);

    int_ptr++;           //算术步长通常为 4 字节
    char_ptr++;          //算术步长是 1 字节

    printf("After arithmetic: int_ptr: %u, char_ptr: %u\n",
           (unsigned int)int_ptr, (unsigned int)char_ptr);

    return 0;
}

编译:gcc ExtremeC_examples_chapter1_10.c

运行:./a.out

Before arithmetic: int_ptr: 1105299316, char_ptr: 1105299316
After arithmetic: int_ptr: 1105299320, char_ptr: 1105299317

通过对算数运算前后地址的比较可以清楚的看出,整型指针的步长为 4 字节,字符型指针的步长为 1 字节。如果再次运行这个例子,指针可能会指向一些其它地址,但它们的算数步长保持不变

使用指针算术运算遍历(iterate)一段内存区域:

ExtremeC_examples_chapter1_11.c

#include <stdio.h>

#define SIZE 5

int main(int argc, char** argv) {
    int arr[SIZE];
    arr[0] = 9;
    arr[1] = 22;
    arr[2] = 30;
    arr[3] = 2;
    arr[4] = 18;

    for (int i = 0; i < SIZE; i++) {
        printf("%d\n", arr[i]);
    }
}

使用指针在数组边界范围内遍历数组:

ExtremeC_examples_chapter1_12.c

#include <stdio.h>

#define SIZE 5

int main(int argc, char** argv) {
    int arr[SIZE];
    arr[0] = 9;
    arr[1] = 22;
    arr[2] = 30;
    arr[3] = 23;
    arr[4] = 18;

    int* ptr = &arr[0];

    for (;;) {
        printf("%d\n", *ptr);
        if (ptr == &arr[SIZE - 1]) {
            break;
        }
        ptr++;
    }

    return 0;
}

注意:

在 C 语言中,数组实际上是一个指针,指向数组的第一个元素。在这个例子中,arr 的实际数据类型是 int *。因此:

int *ptr = arr; <==> int *ptr = &arr[0];

通用指针

void * 类型的指针称为通用指针。它像其他指针一样可以指向任何地址,但由于不知道它的实际数据类型,因此也不知道它的算术步长。通用指针通常用于保存其他指针的内容,但未保留这些指针的实际数据类型。因此,不能对通用指针进行解引用,也不能对它进行算术运算,因为它指向的数据的类型是未知的。

ExtremeC_examples_chapter1_13.c

#include <stdio.h>

int main(int argc, char** argv) {
    int var = 9;
    int* ptr = &var;
    void* gptr = ptr;
    printf("%d\n", *gptr);

    return 0;
}

使用 gcc 编译上述代码,会有如下报错:

$ gcc ExtremeC_examples_chapter1_13.c 
ExtremeC_examples_chapter1_13.c: In function ‘main’:
ExtremeC_examples_chapter1_13.c:7:20: warning: dereferencing ‘void *’ pointer
    7 |     printf("%d\n", *gptr);
      |                    ^~~~~
ExtremeC_examples_chapter1_13.c:7:20: error: invalid use of void expression

通用指针非常便于定义 通用函数(generic function) ,通用函数可以接受各种不同类型的指针作为它们的输入实参。

ExtremeC_examples_chapter1_14.c

#include <stdio.h>

void print_bytes(void* data, size_t length) {
    char delim = ' ';
    unsigned char* ptr = data;
    for (size_t i = 0; i < length; i++) {
        printf("%c 0x%x", delim, *ptr);
        delim = ',';
        ptr++;
    }
    printf("\n");
}

int main(int argc, char** argv) {
    int a = 9;
    double b = 18.9;

    print_bytes(&a, sizeof(int));
    print_bytes(&b, sizeof(double));

    return 0;
}

编译:gcc ExtremeC_examples_chapter1_14.c

运行:./a.out

  0x9, 0x0, 0x0, 0x0
  0x66, 0x66, 0x66, 0x66, 0x66, 0xe6, 0x32, 0x40

在上述代码中, print_bytes 函数接收一个地址值(void* 类型的指针)和一个指出数据类型长度的整数作为其实参。使用这些实参,函数打印从给定地址开始到给定长度为止的所有字节。该函数接受一个通用指针,这允许用户传递想用的任何类型的指针。

注意:

对于 void pointer(空指针、通用指针)的赋值不需要显式强制类型转换。这是为什么在传递 a 和 b 的地址的时候没有做显式的强制类型转换的原因。

print_bytes 函数中,为了在内存中移动,必须使用 unsigned char 类型指针,否则,不能直接对空指针参数 data 做任何算术运算。char *unsigned char* 的步长是一个字节,因此,这是对于逐字节遍历一段内存和逐个处理每个字节来说最好的指针类型。

指针的大小

指针的大小取决于体系结构,而不是特定的 C 概念。可以使用 sizeof 获取指针的大小。根据经验,指针大小在 32 位体系结构中为 4 字节,在 64 位体系结构中为 8 字节,但在其他体系结构中可能会是其他值。

悬空指针

误用指针会导致许多问题。悬空指针就是其中非常著名的一个。指针通常指向已经分配给某个变量的一个地址,对某地址中的数据进行读取或修改。而如果该地址中并没有存储变量,这是严重错误行为,可能导致崩溃或段错误(segmentation fault)的情况。段错误是一个可怕的错误,每个 C/C++ 开发人员在编写代码时都应该至少见过一次。这种情况通常发生在误用指针时:在你访问内存中不允许访问的位置时;或者曾在内存某处存储了一个变量,但是该内存现在被释放了。

ExtremeC_examples_chapter1_15.c

#include <stdio.h>

int* creare_an_integer(int default_value) {
    int var = default_value;
    return &var;
}

int main() {
    int* ptr = NULL;
    ptr = creare_an_integer(10);
    printf("%d\n", *ptr);

    return 0;
}

编译:gcc ExtremeC_examples_chapter1_15.c

ExtremeC_examples_chapter1_15.c: In function ‘creare_an_integer’:
ExtremeC_examples_chapter1_15.c:5:12: warning: function returns address of local variable [-Wreturn-local-addr]
    5 |     return &var;
      |            ^~~~

在上述示例中,ptr 指针是悬空的,它指向的是原本变量 var 的内存位置,但该内存已经被释放。变量 var 是函数 create_an_integer 的局部变量,离开函数后它将被释放,但是可以从函数中返回它的地址。因此,在 main 函数中,当把返回的地址复制给 ptr 时, ptr 指向内存中无
效的地址,成为悬空指针。现在,对指针的解引用会导致严重的问题,程序崩溃。

使用堆内存来分配变量,并在函数之间传递地址:

ExtremeC_examples_chapter1_16.c

#include <stdio.h>
#include <stdlib.h>

int* create_an_integer(int default_value) {
    int* var_ptr = (int* )malloc(sizeof(int));
    *var_ptr = default_value;
    return var_ptr;
}

int main() {
    int* ptr = NULL;
    ptr = create_an_integer(10);
    printf("%d\n", *ptr);
    free(ptr);

    return 0;
}

在上述示例中, create_an_integer 函数中创建的指针变量不再是局部变量。相反,它是从堆内分配的一个变量,并且它的生命周期不受声明它的函数的限制。因此,可以在主调(外部)函数中访问它。指向该变量的指针不再悬空,只要变量存在且未被释放,它们就可以被解引用。最后,通过调用 free 函数释放该变量,结束生命周期。

3. 函数

函数的剖析

函数是一个逻辑的盒子,它包含名称、输入参数列表和输出结果列表。函数由函数的调用引发,函数调用只是使用函数的名称来执行其逻辑。正确的函数调用应该传递所有必需的参数给函数并等待其执行。注意,在 C 语言中函数总是 阻塞式(blocking) 的,这意味着主调函数必须等待被调函数完成,然后才能收集返回的结果。

与阻塞函数相反还有 非阻塞函数(non-blocking) 。当调用非阻塞函数时,调用者不等待函数完成,就可以继续执行。在这种方案中,通常有一个回调(callback)机制,在被调用函数完成时触发。非阻塞函数也可以称为 异步函数(asynchronous function 或 async function) 。因为在 C 语言中没有异步函数,所以需要使用多线程解决方案来实现它们。

栈管理

栈段:栈段是用于为所有的局部变量、数组和结构分配内存的默认的内存位置。因此,当在函数中声明一个局部变量时,实际是在栈段中为其分配内存的。这种分配总是发生在栈段的顶部。

函数体中声明的所有局部变量都放在栈段的顶部。因此,当离开函数时,所有的栈变量都被释放。这就是为什么我们称它们为局部变量(local variables),因此这也可以解释为什么一个函数不能访问另一个函数中的变量。这种机制也解释了为什么在进入函数之前和离开函数之后局部变量是未定义的。

栈是内存中的一段有限的空间,你可能会填满它,并发生潜在的 栈溢出(stack overflow) 错误。这种错误通常在有太多的函数调用自身时没有任何中断条件或者限制时,这种溢出情况经常发生。

值传递和引用传递

  • 值传递函数调用的例子

ExtremeC_examples_chapter1_17.c

#include <stdio.h>

void func(int a) {
    a = 5;
}

int main(int argc, char** argv) {
    int x = 3;
    printf("Before function call: %d\n", x);
    func(x);
    printf("After function call: %d\n", x);
    return 0;
}

编译:gcc ExtremeC_examples_chapter1_17.c

运行:./a.out

Before function call: 3
After function call: 3
  • 指针传递函数调用的例子,它不同于引用传递

ExtremeC_examples_chapter1_18.c

#include <stdio.h>

void func(int *a) {
    int b = 9;
    *a = 5;
    a = &b;
}

int main(int argc, char** argv) {
    int x = 3;
    int* xptr = &x;
    printf("Value before call: %d\n", x);
    printf("Pointer before function call: %p\n", (void*)xptr);
    func(xptr);
    printf("Value after call: %d\n", x);
    printf("Pointer after function call: %p\n", (void*)xptr);
    return 0;
}

编译:gcc ExtremeC_examples_chapter1_18.c

运行:./a.out

Value before call: 3
Pointer before function call: 0x7fff6a5e962c
Value after call: 5
Pointer after function call: 0x7fff6a5e962c

如上输出所示,在函数调用之后,指针的值没有改变。这意味着指针参数是按值传递的。在 func 函数内部对指针进行解引用可以访问指针所指向的变量。但是,改变函数内部的指针形参的值并不会改变主调函数中对应的实参。C 语言中,在函数调用期间,所有实参都是通过值传递的,对指针进行解引用允许修改主调函数中的变量。

函数指针

函数指针有许多应用,但最重要的应用之一是将一个大的二进制程序拆分成为更小的二进制程序,并再次将它们加载到小的可执行文件中。由此产生了 模块化(modularization) 和软件设计的思想。函数指针是 C++ 中实现多态的构建块,它允许我们扩展现有的逻辑。

  • 使用一个函数指针来调用不同的函数

ExtremeC_examples_chapter1_19.c

#include <stdio.h>

int sum(int a, int b) {
    return a + b;
}

int subtract(int a, int b) {
    return a - b;
}

int main() {
    int (*func_ptr)(int, int);
    func_ptr = NULL;
    func_ptr = &sum;
    int result = func_ptr(5, 4);
    printf("Subtract: %d\n", result);

    func_ptr = &subtract;
    result = func_ptr(5, 4);
    printf("Subtract: %d\n", result);

    return 0;
}

编译:gcc ExtremeC_examples_chapter1_19.c

运行:./a.out

Subtract: 9
Subtract: 1

如上所示,我们可以使用一个函数指针为相同的参数列表调用不同的函数,这是一个重要的特性。如果你熟悉面向对象编程,那么首先想到的就是 多态(polymorphism)虚函数(virtual functions) 。事实上,这是在 C 中支持多态和模仿 C++ 虚函数的唯一方法。

  • 使用一个函数指针来调用不同的函数

ExtremeC_examples_chapter1_20.c

#include <stdio.h>

typedef int bool_t;
typedef bool_t (* less_than_func_t)(int, int);

bool_t less_than(int a, int b) {
    return a < b ? 1 : 0;
}

bool_t less_than_modular(int a, int b) {
    return (a % 5) < (b % 5) ? 1 : 0;
}

int main(int argc, char** argv) {
    less_than_func_t func_ptr = NULL;

    func_ptr = &less_than;
    bool_t result = func_ptr(3, 7);
    printf("%d\n", result);

    func_ptr = &less_than_modular;
    result = func_ptr(3, 7);
    printf("%d\n", result);

    return 0;
}

编译:gcc ExtremeC_examples_chapter1_20.c

运行:./a.out

1
0

4. 结构

结构的工作是封装。封装是软件设计中最基本的概念之一,它将相关的成员组合和封装在新类型下。然后我们可以使用这个新的类型来定义所需的变量。

内存布局

  • 打印为结构变量分配的字节数

ExtremeC_examples_chapter1_21.c

#include <stdio.h>

struct sample_t
{
    char first;
    char second;
    char third;
    short fourth;
};

void print_size(struct sample_t* var) {
    printf("Size: %lu bytes\n", sizeof(*var));
}

void print_bytes(struct sample_t* var) {
    unsigned char* ptr = (unsigned char*)var;
    for (int i = 0; i < sizeof(*var); i++, ptr++) {
        printf("%d ", (unsigned int)*ptr);
    }
    printf("\n");
}

int main(int argc, char** argv) {
    struct sample_t var;
    var.first = 'A';
    var.second = 'B';
    var.third = 'C';
    var.fourth = 765;
    print_size(&var);
    print_bytes(&var);

    return 0;
}

编译:gcc ExtremeC_examples_chapter1_21.c

运行:./a.out

Size: 6 bytes
65 66 67 0 253 2 

对于上例中的结构,它有四个成员,三个 char 类型和一个 short 类型。如果假设 sizeof(char) 是 1 字节,sizeof(short) 是 2 字节,通过简单的计算,每个 sample_t 类型的变量在内存中应该占据 5 个字节的空间。但是查看输出,sizeof(sample_t) 是 6 个字节。这是为什么?

为了更清楚的说明这一点并解释为什么结构变量的大小不是 5 个字节,我们需要引入 “内存对齐(memory alignment)” 的概念。CPU 总是做所有的计算工作,除此之外,它还需要在计算前从内存中加载值,并在计算后将结果再次存储在内存中。CPU 内部的计算速度非常快,但是相比之下访问内存的速度非常慢。了解 CPU如何与内存交互是很重要的,因为我们可以使用这些知识来改进程序或调试问题。

CPU 通常在每次访问内存时读取特定数量的字节。这个字节数通常称为一个字(word)。因此,内存被分割成若干字,而字是 CPU 用来读写内存的原子单位。字中的实际字节数依赖于体系结构。列如,在大多数 64 位的机器中,字的大小是 32 位或 4 字节。关于 “内存对齐”,如果一个变量的起始字节位于一个字的开头,那么该变量在内存中是对齐的。通过这种方式,CPU 可以在加载值时优化其内存访问次数。

对于上述结构的 3 个成员, firstsecondthird 每个都是一个字节,它们驻留在结构布局的第一个字中,并且它们都可以被一次内存访问读取。关于第四个成员, fourth 占 2 个字节。如果我们忘记了内存对齐,fourth 的第一个字节将是第一个字的最后一个字节,这使得它没有对齐。

如果是这种情况,CPU 要访问内存两次,同时移动一些比特来获取成员的值。这就是为什么在字节 67 后面会多一个 0 。添加零字节是为了填充完当前字,并让第四个成员从下一个字节开始。这里,我们说第一个字被一个零字节填充。编译器使用填充(padding)技术来对齐内存中的值。填充是为对齐内存而添加的额外字节。

也可以关闭内存对齐。在 C 中,有更具体的术语来表示内存对齐结构,即:结构没有被 “打包”。“打包结构”(Packed structures)无法对齐,使用它们可能会导致二进制不兼容和性能下降。

  • 声明一个被打包的结构

ExtremeC_examples_chapter1_22.c

#include <stdio.h>

struct __attribute__((__packed__)) sample_t
{
    char first;
    char second;
    char third;
    short fourth;
};

void print_size(struct sample_t* var) {
    printf("Size: %lu bytes\n", sizeof(*var));
}

void print_bytes(struct sample_t* var) {
    unsigned char* ptr = (unsigned char*)var;
    for (int i = 0; i < sizeof(*var); i++, ptr++) {
        printf("%d ", (unsigned int)*ptr);
    }
    printf("\n");
}

int main(int argc, char** argv) {
    struct sample_t var;
    var.first = 'A';
    var.second = 'B';
    var.third = 'C';
    var.fourth = 765;
    print_size(&var);
    print_bytes(&var);

    return 0;
}

编译:gcc ExtremeC_examples_chapter1_22.c

运行:./a.out

Size: 5 bytes
65 66 67 253 2

结构体指针

结构变量指针指向结构变量的第一个成员的地址

ExtremeC_examples_chapter1_24.c

#include <stdio.h>

typedef struct {
    int x;
    int y;
} point_t;

typedef struct {
    point_t center;
    int radius;
} circle_t;

int main(int argc, char** argv) {
    circle_t c;

    circle_t* p1 = &c;
    point_t* p2 = (point_t*)&c;
    int* p3 = (int*)&c;

    printf("p1: %p\n", (void* )p1);
    printf("p2: %p\n", (void* )p2);
    printf("p3: %p\n", (void* )p3);
}

编译:gcc ExtremeC_examples_chapter1_24.c

运行:./a.out

p1: 0x7ffe8624679c
p2: 0x7ffe8624679c
p3: 0x7ffe8624679c

如上输出可以看到,所有的指针都指向同一个字节,但是它们的类型不同。这通常用于通过添加更多成员扩展来自他库的结构。这也是在 C 语言中实现继承(inheritance)的方式。

  • 49
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值