【八股文 03】extern、static、this、inline、volatile 关键字

发布时间 2023-08-07 17:40:25作者: 她爱喝水

0 概览

以下为概览,如果看到问题都能基本想到答案,则不需要看正文中详细的内容

  • extern
    • 作用
  • static
    • 作用
    • 修饰变量
      • 局部变量
      • 全局变量
      • 类成员变量
    • 修饰函数
      • 普通函数
      • 类成员函数
  • this 指针
    • this 指针的类型为?在常函数里 this 指针的类型为?
  • inline 内联函数的特点与使用
    • 问题:虚函数(virtual)可以是内联函数吗?
  • volatile 定义与作用
    • 问题:一个参数既可以是 const 还可以是 volatile 吗

1 extern

  • 定义:关键字 extern 可以应用于全局变量、函数或模板声明。 它指定符号具有 external(外部)链接,链接器会在当前项目的所有文件中去查找该实体的唯一一次定义。

  • 主要作用:

    • 使其他文件可以通过这个声明来使用全局变量或函数
    • 引用 C 语言写的函数或变量

注意:C++11 非 const 变量默认为 extern

1.1 修饰非 const 全局变量

链接器会在当前项目的所有文件中去查找该实体的唯一一次定义

//fileA.cpp
int i = 42; // 声明并定义

//fileB.cpp
extern int i;  // 只声明,可以在该文件中使用 fileA.cpp 定义的 i

//fileC.cpp
extern int i;  // 只声明,可以在该文件中使用 fileA.cpp 定义的 i

//fileD.cpp
int i = 43; // LNK2005! 'i' already has a definition.  报错:i 已经被定义
extern int i = 43; // LNK2005! 'i' already has a definition.  报错:i 已经被定义

1.2 修饰 const 全局变量

链接器会在当前项目的所有文件中去查找该实体的唯一一次定义

//fileA.cpp
extern const int i = 42; // extern const 声明并定义

//fileB.cpp
extern const int i;  // 只声明,可以在该文件中使用 fileA.cpp 定义的 i

1.3 extern "C"

如果在 C++ 程序中需要使用 C 语言写的函数或变量,需要使用 extern "C" 来声明它,这样可以确保 C++ 编译器不会对其名称进行修饰,从而能够正确地在 C++ 代码中调用 C 语言写的函数或变量。

// sum.cpp
extern "C"
{
    int Sum(int i, int j)
    {
        return i + j;
    }
}

注意:在extern "C" 里面需要严格遵守 C 规定,函数重载等是不被支持的。

2 static

作用:修改存储位置(生命周期)或作用域(可见性)

  • 修饰 局部变量,修改变量的存储位置和生命周期

    • 存储位置:使变量存储在静态区
    • 生命周期:在 main 函数运行前就分配了空间,持续到程序结束(程序的生命周期内)
    • 初始化:如果有初始值就用初始值初始化它,如果没有初始值系统用默认值初始化它
    • 作用域:仍限制在语句块内(注意:这点不变)
  • 修饰 全局变量,修改变量的作用域,使其从整个工程可见变成本文件可见

    • 存储位置:使变量存储在静态区
    • 生命周期:在 main 函数运行前就分配了空间,持续到程序结束(程序的生命周期内)
  • 修饰 成员变量,修饰成员变量使所有的对象只保存一个该变量,而且不需要生成对象就可以访问该成员

    • 存储位置:使变量存储在静态区
    • 生命周期:在 main 函数运行前就分配了空间,持续到程序结束(程序的生命周期内)
    • 作用域:整个类所拥有,对类的所有对象只有⼀份拷⻉
    • 注意点:
      1. 在类外初始化,因为 static 修饰的变量先于对象存在
      2. 如果不是常量,则需要在类外定义,在类内仅是声明(否则编译器会报未定义)
  • 修饰 普通函数,修改函数的作用域,仅在定义该函数的文件内才能使用。在多人开发项目时,为了防止与他人命名空间里的函数重名,可以将函数定位为 static

    • 存储位置:代码区
    • 生命周期:程序的生命周期内
    • 作用域:仅在定义该函数的文件内才能使用
  • 修饰 成员函数,修饰成员函数使得不需要生成对象就可以访问该函数,但是在 static 函数内不能访问非静态成员

    • 存储位置:代码区
    • 生命周期:程序的生命周期内
    • 作用域:整个类所拥有,对类的所有对象只有⼀份拷⻉
    • 注意点:
      1. 在 static 成员函数内不能访问非静态成员。因为 static 成员函数属于类不属于对象,所以 static 成员函数没有 this 指针,因此只能访问 static 成员变量
      2. static 成员函数不能被 virual 修饰,static 成员函数不属于任何对象或实例,所以加上 virtual 没有实际意义。而且 static 成员函数没有 有 this 指针,虚函数的实现是为每⼀个对象分配⼀个 vptr 指针,⽽ vptr 是通过 this 指针调⽤的,所以不能为 virtual。

2.1 对变量来说,static 和非 static 的对比

如下表所示:

变量类型 存储位置 生命周期 作用域 初始化位置
局部变量 栈区(stack) 在包含变量的语句块内 在包含变量的语句块内 在包含变量的语句块内或者不初始化都可以
静态局部变量 静态区(.bss 或 .data) 程序的生命周期内 在包含变量的语句块内(语句块结束虽然变量仍存在,但不能使用它) 在 main 函数运行前,有初始值就用初始值初始化它,如果没有初始值,系统用默认值初始化它
全局变量 静态区(.bss 或 .data) 程序的生命周期内 当一个源程序由多个源文件组成时,则在各个源文件都是有效的 在 main 函数运行前,有初始值就用初始值初始化它,如果没有初始值,系统用默认值初始化它
静态全局变量 静态区(.bss 或 .data) 程序的生命周期内 局限于本源文件内,只能为该源文件内的函数公用 在 main 函数运行前,有初始值就用初始值初始化它,如果没有初始值,系统用默认值初始化它
类成员变量 静态区(.bss 或 .data) 对象存在期间 对应的类的对象 类内
类静态成员变量 静态区(.bss 或 .data) 程序的生命周期内 类的所有对象 在类外初始化

2.2 对函数来说,static 和非 static 的对比

如下表所示:

函数类型 存储位置 生命周期 作用域
普通函数 代码区 程序的生命周期内 当一个源程序由多个源文件组成时,则在各个源文件都是有效的
静态普通函数 代码区 程序的生命周期内 仅在定义该函数的文件内才能使用
类成员函数 代码区 对象存在期间 对应的类的对象还存在时
类静态成员函数 代码区 程序的生命周期内 整个类所拥有

2.3 代码示例

2.3.1 静态局部变量

当变量声明为 static 时,空间将在程序的生命周期内分配。即使多次调用该函数,静态变量的空间也只分配一次,前一次调用中的变量值通过下一次函数调用传递。这对于在 C/C ++或需要存储先前函数状态的任何其他应用程序非常有用

#include <iostream>
#include <string>
using namespace std;

void demo()
{
    // static variable
    static int count = 0;
    cout << count << " ";

    // value is updated and
    // will be carried to next
    // function calls
    count++;
}

int main()
{
    for (int i = 0; i < 5; i++)
        demo();
    return 0;
}

输出:

0 1 2 3 4

2.3.2 静态全局变量

修饰 全局变量,修改变量的作用域,使其从整个工程可见变成本文件可见

1、先看非 static 修饰全局变量的例子,如下所示:

static.cpp 如下所示:

#include <iostream>
#include <string>

#include "Apple.h"

using namespace std;

extern int s_count;

int main()
{
    Apple obj;
    obj.printMsg();

    s_count = 5;
    cout << "main s_count:" << s_count << ", addr:" << &s_count << endl;
    return 0;
}

Apple.h 如下所示:

#include <iostream>
#include <string>
using namespace std;

class Apple
{
public:
    void printMsg();
};

Apple.cpp 如下所示:

#include "Apple.h"

int s_count;

void Apple::printMsg()
{
    s_count = 2;
    cout << "Welcome to Apple, s_count:" << s_count << ", addr:" << &s_count << endl;
}

编译并运行如下所示:

image

可以看到能正常使用全局变量,地址是同一个,因此全局变量的作用域为整个工程。

2、此时我们给全局变量加上 static 修饰词,更改 Apple.cpp 如下所示:

int s_count; 改成 -> static int s_count;

编译如下所示:

image

说 s_count 重复定义了。也就是说在 static.cpp 中不能使用它,也验证了 static 修饰 全局变量,修改变量的作用域,使其从整个工程可见变成本文件可见。

2.3.3 静态成员变量

先看一个错误的例子,之前说过如果不是常量,则需要在类外定义,在类内仅是声明(否则编译器会报未定义),如下所示:

#include<iostream>
using namespace std;

class Apple
{
public:
    static int i;

    Apple()
    {
        // Do nothing
    };
};

int main()
{
    Apple obj1;
    Apple obj2;
    obj1.i = 2;
    obj2.i = 3;

    // prints value of i
    cout << obj1.i << " " << obj2.i;
}

编译的时候,会报错如下所示:

image

因此,类中的静态变量应由用户使用类外的类名和范围解析运算符显式初始化,如下所示:

#include<iostream>
using namespace std;

class Apple
{
public:
    static int i;

    Apple()
    {
        // Do nothing
    };
};

int Apple::i = 1;

int main()
{
    Apple obj;
    // prints value of i
    cout << obj.i;
}

输出:

1

2.3.4 静态对象

类对象为静态,就像变量一样,对象也在声明为 static 时具有范围,声明周期为程序的生命周期

先看一个非静态对象的例子,如下所示:

#include<iostream>
using namespace std;

class Apple
{
    int i;
    public:
        Apple()
        {
            i = 0;
            cout << "Inside Constructor\n";
        }
        ~Apple()
        {
            cout << "Inside Destructor\n";
        }
};

int main()
{
    int x = 0;
    if (x == 0)
    {
        Apple obj;
    }
    cout << "End of main\n";
}

输出:

Inside Constructor
Inside Destructor
End of main

在上面的程序中,对象在 if 块内声明为非静态。因此,变量的范围仅在 if 块内。因此,当创建对象时,将调用构造函数,并且在 if 块的控制权越过析构函数的同时调用,因为对象的范围仅在声明它的 if 块内。

现在将对象声明为静态,现在让我们看看输出的变化,如下所示:

#include<iostream>
using namespace std;

class Apple
{
    int i;
    public:
        Apple()
        {
            i = 0;
            cout << "Inside Constructor\n";
        }
        ~Apple()
        {
            cout << "Inside Destructor\n";
        }
};

int main()
{
    int x = 0;
    if (x == 0)
    {
        static Apple obj;
    }
    cout << "End of main\n";
}

输出:

Inside Constructor
End of main
Inside Destructor

现在,在 main 结束后调用析构函数,这是因为静态对象的范围是贯穿程序的生命周期。

2.3.5 静态成员函数

我们被允许使用对象和 '.' 来调用静态成员函数,但 建议使用类名和范围解析运算符调用静态成员函数

#include<iostream>
using namespace std;

class Apple
{
    public:
        // static member function
        static void printMsg()
        {
            cout << "Welcome to Apple!";
        }
};

// main function
int main()
{
    // invoking a static member function
    Apple::printMsg();
}

输出:

Welcome to Apple!

3 this

1、this 指针是一个隐含于每一个非静态成员函数中的特殊指针。它指向调用该成员函数的那个对象。

2、当对一个对象调用成员函数时,编译程序先将对象的地址赋给 this 指针,然后调用成员函数,每次成员函数存取数据成员时,都隐式使用 this 指针。

3、当一个成员函数被调用时,自动向它传递一个隐含的参数,该参数是一个指向这个成员函数所在的对象的指针。

4、this 指针被隐含地声明为: ClassName *const this,这意味着不能给 this 指针赋值

  • 在 ClassName 类的 const 成员函数中,this 指针的类型为:const ClassName* const,这说明不能对 this 指针所指向的这种对象是不可修改的(即不能对这种对象的数据成员进行赋值操作,因为常函数不能修改成员变量);

5、this 并不是一个常规变量,而是个右值,所以不能取得 this 的地址(不能 &this)。

6、在以下场景中,经常需要显式引用 this 指针:

  • 为实现对象的链式引用;

  • 为避免对同一对象进行赋值操作;

  • 在实现一些数据结构时,如 list。

3.1 分析 this 示例代码

示例来源于 this指针那些事 - https://light-city.github.io/basic_content/this/,可直接去查看

类中的 this 指针是什么?

className *const, 即指向常量的指针,即指针指向的对象的值不可改变,即不可为 this 指针赋值

类中常函数里的 this 指针是什么?

首先我们复习一下常函数的概念,常函数就是常量函数,被 const 修饰的成员函数。常函数不能修改成员变量的值,只能访问 const 变量与 const 函数。

先说结论:类中常函数里的 this 指针类型为 const ClassName* const,则指向常量的常指针,即指针指向的对象的值不可改变且指针本身也不允许改变

其实原因就是,这个 const 函数,它只能访问 const 变量与 const 函数,不能修改其他变量的值,所以需要一个 this 指向不能修改的变量,那就是const ClassName* (即常指针),又由于本身 this 是 ClassName* const(指向常量的指针),所以就为 const ClassName* const(指向常量的常指针)

这个地方对 const 还是不太清楚的,可以复习一下 【八股文 01】const 关键字 - https://www.cnblogs.com/PikapBai/p/17564064.html

4 inline(内联函数)

4.1 定义

inline 关键字告诉编译器用函数定义中的代码替换每个函数调用实例

4.2 使用

// 声明1(加 inline,建议使用)
inline int functionName(int first, int second,...);

// 声明2(不加 inline)
int functionName(int first, int second,...);

// 定义
inline int functionName(int first, int second,...) {/****/};

// 类内定义,隐式内联
class A {
    int doA() { return 0; }         // 隐式内联
}

// 类外定义,需要显式内联
class A {
    int doA();
}
inline int A::doA() { return 0; }   // 需要显式内联

4.3 优点

减少压栈、跳转和返回的操作,没有普通函数调用时的额外开销

4.4 限制

1、对编译器的一种请求,编译器有可能会拒绝这种请求

2、不能存在任何形式的循环语句(函数执行时间要比普通函数调用开销大)

3、函数体不能过于庞大(每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间)

4、内联函数声明必须在调用语句之前

4.5 适用场景

1、使用宏定义的地方都可以使用 inline 函数
2、使用类成员接口函数来读写类的私有成员和保护成员,会提高效率

4.6 虚函数(virtual)可以是内联函数(inline)吗?

虚函数可以是内联函数,内联是可以修饰虚函数的,但是当虚函数表现多态性的时候不能内联。

内联是在编译期建议编译器内联,而虚函数的多态性在运行期,编译器无法知道运行期调用哪个代码,因此虚函数表现为多态性时(运行期)不可以内联。

inline virtual 唯一可以内联的时候是:编译器知道所调用的对象是哪个类(如 Base::who()),这只有在编译器具有实际对象而不是对象的指针或引用时才会发生。

虚函数内联使用,示例代码:

#include <iostream>
using namespace std;
class Base
{
public:
    inline virtual void who()
    {
        cout << "I am Base\n";
    }
    virtual ~Base() {}
};
class Derived : public Base
{
public:
    inline void who()  // 不写inline时隐式内联
    {
        cout << "I am Derived\n";
    }
};

int main()
{
    // 此处的虚函数 who(),是通过类(Base)的具体对象(b)来调用的,编译期间就能确定了,所以它可以是内联的,但最终是否内联取决于编译器。
    Base b;
    b.who();

    // 此处的虚函数是通过指针调用的,呈现多态性,需要在运行时期间才能确定,所以不能为内联。
    Base *ptr = new Derived();
    ptr->who();

    // 因为Base有虚析构函数(virtual ~Base() {}),所以 delete 时,会先调用派生类(Derived)析构函数,再调用基类(Base)析构函数,防止内存泄漏。
    delete ptr;
    ptr = nullptr;

    system("pause");
    return 0;
} 

4.7 inline 和 define 的区别

1、define 在预处理时进行,之作简单字符串替换
2、内联函数在编译时直接将函数代码嵌入到目标代码中,并且进行类型检查,具有返回值,可以实现重载

5 volatile

5.1 定义

volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素(操作系统、硬件、其它线程等)更改。所以使用 volatile 告诉编译器不应对这样的对象进行优化。

5.2 作用

volatile 关键字声明的变量,每次访问时都必须从内存中取出值(没有被 volatile 修饰的变量,可能由于编译器的优化,从 CPU 寄存器中取值)

5.3 用法

volatile int i = 10;

5.4 常用场景

1、在中断服务函数中的使用

/*   main.c */

int flag = 0;
int main(void)
{
    if(flag == 1) 
      {do somethings}

  if(flag == 2)
        {do somethings}

  return 0;
}

/* interrupt*/
void NVIC_Handler(void)
{
  flag = 1;
}

在这种情况下,编译器可能会对其做优化,虽然中断服务函数改变了 flag 的值,但是编译器并没有在变量内存中去读取,而是在寄存器中读取了 flag 之前的缓存数据。在中断函数中的交互变量,一定要加上 volatile 关键字修饰,这样每次读取 flag的值都是在其内存地址中读取的,确保是我们想要的数据。

2、多任务环境下各任务间共享的标志应该加 volatile。原因其实和上面中断一样,要共享标志,又不想让编译器优化了这一点,需要加上该修饰词。

3、存储器映射的硬件寄存器通常也要加 voliate,因为每次对它的读写都可能有不同意义。

5.5 一个参数既可以是 const 还可以是 volatile 吗

可以的,例如只读的状态寄存器。它是 volatile 因为它可能被意想不到地改变。它是 const 因为程序不应该试图去修改它。软件不能改变,并不意味着我硬件不能改变你的值,这就是单片机中的应用

6 参考资料

1、interview - 作者:huihut - https://github.com/huihut/interviewhttps://interview.huihut.com/#/?id=const

2、C++那些事 - 作者:Light-City - https://light-city.github.io/

3、《C++八股文-小贺-v1.0.pdf》 - 作者:小贺/微信搜索 herongwei - https://github.com/rongweihe/CPPNotes

4、extern (C++) - https://learn.microsoft.com/zh-cn/cpp/cpp/extern-cpp