前言
最近开始二刷c++ primer,第一遍很模糊的过了一下,由于前面的基础很多没理解透,从12章到16章基本是懵逼的状态。第二次为了保证质量准备把每个章节个人感觉重要的部分进行一个记录与总结,其中也记录了部分看书过程中遇到的问题,欢迎大佬们讨论。
文章目录
- 一、基本内置类型
- 1.1基本概念
- 1.2类型转换
- 二、变量
- 2.1初始化
- 2.2变量声明和定义的关系
- 三、复合类型
- 3.1引用
- 3.2指针
- 四、const限定符
- 4.1const的引用
- 4.2指针和const
- 4.3顶层const
- 4.4constexpr和常量表达式
- 五、处理类型
- 5.1类型别名
- 5.2auto类型说明符
- 5.3decltype类型
一、基本内置类型
1.1基本概念
C++包含算术类型和空类型两种基本数据类型。算术类型包含字符、整型数、布尔值和浮点数;空类型不对应具体的值,常见的比如写一个void的函数。下图是算术类型的表格,不同类型所占比特数不同,它们可以表示的范围也不一样。long long是C++11的新特性。对于浮点型,float和double分别有7和16个有效位。
带符号和无符号类型
如果把布尔型和扩展的字符型除去,整形可以划分为带符号和无符号两种。带符号类型可以表示正数、负数或0,而无符号类型仅能表示大于等于0的数。在有符号类型int、short等前面加上unsigned就可以变成无符号类型,unsigned int可以直接缩写为unsigned。对于字符型:char、signed char和unsigned char。char和signed char并不一样,所以字符的表现形式只有两种,char具体怎么表示由编译器决定。一般来说,两种类型最明显的区别就是它们所能表示的范围,对于一个8比特的有符号char,所能表示范围为-128-127,而无符号char范围则为0-255。
如何选择类型
书上写得很详细,对于初学者更应该关注前期如何合理、规范的定义类型。
- 明确数值不为负,选择无符号类型
- 尽量使用int,short太小,而long long太大,除非明确知道需要的范围超过了int。
- 尽量不使用char,因为在不同的机器上是否带符号是不确定的,如果要使用也要必须指定类型。
- 浮点数优先选择double,不仅精度高而且计算代价和单精度相比也相差无几。
1.2类型转换
当一种类型由于某些需要主动/被动转换成了另一种形式就是发生了类型转换。类型所能表示的值的范围决定了转换的过程:一个布尔值转换为整形,false->0,true->1;浮点型转换为整形,小区部分记为0并保留整数部分。转换的时候比如考虑当前类型所能表示的范围,当一个无符号类型被赋予了一个超出范围的数,那么最后得到的结果是对无符号类型表示范围总数取模后的余数;对于有符号类型表示的数超出范围将是未定义的。
当表达式中含有无符号类型
前面说过,类型转换的操作可以是主动也能是被动,在一段表达式中,如果同时出现一个有符号整数和无符号整数,那么有符号数就会进行主动转换成无符号数进行计算。在第二个表达式中,b是一个有符号整数,所以首先应该把它转换成无符号类型再进行计算,a和b相加后再对232取模(对这个取模感觉有点问题,要得到下面的结果,也可以直接a加上b后为-32,232 = 4294967296,那么用4294967296 - 32就得到了结果,取模反而不对,换成其它数字也能得到正确的结果,可能我哪儿理解有问题)。
unsigned a = 10;
int b = -42;
cout << b + b << endl; // -84
cout << a + b << endl; // 4294967264 这里的int是32位
/*
a = 10
b = -42 转换为无符号 (4294967296 - 42)= 4294967254
b = 4294967254 现在b也是无符号类型
a + b = 4294967264
*/
思考很久没有搞懂,问别人直接一句用二进制看就醒悟了,看来以前学的计算机基础全没了,要知道整数在计算机中是由补码来存储的,比如对于8比特大小的-1,[-1] = [1000 0001]原 = [1111 1110]反 = [1111 1111]补
,看最后的补码,由于转换成了无符号数,那么该二进制的结果就是255。如果要快速算可以参考上面代码块b=-42的转换过程。
平时在写表达式时一定要确定变量的类型,否则出现如上面表达式的转换往往会找不到的出错的原因,特别是绝对不要用无符号类型写for循环的判定值,否则该循环会无休止的执行下去,当然如果坚持要用也可以通过使用while的方式解决。
二、变量
数据类型决定着变量所占内存空间的大小和布局方式、该空间能存储的值的范围,以及变量能参与的运算。对象是指一块能存储数据并具有某种类型的内存空间,而命了名的对象就叫做变量。
2.1初始化
初始化就是一个对象获得了一个特定的值,这里要注意初始化并不是赋值,初始化的含义是创建对象赋予其一个初始值,而赋值的含义是把当前对象的值移除,而以一个新值来替代。
列表初始化
这是C++11的新特性,通过花括号的方式使用:int a{1};
,但是这种方式存在丢失信息的风险,下面的代码在编译器中会报错。
long double a = 3.1123123123123;
int b{a}; // 错误。由于存在丢失信息的风险,转换未执行
默认初始化
没有指定初值的变量将会进行默认初始化,默认值的类型由变量类型决定。定义在函数体外的变量会被默认初始化为0,而内部的内置类型不会被初始化,建议初始化每一个内置类型的变量。
2.2变量声明和定义的关系
C++支持分离式编译机制,该机制允许将程序分割为若干个文件,每个文件可被独立编译。为了支持分离式编译,C++将声明和定义区分开,声明就是告诉程序一个名字,定义就是去设计这个名字的具体内容并把它们关联起来,如果要声明一个变量而非定义它,可以在前面加一个关键字extern
,并且声明是不能初试化的,否则就成了定义。
extern int i; // 声明
int j; // 定义
extern int i = 23; // 报错
对于实际的情况,我们可以在多个文件中声明同一个变量,但是定义该变量只能在一个文件中。
三、复合类型
引用和指针是常见而重要的两中复合类型。
3.1引用
在C++中,谈到引用时一般指‘左值引用’,C++11中新增了右值引用,一般用于内置类型。引用其实就是给一个对象起了另外一个名字,并且引用必须初始化,这是因为在定义引用时,程序将和它的初始值对象一直绑定在一起,所以一个引用也无法被绑定到另外一个对象上。
int a = 123;
int &b = a; // b是a的另外一个名字
int &c; // 报错,引用必须初始化
定义了引用后,所有的操作都会影响到引用绑定的对象
b = 2;
cout << a << end; // 2
如果用引用来初始化另一个引用,相当于就是把该引用绑定的对象作为初始值。
int &d = b; // d和b都是a的名字
由于引用本身不是一个对象,所以不能定义引用的引用,所以引用只能绑定到对象上,不能与字面值或某个表达式的计算结果绑定在一起。
3.2指针
类似于引用,指针也实现了对其他对象的间接访问,但是指针存在几点不同。1)指针本身是一个对象,同时它可以在生命周期类指向不同的对象。2)定义指针无需初始化。
定义指针就是把声明符写成*d
的形式,指针实际存储的是一个对象的地址,要想获得地址,需要使用&
,而要想访问指针指向的对象,需要使用解引用符*
。
int a = 12;
int *p = &a // p存放a的地址,或者说p是指向a的指针
int *q = p; // q的初始值为指向a的指针
*p = 123; // 相当于a = 123
指针同样有空值,可以通过下面三种方式初始化一个空指针。nullptr字面值是在C++11中引入。虽然NULl = 0,但是在定义指针是不能出现int a = 0, p3 = a
这样的表达式,也就是不能把int变量直接赋值给指针,即使该值为0。
int *p1 = nullptr; // 字面值形式
int *p2 = 0; // 字面值形式
int *p3 = NULL; // 预处理变量,相当于0,使用它需要包含头文件cstdlib
前面在讲引用的时候说过,由于引用本身并不是一个对象,所以引用在绑定了一个对象后就不会再改变,而指针就不一样,它可以随时更换自己指向的对象。
void*
void*是一种特殊的指针,可以用于存放任意对象的地址。一般来说该指针使用较少,因为我们不知道它指向的对象是什么类型,也就不能直接操作void *
指向的对象。
小结
-
指针和引用
指针和引用都是符合类型,指针‘指向’内存中的某个对象,而引用‘绑定到’内存中的某个对象,它们都实现了对其他对象的间接访问。它们的区别有:引用本身不是一个对象,并且在定义时必须初始化,同时引用只要一开始绑定了一个对象那么后续就不能再改变。指针本身是一个对象,所以允许对指针赋值和拷贝,定义时可以不用初始化,并且在它的生命周期内,一个指针可以绑定多个对象。
指向指针的指针
指针本身是一个对象,所以它有自己的地址,那么肯定也接受另一个指针指向自己,也就是指向指针的指针。
int a = 123;
int *p = &a;
int **p2 = &p; // 指向指针的指针
下图描述了上面代码变量之间的关系,如果解引用一个指向指针的指针就会得到一个指针。
指向指针的引用
引用不是一个对象,所以不存在指向引用的指针。
int i = 42;
int *p; // p是一个指针
int *&r = p; // r是一个对指针p的引用,从右往左解读,首先r左边是&,那么r是一个引用,然后跟着一个*,那么说明r引用的是一个指针。
r = &i; // r引用了指针p,也就是r是p的另外一个名字,所以这里相当于让p指向i
*r = 0; // 同理,解引用r就是解引用p,那么现在i的值为0
四、const限定符
通过const修饰的变量在程序的执行过程中不能被改变,所以一旦使用const就必须要初始化。同时const变量也能和普通变量一样参与算术运算、赋值、转化等操作,也就是说只有当要改变改变量时const限定符才会发挥作用。一般来说一个const变量的作用域只存在于一个文件中,多个文件即使定义同名的const变量,它们也是独立的。那么如果我们希望一个const变量能够在多个文件中共享,可以使用关键字extern
。
t1.h
extern const int f = 1024; // 在一个文件中定义
t2.cpp
extern const int f; // 其它文件进行声明,两个文件的f是同一个
4.1const的引用
又称为常量引用。const常量也能被引用,与普通的引用不同,我们不能通过引用去改变它所绑定对象的值。
const int a = 123;
const int &r = a;
r = 32; // 错误
int &r1 = a; // 错误,非常量引用不能绑定到一个常量对象上
前面引用提到,引用的类型必须与所引用的对象一致,这里有两种例外。1)只要在初始化常量引用时=右边的结果可以主动转换为引用的类型。2)一个常量引用可以绑定到非常量的对象、字面值或者一般表达式。下面的例子中,我们定义了一个常量引用r1绑定到一个非常量对象val上,并且它们的类型也不同,但是这里并不会报错。
double val = 3.14;
const int &r1 = val;
其实在编译器内部,会首先声明一个临时的整形变量(临时量:编译器需要一个空间来存储表达式的结果而临时创建的一个未命名的对象),然后再把r1绑定到这个临时量上。
// 编译器内部的操作
const int temp = val; // val由double转换为int,进行了窄化
const int &r1 = temp;
如果我们通过非常量引用int &r2 = val
,就会直接报错,继续上面的一个过程,既然定义了一个非常量引用r2,就说明可能会改变它所绑定的对象,而在编译器的处理过程中,r2会绑定到一个临时变量上,所以通过r2也就只能改变临时变量的值,而val不会被影响,所以C++视这种把非常量引用绑定到临时量上为非法操作。
4.2指针和const
指向常量的指针不能用于改变所指对象的值,存放常量对象的地址只能使用指向常量的指针。与常量引用类似,指向常量的指针可以指向一个非常量的对象。
const int a = 123;
const int *p = &a; // p不能改变a的值
const指针
使用常量指针必须进行初始化,也就是该指针指向的对象在以后无法改变,但是任然可以通过该指针修改指向对象的值。下面是定义方式。
int a = 12
int *const p = %a; // p指针将一直指向a
继续套娃,可以写一个指向常量指针对象的常量指针,这样一来,不仅常量指针p不能指向其它对象,且无法通过它改变b的值。
const int b = 213;
const int *const p = &b;
4.3顶层const
顶层const的含义表示指针本身是个常量,与它相反的底层const表示指针所指的对象是一个常量。一般来说,顶层const可以表示任意的对象是常量,使用与任何类型;底层const则与指针和引用等符合类型的基本类型部分有关。指针类型既可以是顶层const也可以是底层const。(第一次看的时候有点模糊,简单来说就是一个变量,如果自己不能改变就是顶层const,如果该变量时指针,它指向的对象不能改变而自己可以改变就是底层const,可以看看书上的例子。)
int i = 0;
int *const p1 = &i; // p1是一个常量指针,自己不能改变所以是顶层const
const int *p2 = &i; // p2是一个指向常量的指针,虽然不能通过它改变i的值,但是它自己可以指向其它对象,所以这是一个底层const
const int *const p3 = &p2; // p3是一个指向常量的常量指针,所以同时拥有顶层const和底层const
在进行拷贝操作时,顶层const不受影响,而底层const存在限制:拷入和拷出的对象必须具有相同的底层const资格。或者说两个类型能够转换,一般来说非常量可以转换成常量,反之不行。
int *p = p3; // 错误,p3含有底层const而p没有。可以这样理解,p3也含有顶层const,也就是说它本身是常量不能改变,如果我们定义一个普通指针p指向p3,那么就说明可以通过p去改变p3的值,产生了矛盾。
总的来说,判断一个表示是否可行如果按照定义其实还挺麻烦,我觉得只要把每个变量是否是常量搞清楚,是常量就不能用普通指针或引用去指向它。下面有两道例题2.30、2.31。
4.4constexpr和常量表达式
常量表达式指值不会改变且在编译过程就能得到计算结果的表达式。字面值就一个常量表达式。
const int a = 20; // 常量表达式
const int b = a + 1; // 常量表达式
int c = 23; // 非常量表达式
const int d = fn(); // 非常量表达式
constexpr变量
C++11新增,允许将变量声明为constexpr类型以便由编译器来验证变量的值是否是一个常量表达式。【代码风格:如果认定一个变量是一个常量表达式,声明的时候就使用constexpr】
constexpr int a = 20; // 20是一个常量表达式,所以a是常量表达式
constexpr int b = fn(); // 只有当fn是一个constexpr函数时,b才是一个常量表达式
字面值类型
目前为止,算术类型、引用和指针都属于字面值类型,而像自定义类、IO库、string类型则不属于字面值类型,也就不能被定义为constexpr。一个constexpr指针的初试值必须为nullptr或0,或者存储于某个固定地址中的对象
指针和constexpr
constexpr声明一个指针仅对指针有效,而不影响所指的对象。对于下面两个指针p和q,p是一个指向常量的指针,而q是一个常量指针,相当于int *const q = nullptr
,关键在于constexpr把定义的对象置为顶层const。
const int *p = nullptr;
constexpr int *q = nullptr;
例题
书上有一些考察引用和指针的例题,可以巩固一下上面的内容。
a是非法的,因为非常量引用不能绑定到字面值上【见3.1】。
b,c,d,e,g都是合法的
f是非法的,既然是定义一个常量指针,那么就必须初始化
2.30)
v2是顶层const,因为它是一个常量
p2是底层const,它不能改变指向对象的值,自己可以改变
p3即是顶层const也是底层const
r2是底层const2.31)
r1 = r2正确,虽然r2不能去改变v2的值,但是只赋值给r1不会有任何影响
p1 = p2 错误,p2是一个常量指针,不能修改v2的值,如果让一个普通指针p1指向p2,就说明可以通过p1去修改v2的值,产生矛盾。
p2 = p1正确,p2是底层const,自己可以改变。
p1 = p3 错误,与p1 = p2错误同理。
p2 = p3 正确。
五、处理类型
5.1类型别名
typedef
是传统定义类型别名的关键字,含有typedef的声明语句定义的不再是变量而是类型别名。C++11规定一个新的别名声明方法using
。
typedef int hh; // hh a = 12 就相当于 int a = 12;
using hh = int; // 和上面一样的效果
5.2auto类型说明符
平常我们要接受一个值必须要知道它的类型,然后声明一个变量去接收,如果使用auto
,编译器就可以根据值来推算类型,不需要我们再去声明类型,同时注意使用auto定义的变量必须有初始值。在使用auto声明多个变量时一定要保证这些变量的初始值是同类型的。
复合类型、常量和auto
在使用指针和引用时,实际操作的都是背后指向的对象,auto
就会根据该对象的类型来给出结果。auto会忽略掉顶层const,如下,b和c都直接忽略了a的顶层const,注意d对一个常量对象取址是一种底层const。
const int a = 1, &r = a;
auto b = a; // b是一个整数
auto c = r; // c是一个整数
auto d = &a; // 对一个常量对象取址是一种底层const,所以d一个指向整数常量的指针
如果想要保留顶层const,只需要在声明类型前加一个const就行。
const auto b = a; // b是一个整形常量
auto
用作引用时可以保留顶层const。
auto &e = a; // e是一个常量引用
auto f = 23; // 23是字面值,而非常量引用不能绑定到字面值上
const auto &j = 32; // j是常量引用,可以绑定字面值32
5.3decltype类型
使用auto
时编译器会根据表达式的类型推断出定义变量的类型并且用表达式结果初试化,如果我们只需要得到表达式的类型而不需要初始化,就可以使用C++11引入的decltype。
decltype(f()) sum = x; // sum的类型就是函数f的返回类型
decltype
在处理顶层const与auto
不同,如果decltype
使用的表达式是一个变量,那么直接返回该变量的类型(包括顶层const和引用)
const int a = 0, &r = a;
decltype(a) x = 0; // x是const int
decltype(r) y = x; // y是const int &
decltype和引用
如果decltype
表达式不是一个变量,则返回表达式结果对应的类型。下面的两个表达式中decltype(r)
结果是引用类型,但是r + 0
结果是一个具体值也就是int
类型。第二表达式中,decltype
使用解引用就会得到引用类型,所以最后的结果是int &
而不是int
。
int i = 42, *p = &i, &r = i;
decltype(r + 0) b; // b是int
decltype(*p) c; // 对p解引用得到的应是int &,所以这里错误,引用必须初始化
decltype
的结果与表达式的关系非常密切,对于decltype
所用表达式,变量名添上一对括号就有不同的结果,因为变量是一种可以赋值语句左值的特殊表达式,这样的decltype
的结果会是引用。
decltype(i) d; // d是一个未初始化的int变量
decltype((i)) c; // (i)是一个表达式,结果类型为引用,没有初始化将会报错
小结
decltype
和auto
的区别auto
用编译器计算变量的初始值来推断其类型,而decltype
只计算类型,而不计算表达式的值。decltype(a = b)
中,a的值并不会改变,只会得到一个int&类型。- 编译器推断出的
auto
类型有时候与初始值类型不完全一样,存在忽略顶层const,而decltype
则会保留。
达式的关系非常密切,对于decltype
所用表达式,变量名添上一对括号就有不同的结果,因为变量是一种可以赋值语句左值的特殊表达式,这样的decltype
的结果会是引用。
decltype(i) d; // d是一个未初始化的int变量
decltype((i)) c; // (i)是一个表达式,结果类型为引用,没有初始化将会报错
参考书籍
《C++ Primer 第五版》