C++ 补完计划(四):C++ 中的一些关键字
参考:
const
在 C/C++ 中,const
是一个关键字,用于表示常量。const
可以用于修饰变量、函数、指针等,主要作用有以下几种:
修饰变量
当 const
修饰变量时,该变量将被视为只读变量,即不能被修改。
对于确定不会被修改的变量,应该加上 const
,这样可以保证变量的值不会被无意中修改,也可以使编译器在代码优化时更加智能。
这里的变量只读,其实只是编译器层面的保证,实际上可以通过指针在运行时去间接修改这个变量的值。虽然可以这样操作,但这违反了 const
的语义,可能会导致程序崩溃或者产生未定义行为(undefined behavior),大家学习了解即可,实际编程中切莫如此操作。因为编译器可能会做一些优化,也就是在用到 const
变量的地方,编译器可能生成的代码直接就替换为常量的值,而不是访问一遍常量的指令。
所以极大可能你虽然修改了值,但是却不起作用!
下面这个例子,展示了使用 const_cast
修改 const
变量的值却不会起作用:
1 | const int a = 10; |
在上面的例子中,将 p 声明为 const int*
类型,指向只读变量 a 的地址。然后使用 const_cast
将 p 强制转换为 int*
类型的指针 q,从而去掉了 const
限制。接下来,通过指针 q 间接修改了变量 a 的值。但是请注意,即使 a 的值被修改了,但在程序中输出a 的值仍然是 10,正如前面分析,因为 a 是只读变量,所以编译器做了优化,早就把代码实际替换为了下面这样:
std::cout << "a = " << 10 << std::endl;
修饰函数参数,表示函数不会修改参数
当 const
修饰函数参数时,表示函数内部不会修改该参数的值。这样做可以使代码更加安全,避免在函数内部无意中修改传入的参数值。
尤其是引用作为参数时,如果确定不会修改引用,那么一定要使用 const
引用。
修饰函数返回值
当 const
修饰函数返回值时,表示函数的返回值为只读,不能被修改。这样做可以使函数返回的值更加安全,避免被误修改。如:
1 | const int func() { |
实际上功能与第一条有些类似。
修饰指针或引用
在 C/C++ 中,const
关键字可以用来修饰指针,用于声明指针本身为只读变量或者指向只读变量的指针。
根据 const
关键字的位置和类型,可以将 const
指针分为以下三种情况:
pointer to const
这种情况下,const
关键字修饰的是指针所指向的变量,而不是指针本身。
因此,指针本身可以被修改(意思是指针可以指向新的变量),但是不能通过指针修改所指向的变量。
这种指针有很多种叫法,在 C++ primer 中,你可以叫它 “Pointer to Const
”,即 指向常量的指针,同时由于指向的对象为 const
而自己不是 const
类型,因此又可以被归类为一种 底层 const(指向的对象是一个常量)。
1 | const int* p; // 声明一个指向只读变量的指针,可以指向 int 类型的只读变量 |
还有一点需要注意的是,允许一个指向常量的指针指向一个非常量的对象;所谓指向常量的指针仅仅要求不能通过该指针改变对象的值,而没有规定那个对象的值不能通过其他途径改变。这部分详情请参考 《C++ Primer 5th》P56。
const pointer
这种情况下,const
关键字修饰的是指针本身,使得指针本身成为只读变量。
因此,指针本身不能被修改(即指针一旦初始化就不能指向其它变量),但是可以通过指针修改所指向的变量。
1 | int a = 10; |
同样地,这种指针也有很多种叫法,且一些中文书籍与《C++ Primer 5th》还有冲突,这里还是以 C++ Primer 为准,叫它 const pointer
;其次,由于指针本身为 const 变量,因此可以被称为 顶层const(指针本身是个 const)。
const pointer pointer to const
有点绕口令的感觉了,总之就是结合了以上两点。
这种情况下,const
关键字同时修饰了指针本身和指针所指向的变量,使得指针本身和所指向的变量都成为只读变量。
因此,指针本身不能被修改,也不能通过指针修改所指向的变量。
1 | const int a = 10; |
const reference
常量引用是指引用一个只读变量的引用,因此不能通过常量引用修改变量的值。
1 | const int a = 10; |
如何区分?
从左至右顺序理解修饰符会让我们更好地区分以上的几种指针:
-
const int* p;
: 这种情况下const
修饰的是int
,因此声明的是指向const int
的指针。 -
int* const p = &a;
: 这种情况下const
修饰的是 p,p 是什么?p是一个int*
类型的指针,因此声明的是一个const pointer
。
修饰成员函数
当 const
修饰成员函数时,表示该函数不会修改对象的状态(就是不会修改成员变量)。
这样有个好处是,const
的对象就可以调用这些成员方法了,因为 const
对象不允许调用非 const
的成员方法。
也很好理解,既然对象是 const
的,那我怎么保证调用完这个成员方法,你不会修改我的对象成员变量呢?那就只能你自己把方法声明为 const
的了。
普通函数不能修饰为
const
1 | class A { |
这里还要注意,const
的成员函数不能调用非 const
的成员函数,原因在于 const
的成员函数保证了不修改对象状态,但是如果调用了非 const
成员函数,那么这个保证可能会被破坏。
总之,const
关键字的作用是为了保证变量的安全性和代码可读性。
const 成员函数进阶知识点
-
const
成员函数可以修改static
成员变量,因为static
成员变量是属于类的,而不是属于对象的。 -
const
成员函数可以修改mutable
成员变量。1
2
3
4
5
6
7
8
9
10
11class A {
public:
void func() const {
m_value = 10; // 合法,可以修改 mutable 成员变量
m_data = 20; // 非法,不能修改普通成员变量
}
private:
mutable int m_value;
int m_data;
};
mutable
mutable
是 C++ 中的一个关键字,用于修饰类的成员变量,表示该成员变量可以在 const
成员函数中被修改。它的主要作用是允许在逻辑上保持常量的成员函数中修改某些成员变量,从而提供更大的灵活性。
主要作用:
-
允许在
const
成员函数中修改成员变量: 通常,const
成员函数不能修改类的成员变量,但有时我们希望在const
成员函数中修改某些成员变量,例如用于缓存或统计目的。mutable
关键字可以实现这一点。 -
用于
lambda
表达式: 在lambda
表达式中,mutable
关键字允许修改捕获的变量。默认情况下,lambda
表达式捕获的变量是只读的,使用mutable
可以使这些变量在lambda
表达式内部可修改。1
2
3
4
5
6
7
8
9
10int main() {
int x = 10;
auto lambda = [x]() mutable {
x = 20; // 现在可以修改 x
std::cout << x << std::endl;
};
lambda();
std::cout << x << std::endl; // 原来的 x 仍然是 10
return 0;
}
static
在 C/C++ 中,static
是一个非常重要的关键字,它可以用于变量、函数和类中。
static 修饰全局变量
static
修饰全局变量可以将变量的作用域限定在当前文件中,使得其他文件无法访问该变量。 同时,static
修饰的全局变量在程序启动时被初始化(可以简单理解为在执行 main 函数之前,会执行一个全局的初始化函数,在那里会执行全局变量的初始化),生命周期和程序一样长。
1 | // a.cpp 文件 |
static 修饰局部变量
static
修饰局部变量可以使得变量在函数调用结束后不会被销毁,而是一直存在于内存中,下次调用该函数时可以继续使用。
同时,由于 static
修饰的局部变量的作用域仅限于函数内部,所以其他函数无法访问该变量。
利用这个特性可以实现 Meyer’s Singleton 单例模式。详见 C++ 补完计划(五):C++ 设计模式
1 | void foo() { |
static 修饰函数
static
修饰函数可以将函数的作用域限定在当前文件中,使得其他文件无法访问该函数。
同时,由于 static
修饰的函数只能在当前文件中被调用,因此可以避免命名冲突和代码重复定义。
1 | // a.cpp 文件 |
static 修饰类成员变量和函数
static 修饰类成员变量和函数可以使得它们在所有类对象中共享,且不需要创建对象就可以直接访问。
1 | class MyClass { |
static变量在类的声明中不占用内存,因此必须在.cpp文件中定义类静态变量以分配内存。文件域的静态变量和类的静态成员变量在main执行之前的静态初始化过程中分配内存并初始化;局部静态变量在第一次使用时分配内存并初始化。
volatile
volatile
是 C 语言中的一个关键字,用于修饰变量,表示该变量的值可能在任何时候被外部因素更改,例如硬件设备、操作系统或其他线程。
当一个变量被声明为volatile
时,编译器会禁止对该变量进行优化,以确保每次访问变量时都会从内存中读取其值,而不是从寄存器或缓存中读取。避免因为编译器优化而导致出现不符合预期的结果。
const
和volatile
可以一起使用,volatile
的含义是防止编译器对该代码进行优化,这个值可能变掉的。而const
的含义是在代码中不能对该变量进行修改。因此,它们本来就不是矛盾的。
1 |
|
上面声明了一个volatile int
类型的全局变量counter
,并创建了两个线程。
每个线程都会对counter
变量进行100000次自增操作。
由于counter
变量被声明为volatile,编译器不会对其进行优化,确保每次访问都会从内存中读取值。
当然啦,即便是volatile
关键字可以确保编译器不对变量进行优化,但上面任然存在并发问题,counter++
操作仍然可能导致数据不一致。
为了解决这个问题,需要使用互斥锁、原子操作或其他同步机制。
class & struct
C++ 中为了兼容 C 语言而保留了 C 语言的 struct
关键字,并且加以扩充了含义。
在 C 语言中,struct
只能包含成员变量,不能包含成员函数。
而在 C++ 中,struct
类似于 class
,既可以包含成员变量,又可以包含成员函数。
不同点
C++ 中的 struct
和 class
基本是通用的,唯有几个细节不同:
-
class
中类中的成员默认都是private
属性的。 -
在
struct
中结构体中的成员默认都是public
属性的。 -
class
继承默认是private
继承,而struct
继承默认是public
继承。 -
class
可以用于定义模板参数,struct
不能用于定义模板参数。
使用习惯
实际使用中,struct
我们通常用来定义一些 POD
(plain old data)
POD
是 C++ 定义的一类数据结构概念,比如 int
、float
等都是 POD 类型的。
Plain 代表它是一个普通类型,Old 代表它是旧的,与几十年前的 C 语言兼容,那么就意味着可以使用 memcpy()
这种最原始的函数进行操作。
两个系统进行交换数据,如果没有办法对数据进行语义检查和解释,那就只能以非常底层的数据形式进行交互,而拥有 POD 特征的类或者结构体通过二进制拷贝后依然能保持数据结构不变。
也就是说,能用 C 的 memcpy()
等函数进行操作的类、结构体就是 POD 类型的数据。
而 class
用于定义一些 非 POD 的对象,面向对象编程。
auto & decltype
C++11引入了auto
和decltype
关键字,使用他们可以在编译期就推导出变量或者表达式的类型,方便开发者编码也简化了代码。
auto
auto
可以让编译器在编译器就推导出变量的类型。
1 | auto a = 10; // 10是int型,可以自动推导出a是int |
用法:
1 | int i = 10; |
-
auto
的使用必须马上初始化,否则无法推导出类型。 -
auto
在一行定义多个变量时,各个变量的推导不能产生二义性,否则编译失败。
1 | void func(auto value) {} // error,auto不能用作函数参数 |
-
auto
不能用作函数参数。 -
在类中
auto
不能用作非静态成员变量。 -
auto
不能定义数组,可以定义指针。 -
auto
无法推导出模板参数。
1 | int i = 0; |
-
在不声明为引用或指针时,
auto
会忽略等号右边的引用类型和cv属性(const
、volatile
属性)。 -
在声明为引用或者指针时,
auto
会保留等号右边的引用和cv属性。
decltype
上面介绍auto
用于推导变量类型,而decltype
则用于推导表达式类型,这里只用于编译器分析表达式的类型,表达式实际不会进行运算。
1 | int func() { return 0; } |
decltype
不会像auto
一样忽略引用和cv属性,decltype
会保留表达式的引用和cv属性。
1 | const int &i = 1; |
对于decltype(exp)
有:
-
exp 是表达式,
decltype(exp)
和 exp 类型相同。 -
exp 是函数调用,
decltype(exp)
和函数返回值类型相同。 -
其它情况,若 exp 是左值,
decltype(exp)
是 exp 类型的左值引用。
1 | int a = 0, b = 0; |
auto
和 decltype
的配合使用
auto
和 decltype
一般配合使用在推导函数返回值的类型问题上。如:
1 | template<typename T, typename U> |
上面代码由于 t 和 u 类型不确定,那如何推导出返回值类型呢,我们可能会想到这种
1 | template<typename T, typename U> |
这段代码在 C++11 上是编译不过的,因为在 decltype(t+u)
推导时,t和u尚未定义,就会编译出错,所以有了下面的叫做返回类型后置的配合使用方法:
1 | template<typename T, typename U> |
返回值后置类型语法就是为了解决函数返回制类型依赖于参数但却难以确定返回值类型的问题。
几种类型转换关键字
在 C 语言中,我们大多数是用 (type_name) expression
这种方式来做强制类型转换,但是在 C++ 中,更推荐使用四个转换操作符来实现显式类型转换:
static_cast
dynamic_cast
const_cast
reinterpret_cast
static_cast
用法: static_cast <new_type> (expression)
其实 static_cast
和 C 语言 ()
做强制类型转换基本是等价的。
主要用于以下场景:
基本类型之间的转换
将一个基本类型转换为另一个基本类型,例如将整数转换为浮点数或将字符转换为整数。
1 | int a = 42; |
指针类型之间的转换
将一个指针类型转换为另一个指针类型,尤其是在类层次结构中从基类指针转换为派生类指针。这种转换不执行运行时类型检查,可能不安全,要自己保证指针确实可以互相转换。
1 | class Base {}; |
引用类型之间的转换
类似于指针类型之间的转换,可以将一个引用类型转换为另一个引用类型。在这种情况下,也应注意安全性。
1 | Derived derived_obj; |
static_cast
在编译时执行类型转换,在进行指针或引用类型转换时,需要自己保证合法性。
如果想要运行时类型检查,可以使用 dynamic_cast
进行安全的向下类型转换。
dynamic_cast
用法: dynamic_cast <new_type> (expression)
dynamic_cast
在C++中主要应用于父子类层次结构中的安全类型转换。
它在运行时执行类型检查,因此相比于 static_cast
,它更加安全。
dynamic_cast
的主要应用场景:
向下类型转换
当需要将基类指针或引用转换为派生类指针或引用时,dynamic_cast
可以确保类型兼容性。
如果转换失败,dynamic_cast
将返回空指针(对于指针类型)或抛出异常(对于引用类型)。
1 | class Base { virtual void dummy() {} }; |
用于多态类型检查
处理多态对象时,dynamic_cast
可以用来确定对象的实际类型,例如:
1 | class Animal { public: virtual ~Animal() {} }; |
另外,要使用dynamic_cast有效,基类至少需要一个虚拟函数。
因为,dynamic_cast
只有在基类存在虚函数(虚函数表)的情况下才有可能将基类指针转化为子类。
dynamic_cast
底层原理
dynamic_cast
的底层原理依赖于运行时类型信息(RTTI, Runtime Type Information)。
C++编译器在编译时为支持多态的类生成RTTI
,它包含了类的类型信息和类层次结构。
我们都知道当使用虚函数时,编译器会为每个类生成一个虚函数表(vtable),并在其中存储指向虚函数的指针。
伴随虚函数表的还有 RTTI
(运行时类型信息),这些辅助的信息可以用来帮助我们运行时识别对象的类型信息。
《深度探索C++对象模型》中有个例子:
1 | class Point |
首先,每个多态对象都有一个指向其vtable
的指针,称为vptr
。
RTTI
(就是上面图中的 type_info
结构)通常与vtable
关联。
dynamic_cast
就是利用RTTI
来执行运行时类型检查和安全类型转换。
以下是dynamic_cast
的工作原理的简化描述:
- 首先,
dynamic_cast
通过查询对象的vptr
来获取其RTTI
(这也是为什么dynamic_cast
要求对象有虚函数) - 然后,
dynamic_cast
比较请求的目标类型与从RTTI
获得的实际类型。如果目标类型是实际类型或其基类,则转换成功。 - 如果目标类型是派生类,
dynamic_cast
会检查类层次结构,以确定转换是否合法。如果在类层次结构中找到了目标类型,则转换成功;否则,转换失败。 - 当转换成功时,
dynamic_cast
返回转换后的指针或引用。 - 如果转换失败,对于指针类型,
dynamic_cast
返回空指针;对于引用类型,它会抛出一个std::bad_cast
异常。
因为 dynamic_cast
依赖于运行时类型信息,它的性能可能低于其他类型转换操作(如static_cast
),static_cast
是编译器静态转换,编译时期就完成了。
const_cast
用法: const_cast <new_type> (expression)
new_type
必须是一个指针、引用或者指向对象类型成员的指针。
修改 const
对象
当需要修改 const
对象时,可以使用 const_cast
来删除 const
属性。
1 | const int a = 42; |
const
对象调用非const
成员函数
当需要使用const
对象调用非const
成员函数时,可以使用const_cast
删除对象的const
属性。
1 | class MyClass { |
不过上述行为都不是很安全,可能导致未定义的行为,因此应谨慎使用。
reinterpret_cast
用法: reinterpret_cast <new_type> (expression)
reinterpret_cast
用于在不同类型之间进行低级别的转换。
首先从英文字面的意思理解,interpret是“解释,诠释”的意思,加上前缀“re”,就是“重新诠释”的意思;
cast 在这里可以翻译成“转型”(在侯捷大大翻译的《深度探索C++对象模型》、《Effective C++(第三版)》中,cast都被翻译成了转型),这样整个词顺下来就是“重新诠释的转型”。
它仅仅是重新解释底层比特(也就是对指针所指针的那片比特位换个类型做解释),而不进行任何类型检查。
因此,reinterpret_cast
可能导致未定义的行为,应谨慎使用。
reinterpret_cast
的一些典型应用场景:
指针类型之间的转换
在某些情况下,需要在不同指针类型之间进行转换,如将一个int
指针转换为char
指针。
这在 C 语言中用的非常多,C语言中就是直接使用 ()
进行强制类型转换
1 | int a = 42; |
inline
inline
是 C++ 中的一个关键字,用于告诉编译器将函数内联展开。内联函数的目的是为了减少函数调用时间。它是把内联函数的函数体在编译器预处理的时候替换到函数调用处,这样代码运行到这里时候就不需要花时间去调用函数。但内联函数有个缺点是它会增加执行文件大小。所以如果不适当的使用内联函数会造成执行文件特别大。
inline
在使用上有以下一些要注意的点:
-
使用inline关键字的函数可能会被编译器忽略而不在调用处展开
-
如果定义的
inline
函数过大,为了防止生成的obj
文件太大,编译器会忽略这里的inline
声明 -
inline
是在编译器将函数类容替换到函数调用处,是静态编译的。而虚函数是动态调用的,在编译器并不知道需要调用的是父类还是子类的虚函数,所以不能够inline
声明展开,所以编译器会忽略
-
-
头文件中不仅要包含
inline
函数的声明,还要包含inline
函数的定义编译器需要把
inline
函数体替换到函数调用处,所以编译器必须要知道inline
函数的函数体是啥,所以要将inline
函数的函数定义和函数声明一起写在头文件中,便与编译器查找替换。 -
同一个
inline
函数可以多处声明和定义,但是必须要完全相同 -
定义在
class
声明内的成员函数默认是inline
函数
extern
extern
是 C/C++ 中的一个关键字,用于声明一个全局变量或函数,表示该变量或函数是在其他文件中定义的。
-
extern
可以置于变量声明或者函数声明前,以表示变量或者函数的定义在别的文件中,提示编译器遇到此变量和函数时在其它文件中寻找其定义。 -
extern
变量表示声明一个变量,表示该变量是一个外部变量,也就是全局变量,所以extern
修饰的变量保存在静态存储区(全局区),全局变量如果没有显式初始化,会默认初始化为 0,或者显示初始化为 0 ,则保存在程序的 BSS 段,如果初始化不为 0 则保存在程序的 DATA 段。 -
extern "C"
的作用是为了能够正确的实现 C++ 代码调用 C 语言代码。加上 extern “C” 后,会指示编译器这部分代码按照 C 语言(而不是 C++)的方式进行编译。由于 C++ 支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名;而C语言并不支持函数重载,因此编译 C 语言代码的函数时不会带上函数的参数类型,一般只包括函数名。 这个功能十分有用处,因为在 C++ 出现以前,很多代码都是 C 语言写的,而且很底层的库也是 C 语言写的,为了更好的支持原来的 C 代码和已经写好的 C 语言库,需要在 C++ 中尽可能的支持 C,而 extern “C” 就是其中的一个策略。