C/C++常见修饰符(inline&static&const&extern&volatile)

 本文是关于C/C++语言中常见的五个修饰符:staticconstexterninlinevolatile的含义和用法,在阅读本文前,建议先通过《声明/定义/初始化/赋值》了解有关C/C++中定义、声明等概念以及变量声明和定义的区别。

static

  • 关键字static有着不同不同寻常的历史。起初,在C中引入关键字static是为了表示退出一个块后仍然存在的局部变量;随后,static在C中有了第二种含义:用来表示不能被其它文档访问的全局变量和函数。为了避免引入新的关键字,所以仍使用static关键字来表示这第二种含义。
  • static的三个作用
    • ①控制存储方式(生命周期):函数内部的static变量,即静态局部变量,因为是局部变量,已经是内部连接了。
      • 控制存储方式 $Longrightarrow$ 静态存储区:持久性+默认值为0。
        • ①存储在静态数据区的变量会在进程刚开始运行时就完成初始化,也是唯一的一次初始化。
        • ②在静态数据区,内存中所有的字节默认值都是0x00(对于整型为0;对于字符数组为''),某些时候这一特点可以减少进程员的工作量。
      • static修饰局部变量
        • 一般情况下,局部变量是放在栈区的,并且局部变量的生命周期在该语句块执行结束时便结束了;如果用static进行修饰的话,该变量便存放在静态数据区,其生命周期一直持续到整个进程执行结束 $Longrightarrow$ 生命周期及其存储空间发生了变化,但是其作用域并没有改变,其仍然是一个局部变量,作用域仅限于该语句块。
        • 在用static修饰局部变量后,该变量只在初次运行时进行初始化工作,且只进行一次。
    • ②控制可见性与连接类型(作用域):static全局变量,因为是全局变量,已经是静态存储了。
      • 控制可见性 $Longrightarrow$ 隐藏
        • 当我们同时编译多个文档时,所有未加static前缀的全局变量和函数都具有全局可见性(源进程中的其它文档也能访问)。
        • static修饰函数和修饰全局变量
          • 函数/全局变量只能用在它所在的编译单元
          • 编译单元:当一个.c.cpp文档在编译时,预处理器首先递归包含头文档,形成一个含有所有必要信息的单个源文档,这个源文档就是一个编译单元。这个编译单元会被编译成为一个同名的目标文档(.o.obj),链接进程把不同编译单元中产生的符号联系起来,构成一个可执行进程。
      • 利用这一特性可以在不同的文档中定义同名函数和同名变量,而不必担心命名冲突;对于函数来讲,static的作用仅限于隐藏。
      • 静态全局变量,作用域仅限于变量被定义的文档中,其它文档中即使用extern(下文会介绍)声明也无法使用它;准确地说,作用域是从声明之处开始,到文档结尾处结束:在定义之处前面的同一文档的那些代码行也不能使用它,想要使用就得在前面再加extern
    • ③C++类中的static成员
      • 设计思路:将和某些类紧密相关的全局变量或函数写在类里面,使其看上去像一个整体,易于理解和维护。
      • 访问方式:可以想访问普通成员函数和变量一样通过对象访问,但常直接用类名::???的方式访问。
      • 静态成员变量:必须在类声明体外的某个地方(一般是实现文档.cpp)初始化。静态成员变量本质上是全局变量,在类的所有实例对象中共享一份。
      • 静态成员函数:本质上是全局函数,并不具体作用于某个对象,不需要对象也可以访问。静态成员函数中不能访问非静态成员变量,也不能调用非静态成员函数。
  • static全局变量 vs 普通全局变量
    • 全局变量本身就是静态存储方式,两者在存储方式上并无不同
    • 普通全局变量的作用域是整个源进程,当一个源进程由多个源文档组成时,普通(非静态的)全局变量在各个源文档中都是有效的;静态全局变量限制了其作用域,只在定义该变量的源文档内有效,在同一源进程的其它源文档中无效;由于静态全局变量的作用域局限于一个源文档内,只能为该源文档内的函数公用,因此可以避免在其它源文档中引起的错误。 如果在不同源文档中出现了用static修饰的同名全局变量,那么这些变量互不干扰。
    • 把全局变量改变成静态变量后改变了变量的作用域,限制了变量的使用范围。
  • static局部变量 vs 普通局部变量
    • 把局部变量改变为静态变量后改变了变量的存储方式,即改变了变量的生存期。
    • static局部变量只被初始化一次,下一次访问依据上一次结果值。
  • static函数 vs 普通函数
    • 只在当前源文档中使用的函数应该声明为内部函数(static函数),内部函数应该在当前源文档中声明和实现;对于可在当前源文档以外使用的函数,应该在一个头文档中声明,要使用这些函数的源文档(包括函数实现)要包含这个头文档

const

  • const修饰的东西(变量/函数)都受到强制保护,进程中使用const可以预防意外的变动,在一定程度上提高进程的健壮性,但是进程中使用过多的const,可能加大代码的阅读难度。
  • const修饰普通变量
    • C的#define预处理命令,只是简单的值替代,缺乏类型的检测机制;C++引入const关键字:
  • 1
    const datatype name=value;
    • 不仅满足了使用预处理命令的要求:①不可变性;②避免意义模糊的数字出现,方便参数调整和修改,同时:③编译器不为普通const常量分配存储空间,而是将它们保存在符号表中 $Longrightarrow$ 编译期间的常量,没有了内存存储等操作,效率更高。
    • const修饰的变量(用来修饰函数的形参除外)必须在声明时进行初始化;一旦一个变量被const修饰,在进程中除初始化外对这个变量进行的赋值都是错误的。
  • const修饰指针:指针常量 vs 常量指针
    • 指针本身也是一个变量,只不过这个变量存放的是地址而已。
    • 指针常量:是一个常量,这个常量本身是一个指针,即指针本身的值不可变,指针只能指向固定的存储单元 $Longrightarrow$ 指针指向的变量的值(这个固定存储单元保存的值)是可以改变的。
    • 常量指针:是一个指针,这个指针指向的变量是一个常量,该变量的值不可变 $Longrightarrow$ 指针本身的值是可以改变的,即指针可以指向其它存储单元。
  • 1
    2
    3
    4
    5

    int* const a;

    int const *a;
    const int* a;
    • const是一个左结合的类型修饰符,它与其左侧的类型合为一个类型修饰符。
  • const修饰函数的参数
  • 1
    2
    3
    4
    5
    6
    // 常量指针:以防意外改动指针指向数据内容
    void (char* strDest, const char* strSrc);
    // 指针常量:以防意外改动指针本身
    void swap(int* const p1, int* const p2);
    // 非内部数据类型的引用传递
    void Func(const MyClass& a);
    • “值传递”函数将自动产生临时变量用于复制该参数,该输入参数无需保护;临时对象的构造、复制、析构都将消耗时间;内部数据类型不存在构造析构的过程,复制也非常快。
    • “引用传递”仅借用一下参数的别名而已,不需要产生临时对象;“引用传递”有可能会改变参数,可以通过const限定。
  • const修饰函数的返回值
1
2
3
4
const char* getString(void);
// 函数返回值采用值传递,加 const 没有任何意义
const int getWidth(void);
const Myclass& getObj(void);
  • const限定类的成员函数
  • 1
    2
    3
    class MyClass {
    int getXXX() const;
    };
    • const只能放在函数声明的尾部,大概是因为其它地方被占用了。
    • 只读函数:函数不能改变类对象的状态(只能由常量对象(实例)调用);不能修改类的数据成员,不能在函数中调用其它非const函数。
  • C和C++中的const有很大区别:在C语言中用const修饰的变量仍然是一个变量;而在C++中用const修饰后,就变成常量了。
  • 1
    2
    const int n=5;
    int a[n];
    • 这种方式在C语言中会报错,原因是声明数组时其长度必须为一个常量;但是在C++中就不会报错。

extern

  • 在C语言中,修饰符extern用在变量或者函数的声明前,用来说明“此变量/函数是在别处定义的,要在此处引用
    • extern置于变量或者函数前,标识变量或者函数的定义在别的文档中,提示编译器遇到此变量或函数时在其他模块(其他.o文档)中寻找其定义。
    • 关于extern的作用域
      • 对外部变量的引用不只是取决于extern声明,还取决于外部变量本身是否能够被引用到,即变量的作用域:要求被引用的变量的链接属性必须是外链接的,通常是全局变量。
        • extern声明的位置对其作用域也有关系,例如在某个函数中的声明就只能在该函数中调用,在其它函数中不能调用。
    • 为啥要用extern?因为用extern会加速进程的编译过程,这样能节省时间。
      • 对其它模块中函数的引用,最常用的方法是#include这些函数声明的头文档,extern的引用方式要直截了当、简洁的多:想引用哪个函数就用extern声明哪个函数,这会加速进程编译,确切的说预处理过程,节省时间;在大型C进程编译过程中,这种加速会更加明显。
    • 正确使用extern共享全局函数/全局变量
      • 供其他文档调用的外部函数和变量在.h文档中通过extern修饰进行声明,在.c/.cpp文档的变量定义与函数实现与普通变量、普通函数一致;要调用该文档中的函数和变量,只需要把.h文档用#include包含进来即可。
  • file1.c
    1
    2
    3
    4
    5
    6
    // 声明全局变量
    int i, j;
    char c;
    void func() {
    //...
    }
  • file2.c
    1
    2
    3
    4
    5
    6
    // 外部变量声明
    extern int i, j;
    extern char c;
    void func1() {
    //...
    }
    • 对外部变量的声明和定义不是一回事。对外部变量的声明,只是声明该变量是在外部定义过的一个全局变量,在这里引用;而外部变量的定义,即对该全局变量的定义,则要分配存储空间;一个全局变量只能定义一次,却可以有多次外部引用。
  • 在C++中extern还有另外一个作用:用于指示C或者C++函数的调用规范。
    • C++和C进程编译完成后在目标代码中命名规则不同,用此来解决名字匹配的问题。
      • C++语言在编译的时候为了解决函数的多态问题,会将函数名和参数联合起来生成一个中间的函数名称(重命名),而C语言则不会,因此会造成链接时找不到对应函数的情况。
    • 在C++中调用C库函数,需要在C++进程中extern "C"{...}声明要引用的函数,这是给链接器用的,告诉链接器在链接的时候用C函数规范来链接。
  • main.cpp
    1
    2
    3
    4
    5
    6
    7
    8
    9
    extern "C" {
    int func1();
    // 或者

    }

    int main(){
    func1();
    }
    • 在C++中导出C函数,用extern "C"{...}进行链接指定,告诉编译器,请保持我的函数名,不要进行任何重命名。
  • xxx.cpp
    1
    2
    3
    4
    extern "C" {
    int func1();
    // ....
    }

inline

  • 1
    #define expression(x,y) (x+y)*(x-y)
    • A. 形式和使用上像一个函数,使用预处理器实现,没有参数压栈等一系列操作 $Longrightarrow$ 效率很高
    • B. 使用(说调用不太准确)它时,只是做预处理器符号表中的简单替换 $Longrightarrow$ 无严格类型检查,返回值也不能被强制转换为可转换的合适的类型。
    • C. C++类及类的访问控制的存在,这种方式无法访问类的保护成员或私有成员。
  • inline修饰全局函数,保留了上述方式的优点,又能有效避免相应的不足。
    • inline内联函数代码被放入符号表中,调用时直接进行替换(像宏一样展开),没有了调用的开销,效率也很高。
      • 栈空间是指放置进程局部数据也就是函数内数据的内存空间,系统下的栈空间是有限的,假如频繁大量地使用(递归死循环调用)就会造成因栈空间不足所造成的进程出错。
      • 函数被调用,函数入栈,即函数栈,会消耗栈空间(栈内存)。
    • 编译器像对待普通函数一样 $Longrightarrow$ 参数类型检测…
    • inline成员函数 $Longrightarrow$ 访问保护成员或私有成员
  • inline内联函数函数体应简单
    • inline函数足够简单:不能包含复杂的结构控制语句(while/switch),不能出现递归。
    • 原因:内联函数会在任何调用它的地方展开,如果太复杂,代码膨胀(进程总代码量增大,消耗更多的内存空间)$Longrightarrow$ 效率反而得不偿失。
    • 内联函数常用在类的set/get函数。
  • inline函数声明和定义(实现)放在头文档中最合适
    • 省却每个文档实现一次的麻烦
    • 避免实现存在不一致的问题

volatile

  • 1
    2
    3
    4
    5
    volatile int i=10;
    int a=i;
    // 其他代码,并未明确告诉编译器,对 i 进行过操作
    ...
    int b=i;
    • volatile指出变量是随时可能发生变化的,每次使用该变量的时候都必须从其地址中读取,因此编译器生成的int b=i;的汇编代码会重新从变量 i 的地址读取数据放在变量 b 中;优化的做法(没有volatile)是:编译器发现两次从变量 i 读数据的代码间没有对 i 进行过操作,会自动把上次读的 i 的数据(一般的编译器可能会将其拷贝放在寄存器中以加快命令的执行速度)放在变量 b 中 $Longrightarrow$ volatile可以保证对特殊地址的稳定访问。
  • volatile与编译器优化
    • 提高执行速度的两个方面
      • 硬件级别的优化:由于内存访问速度远不及CPU处理速度,为提高机器整体性能,在硬件上引入高速缓存cache,加速对内存的访问;另外在现代CPU中命令的执行并不一定按照顺序执行(in-order),没有相关性的命令可以乱序执行(out-of-order),以充分利用CPU的命令流水线,提高执行速度
      • 软件级别的优化:一种是在编写代码时由进程员优化;另一种则是由编译器进行优化。编译器优化常用的方法有:
        • ①将内存变量缓存到寄存器;
        • ②调整命令顺序以充分利用CPU命令流水线,常见的是重新排序读写命令(可能是load/store命令)。
    • volatile总是与优化有关,编译器有一项技术叫做数据流分析,分析进程中的变量在哪里赋值?在哪里使用?在哪里失效,分析结果可以用于常量合并、常量传播等优化,进一步可以死代码消除。编译器对常规内存进行优化的时候,这些优化是透明的,而且效率很好;但有时候这些优化不是进程所需要的,这时可以用volatile禁止这些优化:
      • 不要在两个操作之间把volatile变量缓存在寄存器中:在多任务、中断等环境下,变量可能被其他进程改变。
      • 不做常量合并、常量传播等优化。
      • volatile变量的读写不会被优化掉:如果你对一个变量赋值但后面没用到,编译器常常可以省略那个赋值操作,然而万一这个赋值是对 Memory Mapped的 IO 资源(比如LEDs)进行操作呢!
  • 一般说来,volatile用在如下几个地方:
    • 中断服务进程中修改的供其它进程检测的变量需要加volatile
    • 多任务环境下各任务间共享的标志应该加volatile
    • 存储器映射(Memory Mapped)的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能有不同的意义。
  • 频繁地使用volatile很可能会增加代码尺寸和降低性能!
  • 一个参数可能既是const又是volatile,比如只读的状态寄存器。

References