目录

理解常量const

常量const通俗理解

首先先讲一讲常量,为什么要使用常量,因为不变的值更易于理解、跟踪和分析,所以应该尽可能地使用常量代替变量。

C++ 语言可以用const来定义常量,也可以用 #define来定义常量。但是前者比后者有更多的优点:

  1. const常量有数据类型,而宏常量没有数据类型。编译器可以对前者进行类型安全检查。而对后者只进行字符替换,没有类型安全检查,并且在字符替换可能会产生意料不到的错误(边际效应)。
  2. 有些集成化的调试工具可以对const常量进行调试,但是不能对宏常量进行调试。

所以本文就针对const 修饰的常量以及其他的类型进行探讨。

在C++的学习过程中,不管是初学者,还是略有了解的人,对const关键字的理解总是或多或少遇到一些问题。本文不妨采用一种通俗的理解方式,另辟蹊径来和大家分享一下理解思路。

先说说技巧,怎么区分const修饰的是什么: 理解的时候,在定义或声明中跳过或者忽略掉数据类型(int,char,double等),const后面的内容即为修饰的内容。

比如:

普通类型:

1
2
const int p;//忽略int,即 const后面是p,修饰的即为p
int const p;//同样的分析方法,这句跟上面的分析一致

指针类型:

1
2
3
4
const int *p1; //去掉int,const修饰的是 *
int const *p2; //去掉int,const修饰的是 *,等同于上面一句
int* const p3; //去掉int,const修饰的是p3,区别于上面那种情况
const int* const p4; //去掉int,const即修饰*,又修饰p4,区别上面两种情况

当然还有其他的类型等,放到后面再说。

const修饰的普通变量

变量名的本质:一段连续内存空间的别名

比如:

定义了 :int p = 10;

则系统会分配4个字节的内存,这块内存我们命名为p,其地址是不确定的,这里我们假定是0x2222,于是在p生存的周期内,这块从0x2222开始的地址的内存空间就被命名为p,p = 10 即向这块内存内写入10。

对于普通变量,const修饰时,仅仅改变的是变量的属性,即将原来变量的可读写性变成了只读性。

可以这么理解:普通变量加const修饰是为了防止变量被修改

但有个例外:如果该变量为全局变量,则不能通过指针修改,但如果是局部变量,则还是可以通过指针修改的。

原因是:全局变量在全局静态区,内容不能被修改,但局部变量本身仍在栈区,可以使用指针修改。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const int a = 10; //const修饰全局变量必须初始化

void fun(){
    const int b; //const修饰局部变量可以不初始化
    int *p = &b; //可以利用指针指向b对应的内存块,对该内存块修改
    *p = 20;     
    printf("%d , %d",*p,b); //结果为 20 ,20 

    p = &a;    //可以利用指针指向a对应的内存块
    *p = 20;   //(此句运行出错)但不可以通过指针修改全局变量的内容
    a = 20;    //(此句运行出错)因为a为全局常量,不能修改

}

默认情况下,const对象被设定为仅在文件内有效。当多个文件中出现了同名的const变量时,其实等同于在不同的文件中分别定义了独立的变量。所以如果想要在多个文件中共享const对象,则对于const变量不管是声明还是定义都添加extern关键字,这样只需定义一次就可以了。

const 与指针

利用最前面分析的方法,可以知道const修饰指针变量有三种情况:

const修饰 *

const修饰变量

const既修饰 * 号又修饰变量

只要掌握了前两种情况,最后第三种自然明了。

先说说const修饰 * 号,该情况为(局部变量):

1
2
3
4
5
6
7
8
const int *p_a;//与int const *p_a;等价

int a = 10;
int b = 20;
p_a = &a;   
*p_a = 20;  //错误
a =20;      //正确
p_a = &b;   //正确

const修饰*号时,表示不能通过指针p_a修改p_a指向的内容。这句话有3层意思(对应上述代码三种情况):

①当p_a指向a时,不能通过指针p_a取*号修改a中的内容

②a中的内容仍可以由a自身修改

③p_a指向可以改变,即p_a可以指向b

可以这么记忆:* 号是指针用来对指向的内存操作(读写)的,当对 * 加了`修饰,意味着 * 号的操作只剩下只读的功能,也就是只能使用 *号来读取指针指向的内容,而失去了写的特性,这点与普通变量的情况类似。

再说说const修饰变量的情况:

1
2
3
4
5
6
7
8
9
void fun(){
    int a = 10;
    int b = 20;
    int* const p_a = &a; 

    *p_a = 20; //正确
    a = 30;    //正确
    p_a = &b;  //错误
}

const修饰变量p_a时,表示不能改变p_a的内容,即改不了指针的指向。这句话有3层意思(对应上述代码三种情况):

①当p_a指向a时,能通过指针p_a取*号修改a中的内容

②a中的内容仍可以由a自身修改

③p_a指向不可以改变,即p_a不可以修改,指向b或者其他变量

最好的理解便是:指针变量也是变量,变量名的本质:一段连续内存空间的别名。理解好了这句,也就明白了,const修饰了这个变量,也就是修饰了这块内存空间,使得这块内存空间的可读写性改为了可读性,也就是指针指向固定,无法更改。

看到这里,如果前面的都了解了,那么你就会明白了用const修饰,实际上就是将对应的可读写改成了只读性质。

那么,最后一种情况就是,const既修饰 * 号,又修饰变量的情况了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
void fun(){

    int a = 10;
    int b = 20;

    const int* const p = &a; //必须初始化
    *p = 20;                 //错误    
    p = &b;                  //错误
    a = 20;                  //正确
}

如果明白了前两种情况,那么最后这种情况就好理解了(对应上述代码三种情况)

const修饰*号限定了指针对内存的操作只能为只读

const修饰变量p限定了指针的指向,p不能指向其他变量

③a或b本身的变量可以自身修改。

至于为什么必须初始化,如果理解了上述内容,那也就明白了(因为只读)。并且在初始化的过程中,利用一个对象去初始化另一个对象,则它们是不是const都无关紧要,因为拷贝一个对象的值并不会改变它。说下不初始化的情况,编译器(我用的VS2017)仍能通过,但有警告,p没有明确初始化,并不知道其指向哪里,由于该变量既不能修改指向,又不能修改指向的内容,所以此时该指针也就没有作用了,这种做法是不好的。

const 与引用类型

可以把引用绑定到const对象上,就像绑定到其他对象身上一样,我们称之为对常量的引用。与普通引用不同的是,对常量的引用不能被用作修改它所绑定的对象:

1
2
3
4
const int ci = 1024;
const int &r1 = ci; //正确, 引用及其对应的对象都是常量
r1 = 42;         // 错误,r1 是对常量的引用
int &r2 = ci;     // 错误,试图让一个非常量引用指向一个常量对象

因为不允许直接为ci 赋值,当然也不可以通过引用去改变ci,因此对r2的初始化是错误的,假设该初始化合法, 就可以通过r2来改变其引用的值,这显然不正确。

“常量引用”是“对const的引用“,严格来说,并不存在常量引用,因为引用不是一个对象,我们没法让引用本身恒定不变。事实上,由于C++语言并不允许随意改变引用所绑定的对象,所以从这层意义上来看所有的引用又都算常量。引用的对象是常量还是非常量可以决定其所能参与的操作,却无论如何都不会影响到引用和对象的绑定关系本身。

引用的类型必须与其所引用的对象保持一致,但是有两个例外:第一个是初始化常量引用时允许用任意表达式作为初始值,只要该表达式的结果能转化成引用的类型即可。尤其,允许为一个常量引用绑定非常量的对象、字面值,甚至是一个表达式。

对于以下代码:

1
2
double dval = 3.14;
const int &ri = dval;

此处ri引用了一个Int型的数,对ri的操作应该是整数运算,但dval是一个双精度浮点数而非整数。因此为了确保让ri绑定一个整数,编译器将代码变成了以下形式:

1
2
const int temp = dval; // 由双精度浮点数生成一个临时的整型常量
const int &ri = temp;  // 让ri绑定这个临时量

这种情况下,ri绑定了一个临时量对象,所谓的临时量对象就是编译器需要一个空间来暂存表达式的求值结果临时创建的一个未命名的对象。

const的引用可能引用一个并非const 的对象

必须认识到,常量引用仅引用可参与的操作做出了限定,对于引用的对象本身是不是一个常量未做限定。因为对象也可能是个非常量,所以允许通过其他途径改变它的值。

指针、常量和类型别名

https://i0.hdslb.com/bfs/album/35590d0752e180331f805f1732075ae79f42b857.png

针对上述的情况,特别举一例子作为讲解

先看下面的代码

1
2
3
4
5
6
typedef char* VP;
const char *data[3] = {"I","Love","u"};     // (1)
const VP data1[3] = {"I","Love","u"};      // (2)

data[1] = "hate";       //能正常修改
data1[1] = "hate";      //编译器报错,提示data1[1]是无法修改的左值

根据编译器报错的提示信息,你可能猜到了在(2)式中的指针变为了常量指针,而(1)没有改变。那为什么会造成这种情况呢,这得从编译器的角度来说明变量的申明顺序问题。

首先,先针对声明语句进行剖析一下。

(a) c和c++中声明包含零个或者多个声明说明符,和一个声明符 例如:

1
static unsigned long int *x[N];
1
2
static unsigned long int // 声明说明符 
*x[N] // 声明符 

一个声明符就是被声明的名称,可能还伴有操作符,如*,(),[],&等,以本例为例,*表示x是一个指针,[]表示序列, *x[N]表明x是含有N个指针的序列。 可能基础比较弱的人一直分不清楚指针数组和数组指针,以及返回指针的函数和函数指针怎么写。其实与运算符一样,操作符也是有优先级的,在本例中[]的优先级要比*的优先级高,所以首先其是一个序列,然后才声明序列中的元素是指针。而(*x)[N]中()的优先级比[]的优先级高,所以首先确定了其是一个指针。

(b) 声明说明符包含类型说明符和与类型无关的说明符,分清楚这个对于理解例子中的两个例子为什么会出现不同的情况至关重要。注意在(1)式中char是类型说明符,而const是与类型无关的说明符,所以1)式中const并不会修饰变量data,应该理解为指向const char的指针序列,而不是指向char的const指针序列。。

(c) 声明说明符在一个声明中出现的顺序并不重要,如:

1
2
const void *data[N];
void const *data[N];

这两者实际上是等价的,但是要注意对于指针的声明是从右到左来看的,所以对于在对指针的声明说明符中,正确的写法应该将const放在类型说明符的右边。如:

1
2
T const *p // const 在T后面, 指向常量的指针
T* const p // const 在T后面,常量指针

上面的式子中表示指向const T的指针,而下面的式子修饰了操作符*则表示指向T类型的const指针,说以对于最开始的问题中的(2)按照正确的写法应该是:

1
2
3
VP const data1[3] = {"I","Love","u"}
展开:
char* const data1[3] = {"I","Love","u"}

这样对照刚刚的讲解,现在是不是就能理解为什么data1序列中的指针为const类型了。

constexpr

constexpr 是 C++11 引入的,一方面是为了引入更多的编译时计算能力,另一方面也是解决 C++98 的 const 的双重语义问题。

在 C 里面,const 很明确只有 「只读」 一个语义,不会混淆。C++ 在此基础上增加了 「常量」 语义,也由 const 关键字来承担,引出来一些奇怪的问题。C++11 把 「常量」 语义拆出来,交给新引入的 constexpr 关键字。

看如下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template<int N> class C{};

constexpr int FivePlus(int x) { return 5 + x; }

void f(const int x) {
    C<x> c1; // Error: x is not compile-time evaluable.
    C<FivePlus(6)> c2; // OK
}

void g() {
    const int x = 5;
    C<x> c1; // OK!!! 此处用x的「常量」语义
    *(int *)(&x) = 6; // Still OK! 只处用x的「只读」语义,去除const后便可写了; (int*)是强制类型转换,有些编译器可能会报错
    // 如果上一句报错,就可以写成下面这一句:
    // *const_cast<int*>(&x) = 6;
    C<x> c2; // Still OK! c2是C<5>类型(不是C<6>!)
    C<FivePlus(x)> c3; // Still OK! c3是C<10>类型(不是C<11>!)

    printf("%d\n", x); // 此处绝大多数(所有?)C++编译器会输出5!!
                       // (然而,如果用一个C编译器来编译类似代码,一定输出6)
    const int* p = &x;
    printf("%d\n", *p); // 此处,大多数C++编译器输出6
}

可以看到,f 和 g 都有一个 const int x,但它们的行为却不同。原因在于:f 的 const int x 只是「一个只读的变量」;而 g 的 const int x 既是「一个只读的变量」,又是「一个值为5的常量」,变得飘忽不定。

在 C++11 以后,建议凡是 「常量」 语义的场景都使用 constexpr,只对 「只读」 语义使用 const

可能有人就会糊涂了,只读变量 难道不就是 常量吗?然则非也。

同样一个内存地址,用常量指针关联时,通过这一路径就无法修改;换用非常量指针 关联时,在这条路径上就是可以修改的。

换句话说,用 const 限定变量时,只是剥夺了通过该变量修改相应内存中内容的可能性,但是有可能其他程序或其他指向该内存地址的变量会改变这块内存中的内容,也就是说这块内存地址空间的内容并不会保证一直不变。所以,从现在开始,就把const理解成只读的限定符,把constexpr理解成常量的限定符

参考文献

https://blog.csdn.net/rlyhaha/article/details/80397227

https://blog.csdn.net/JayFan_Ma/article/details/82942903

https://blog.csdn.net/qq_40416052/article/details/82655736

https://blog.csdn.net/qq_40399012/article/details/84069983

https://blog.csdn.net/love_gaohz/article/details/7567856

(43条消息) C++ const 和 constexpr 的区别?_Sunny_Jie的博客-CSDN博客_const与constexpr的区别