返回介绍

未曾领略的新风景

发布于 2024-10-04 12:28:35 字数 36542 浏览 0 评论 0 收藏 0

0x09-未曾领略的新风景

  • 前方曾提到两个关键字 restrictinline 在C语言中的使用,但是后者可能还能带来些许理解上的便利,开启 -O3 优化是一个很不错的选择。
  • inline 的作用还是在于和 static 一起使用,让小函数尽可能的减小开销甚至消除函数开销。
  • restrict 最重要的还是在于编译器的优化上。编译器能够为我们的程序提供优化,这是众所周知的,但是编译器是如何优化的,知道的人少之又少,其中有一些优化是建立在编译器能够理解你的代码,或者说编译器要认为你的代码是可以被优化的情况下,才会采取优化措施:

    • 有一个很重要的地方,称为指针别名,是阻碍编译器优化代码的最重要的地方
    • 什么是指针别名

        void tmp_plus(int * a, int * b)
        {
            for(int i = 0; i < b_len;++i)
                *a += b[i];
        }
      

      这段代码中,a, b 是两个被传入的指针,编译器对他们毫无所知,也不知道a是否在b的范围之内,故无法对其做出最大程度上的优化,这会导致什么结果呢?也就是,每依次循环过后,*a的结果都会写回到主存当中去,而不是在寄存器里迅速进行下一次增加!

      或者有的聪明的编译器可以将其扩展成if ... else的加长版形式来避免写回操作。

      但是如果我们增加了restrict

        void tmp_plus(int * restrict a, int * restrict b) ...
      

      这就是告诉编译器,这两个指针是完全不相干的,你可以放心的优化,不会出错。

  • 但是在这里有一些小的问题,那就是C++并不支持这个关键字,这会导致什么后果?

    • 你在Visual Studio下编程的时候会发现使用restrict关键字是会产生编译错误的,无论你使用 .c 还是 .cpp,难道说不支持吗?实际上不是,主流的编译器都对这个关键字有自己的实现
    • Visual Studio(Visual C++) : __restrict
    • GCC, Clang : __restrict__
  • 剩下一个是前面也大概说过的 volatile,当时对其的解释就是让编译器不对其进行优化的意思,这里再说清楚一点

    • 假设 volatile int i = 0;
    • 首先它的现象本质就是,确保每次读取 i 的时候,是从它的内存位置读取,每次对它操作完毕后,将结果写回它的内存位置,而不是将其优化保存在寄存器内。
    • 这就让一些编译器的优化无法进行,就像上方所说的。
    • 一般将其用在调试时期,防止编译器的优化对自己的代码逻辑造成混淆
    • 但是,正如上面所说,这个关键字的作用是每次都进行存取,开销自然就变大了,意味着无法使用缓存来对其进行加速,换句话来说就是,只要是关于它的操作,开销都将变大。
    • 并且,其所能起到的作用大部分体现在 多线程编程中,而且也无法阻止指令重排之类的优化。
      • 对此,有一个需要提及的内容是,可以适当的使用 内存屏障 来替代这种volatile的功能,内存屏障是由操作系统提供的功能,目的是防止由于某些优化,导致的指令重排的效果。
      • 某些编译器也有提供类似的功能,例如 GCC就可以通过内嵌汇编代码的方式实现这个效果
    • 以上的略微提及,详细可以自行查阅资料。
再议数组
  • 在常见C中,数组是这样的。

      int arr_1[3];
      int arr_2[] = {1, 2, 3}; /* 创建三个元素的数组 */
    
  • C99之后,可以使用一种叫做 复合文字(Compound Literal)的机制来做到更多的事情,最简单的就是创建匿名数组(看着有点像C++11引进的 Lambda匿名函数):

      int *ptoarr = (int[]){1, 2, 4}; /* 之后可以使用 ptoarr 操作 */
      ptoarr[2] = 0;
      printf("The Third number is : %d", ptoarr[2]);
    

    输出: $ The Third number is : 0

    当然,这种机制并不是只能如此使用,稍微高级一点的应用是,可以传递数组了,无论是按参数传递还是返回值。

      int *test_fun(int most[], int length){
          for(int i = 0;i < length;++i)
           most[i] = i;
      return (int []){most[0], most[1], most[2], most[3]...};/* so on */
      }
      // main
      test_fun((int []){6,6,6,6,6}, 5);
    

    这也是自从更新了C99标准以后,可以讲某个整体进行返回的例子,也包括结构体:

      typedef struct compond{
              int value;
              int number;
              int arrays[10];
      }compond;
      //假设有test_fun函数返回该结构体
      ...
      return (combond){
                      1, // 给value
                      2, // 给number
                      {most[0], most[1], most[2], most[3]...}}; //给arrats
    

    当然也可以构造完成之后再返回实体,不过这么做不如上面写的效果好,原因前方已经提过。

    稍微修改一下结构体,又是另一番情况:

      typedef struct compond{
              int value;
              int number;
              int arrays[]; /* 这里不再显式声明大小,也就无法构造实体 */
      }compond;
    

    这个方式很像前方提到的 前桥和弥越界结构体 的例子,只不过这个是一个在C标准允许的情况下,而前桥和弥则是利用一些C语言标准的漏洞达到目的。

    在使用这种结构体的时候,首先要为其动态分配好空间,之后通过指针进行操作,也增建了内存泄漏的风险,所以仁者见仁智者见智了:

      compond* ptocom = malloc(sizeof(compond) + num_you_want * sizeof(int));
      /* 这样就成功分配了足够的空间 */
      ptocom->arrays[0] = some_number;
      ...
      free(ptocom);
      ptocom = NULL;
    

    这其实并不是这种机制的目的,我觉得这种复合文字机制的最大用处还是在于消除艰涩难懂的函数调用

    例如有一个函数的参数列表及其之长,我们就应该考虑使用新机制结合结构体,来对这个函数重新修饰一番:

      int bad_function(double price, double count, int number,
                       int sales, Date sale_day, Date in_day,
                       String name, String ISBN, String market_name,
                      ); /* 实现省略 */
    

    这种函数,在陌生的他人拿到之后,一定头疼不已,可以对它进行一些处理,来减轻使用时候的苦恼:

      /* 首先使用宏进行包裹 */
      #define good_function(...) {\
      /* 使用这个宏作为接口,可传入不限个数的参数 */
    

    接下来定义一个结构体,用于参数的接收。

      /* 接收参数的结构体 */ 
      typedef struct param{
      double price;            /* 销售价格 */
      double count;            /* 折扣 */
      int    number;            /*总数量*/
      int    sales;             /*销售数量*/
      Date   sale_day;        /* 销售日期 */
      Date   in_day;            /* 进货日期 */
      String name;          /* 货物名称 */
      String ISBN;          /* ISBN号 */
      String market_name;   /* 销售市场 */
      }param;
      /* 并配上文档说明每个参数的作用 */
    

    其次继续完成宏

      /* 此时将函数的声明改为: */
      int bad_function(param input);
      /* 宏 */
      #define good_function(...) {\
          bad_function((param){__VA_ARGS__});\
      }
    

    这就完成了包裹

    使用的时候:

      good_function(.price = 199.9, .count = 0.9, 
                    .number = 999, .sale = 20 /*and so on*/)
    

    也可以在宏利使用默认参数,以此来减少一些不必要的工作量,达到像其他高级语言一样的函数默认参数的功能。当然如果不添加默认的值,则会按照标准将其值初始化为 0 或者 NULL.

      #define good_function(...) {\
          bad_function((param{.price = 100.0, .count = 1.0, __VA_ARGS__})); \
          /* 假设想要设置默认价格为100, 默认折扣为 1.0 */\
      }
    

    较之C89(C90)的提取可变宏参数要来的更加灵活及"高效"

    至于 __VA_ARGS__ 宏的较为官方的用法,前人之述备矣,就不在这里记录了。

C11之 _Generic

只看名字就能明白这是C语言支持泛型的兆头。

好像很有意思

不过某些地方依旧有些限制,比如对于选择函数方面。

/* -std=c11 */
void print_int(int x) {printf("%d\n", x);}
void print_double(double x) {printf("%f\n", x);}
void print(){printf("Or else, Will get here\n");}
#define CHOOSE(x) _Generic((x),\
                   int : print_int,\
                   double : print_double,\
                   default : print)(x)

调用它

int main(void)
{
    CHOOSE(11.0);  /* 11.000000 */
    CHOOSE(11.0f); /* Or else, Will get here */
    return 0;
}

缺点就在于,: 后面无法真正的调用函数,而是只能写上函数名或者函数指针, 当然为了突破这一点可以使用宏嵌套来间接实现这一点,但是归根结底,无法在 : 后面调用函数。

#define CHOOSE(X) _Generic((x), \
                            int : prinf("It is Int")\
                            double : printf("It is double"))(x)
/* Compile Error! */

这样做会导致编译错误,编译器会告诉你 CHOOSE并不是一个函数或者函数指针,看起来错误很无厘头,实际上一想,你要是在 : 之后调用了函数,那么左后一个括号该如何自处,唯一的办法就是返回函数指针

typedef void (*void_p_double)(double);
typedef void (*void_p_int)(int);

void print_detail_double(double tmp){
    printf("The Double is %f\n", tmp);
}
void print_detail_int(int tmp){
    printf("The Int is %d\n", tmp);
}

void_p_int print_int(){
    printf("It is a Int! "); 
    return print_detail_int;
}
void_p_double print_double() {
    printf("It is a Double! "); 
    return print_detail_double;
}

void print_default(){printf("Nothing Matching !\n");}
#define CHOOSE(x) _Generic((x),\
                          int : print_int(x),\
                          double : print_double(x),\
                          default : print_default)(x)

调用:

CHOOSE(11);   /* It is a Int The Int is 11 */
CHOOSE(11.0);  /* It is a Double The Double is 11.000000 */
CHOOSE(11.Of); /* Nothing Matching ! */
choose(11l);  /* Nothing Matching ! */

对于宏而言,最新的编译器支持, #program once, 将这个放在头文件中,就代表该头文件只编译一次,也就是说,可以替代原有的老式 #ifdef的三段式保护,具体编译器支持请查询各编译器。

函数返回实体

  • 许多年前,在C编程的普遍常识是,返回指针,而不是一个实体。
  • 但是现在,在这个C99(C11)世纪,早已经打破这个局限,无论是从程序员编写的语法角度看,亦或者是从编译器的优化角度看,都不在需要特地的将一个实体表示为指针进行返回。

      combine* ret_struct(combine* other){
          /* 这里的参数也是指针,因为当时并不允许直接给结构体进行赋值 */
          int value = other->filed_value;
          /* SomeThing to do */
          combine* p_local_ret_com = malloc(sizeof(combine));
          /* 一系列安全检查 */
          return p_local_ret_com;
    

    这在当下自然也是可以的,而且会有不错的性能,但是。但是这也是C语言最令人诟病的地方,你却深深的踏了进去。

    尽量少用 malloc(calloc, realloc) 之类的内存操作函数,是现代C编程的一个指标,在这个函数中,我们没有办法保证分配出去的内存能够回收(因为就这个函数而言并没有回收这个内存),虽然现代计算机(非特殊机器)的内存已经不在乎那几十个甚至几百个中等结构体的内存泄漏,但是内存泄露依然是C语言最严重的问题,没有之一。

    我们该做的就是尽量减少风险的发生率:

      combine ret_struct(combine other){
          /* C99之后,我们就开始允许直接给结构体赋值,
                  意味着可以直接返回结构体了 */
          combine loc_ret_com; /* 如果没有复合的结构体成员的话,各成员会自动初始化为0,不必担心初始化问题 */·
          /* Do SomeThing to 'loc_ret_com' with 'other' */ 
          ...
          return loc_ret_com;
      }
      /* main */
      int main(void)
      {
          combine preview = {...};
          combine action = ret_struct(preview);
          return 0;
      }
    

    这么做的目的自然是为了让我们的风险降到最低,让系统栈帮我们管理内存,包括创建->使用->回收,这个过程(就像被其他语言所津津乐道的GC机制,实际上C语言程序员可以选择自己实现一个垃圾回收机制,在本系列的最后面可能会做一个简易的回收机制供大家参考,但是首先让我们看完风景,再用一个实际程序串联起来后,再去考虑GC)不需要你来操心。

    但是这真的是最好的形式了吗?

    让我们回想一下C语言调用函数的时候发生的某些事情,因为最开始的我们是从 main 函数的调用开始我们的程序.

    • 也就是说,系统在上位这个函数分配了空间
    • 紧接着我们调用了函数 ret_struct
    • 调用之后,为了保存现有状态,栈里会被压入许多信息,包括当下main的位置以及ret_struct的各种参数等等,其中有一个东西就是返回地址
      • 这个被压入的元素保证了在执行完ret_struct之后我们能够顺利的返回main调用它的位置继续执行
      • 这个和我们要讲的有什么关系呢?
    • 没关系我会乱说 = =
    • 一般来说,在函数返回一个值(把所有对象,值都称为值)时,由于这个值是在函数中创建的(无论是传入的参数,还是在函数里创建的非static对象,即便是static或者全局变量情况也是一样只是不符合这个假设结论罢了),所以在函数结束后,栈空间被回收,它就被默认的销毁了(可以参考前桥和弥的书里有这个的解释,实际上值并没有真正被销毁了,但是不允许再用,否则视为非法),但是我们是怎么接收到函数的返回值的?
    • 当然是因为程序帮你拷贝了一份这个值的副本的原因啊。
    • 而这个副本再使用过以后就会立即被销毁,那么我们如果像上方那么返回一个结构体的话会发生什么应该就很清晰了:复制副本->销毁本地的原身->将这个副本的值赋给外部接收的变量(没有则销毁)->销毁副本
    • 这有什么问题,难道还有更好的方法?

      那自然有啊

    • 现代科技飞速发展,编译器也不甘示弱,只要你外部有接收的地址,在(不开优化的情况下,开了优化也可能因为版本问题或者某些不可抗力而不优化)直接return对象的情况下,是可以省去副本的操作的

    • 也就是说:

        /*改写上方代码*/
        combine ret_struct(combine other){
            other->filed_value = ...;
            /* SomeThing to other */
            return (combine){ .filed_value = other->filed_value
                                ...};
        }
      

      如果这么写,编译器就知道,哟!你是想要把这个对象放到外边使用是吧,那我懂了,就直接找到外边接收这个值得变量地址,不再创建副本(其实还是创建,只不过不再销毁而已),而是在那个变量地址中写入这个对象。

    • 这就实现了让系统帮你管理内存的目的,而不是担心是否没有释放内存带来的风险,而且还优化了性能,何乐而不为。

    • 注:关于上方提到的 开了优化也可能因为版本问题或者某些不可抗力而不优化 这个说法是有道理的,因为大家的编译器版本都不一样,有的人用老版本那自然没有这个优化了,有的则是因为你编写的程序逻辑上的构造导致编译器无法为此处产生如此的优化,这个请参考前方提到的书本深入理解计算机系统的优化章节。让然编译原理要是能看自然更清楚喽(ps:我还没看)
    • 题外话:这个方法对于C++同样适用

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文