2.1 基本内置类型
这一章我觉得应该在一开始就讲的,但是这本书放在了第二章,不过问题不大,我们需要了解这些我们一直在用的类型分别是什么。
2.1.2 算数类型
首先基础的类型如下表
其中,主要分为bool:真(true)或者假(false),此时的bool不再是c语言中的BOOL是一个宏定义而是真真实实的数据类型;整型家族:short
int
long
long long
;浮点型家族float
double
long double
和字符型char
wchar_t
char16_t
char32_t
等。
而在其它分类中,整形还能分为带符号的signed
和无符号的unsigned
,在一些代码中,不存在负数的情况可以选择无符号整型来实现更大的位宽,一般的,有符号数的前缀signed
不需要写出来。
2.1.3 类型转换
注意:从这里开始就要注意了,含有无符号类型的表达式不能和有符号类型的变量混用,不然会出现异常。
举个例子
#include <iostream>
int main()
{
int a = -20;
unsigned int b = 10;
std::cout << a + a << std::endl;
std::cout << b + b << std::endl;
std::cout << a + b << std::endl;
return 0;
}
此时可以看到,前两个输出分别是-40和20很正常,但是第三个输出却不是-10,而是4294967286,这个数字哪里来的呢,其实是因为当整型和无符号整型相加的时候,会被类型转换为无符号整型,其中负号跟着的数就变成了在无符号整型中最大值-负号的数。
其他的自动类型转换还有类似于
#include <iostream>
int mian()
{
int a = 0;
double b = 1.12345;
std::cout << a + b << std::endl;
a = b;
std::cout << a << std::endl;
return 0;
此时第一个输出是正常的1.12345,因为在相加时会自动将简单类型转化为复杂类型。
但是当你把一个高精度的数据写入低精度的数据,虽然不会报错,但是会损失精度
2.1.3 字面值常量
一个形如42的值被称作字面值常量 (literal) 这样的值一望而知。每个字面值常量都对应一种数据类型,字面值常量的形式和值决定了它的数据类型。
这一节讲的很晦涩难懂,我们不去看他的书面解释,用自然语言来理解就是,你看的见的值叫字面值,如果他不是变量(不可改变)则为常量。
举个例子a = 20
,其中,20就是字面值常量,又或者"Hello,World!"也是一个字符串型的字面值常量,此时要注意几种字面值常量的写法。
字面值常量类型 | 写法 |
---|---|
整型(10进制数) | 直接写 |
整型(8进制数) | 在前面加0(如012就是8进制中的10) |
整型(16进制数) | 在数字前加0x |
整型(2进制数) | 在数字前加0b |
字符型 | 用单引号’'包裹 |
字符串型 | 用双引号""包裹 |
其中,字符类型和字符串类型最大的区别是,字符串类型会在结尾补一个空字符\0
, 因此,字符串字面值的实际长度要比它的内容多1。
2.1.4 转义序列
这个直接看表,用过C语言就会知道了。
2.1.5 指定字面值的类型
这个也没什么好说的,看表
2.2 变量
变量提供一个具名的、可供程序操作的存储空间。C++中的每个变量都有其数据类型,数据类型决定着变量所占内存空间的大小和布局方式、该空间能存储的值的范围,以及变量能参与的运算。对C++程序员来说“变量(variable)”和“对象(object)”一般可以互换使用。
2.2.1 变量的定义
一般的,变量由这几部分组成:变量类型 变量名 (可以一个或多个以逗号隔开)变量初始化值(以=初始化)分号结束
其中有个小知识很有意思
变量初始化
虽然变量初始化和赋值都是用=
,但是其意义不同,赋值是指使用一个值覆盖原有值,初始化是为一个没有值的变量创造一个值。
默认初始化
如果定义变量时没有指定初值,则变量被默认初始化 (default initialized),此时变量 被赋予了 “默认值”。默认值到底是什么由变量类型决定,同时定义变量的位置也会对此有影响。
如果是内置类型的变量未被显式初始化,它的值由定义的位置决定。定义于任何函数体之外的变量被初始化为0。
又到了小故事时间
2. 2. 2 变量声明和定义的关系
为了允许把程序拆分成多个逻辑部分来编写,C+语言支持分离式编译 (separate compilation)机制,该机制允许将程序分割为若干个文件,每个文件可被独立编译。
为了支持分离式编译,C++语言将声明和定义区分开来。声明(declaration)使得名字为程序所知,一个文件如果想使用别处定义的名字则必须包含对那个名字的声明。而定义 (definition )负责创建与名字关联的实体。
如果想声明一个变量而非定义它,就在变量名前添加关键字extern,而且不要显式地初始化变量
#include<iostream>
int main()
{
extern int j;//声明j
int i;//声明并定义i
return 0;
}
注意:
- 任何包含了显式初始化的声明即成为定义。
- 在函数体内部,如果试图初始化一个由extern 关键字标记的变量,将引发错误。
- 变量能且只能被定义一次,但是可以被多次声明
这里补充一个关键点
2.2.3 标识符
C++ 的标识符 (identifier) 由字母、数字和下画线组成,其中必须以字母开头。标识符的长度没有限制,但是对大小写字母敏感。
变量命名规范
- 标识符号要体现其实际意义
- 变量名首字母尽量用小写字母
- 用户自定义类名首字母一般大写
- 如果标识符由多个单词组成则其单词与单词之间的区分应该更加明显
- 变量名不能与保留字冲突
2.2.4 名字的作用域
首先我们要了解作用于的概念
作用域(scope)是程序的一部分,在其中名字有其特定的含义 。C++语言中大多数作用域都以花括号分隔。
同一个名字在不同的作用域中可能指向不同的实体。名字的有效区域始于名字的声明语句,以声明语句作用域的末端为结束。
全局作用域
名字main 定义于所有花括号之外,它和其他大多数定义在函数体之外的名字一样拥有全局作用域 (global scope)。一旦声明之后,全局作用域内的名字在整个程序的范围内都可使用。
块作用域
其他的名字定义在main中或者其他函数中,它的生命周期就随着main或者其他函数的声明而开始,以其声明的作用域结束而结束。
嵌套的作用域
作用域能彼此包含,被包含(或者说被嵌套)的作用域称为内层作用域(innerscope),包含着别的作用域的作用域称为外层作用域(outerscope)。
作用域中一旦声明了某个名字,它所嵌套着的所有作用域中都能访问该名字。同时,允许在内层作用域中重新定义外层作用域已有的名字
示例程序
#include <iostream>
int main()
{
int a = 1;
for(int i = 1 ;i < 10;i++)
{
int a = i;
std::cout << a << std::endl;
}
std::cout << a << std::endl;
return 0;
}
此事可以看到循环内的a输出1到9,但是循环外的a还是输出1,这说明一件事,就是循环内对循环外声明的值赋值不会作用到循环外。
2.3 复合类型
2.3.1 引用
写在前面哈,c11确实有另外一种引用(右值引用),本章不做说明,在后续C11中会细讲。
引用说成人话就是给变量起别名,原理很像是定义宏。
一般在初始化变量时,初始值会被拷贝到新建的对象中。然而定义引用时,程序把引用和它的初始值绑定(bind)在一起,而不是将初始值拷贝给引用。一旦初始化完成,引用将和它的初始值对象一直绑定在一起。因为无法令引用重新绑定到另外一个对象,因此引用必须初始化。
几个特点记一下
- 为引用赋值本质上就是为原对象赋值
- 引用本身不是一个对象,因此无法生成引用的引用
- 允许在一条语句中定义多个引用,但是必须以
&
开头
示例代码
#include <iostream>
int main()
{
int a = 1;
int &b = a;
b = 10;
std::cout << a << std::endl;
return 0;
}
2.3.2 指针
这个是重中之重!c++很多特性都是依靠它运作的!
看看书中的巨大感叹号
就可以看出指针是多么重要了
指针(pointer)是“指向(pointto)”另外一种类型的复合类型。与引用类似,指针也实现了对其他对象的间接访问。然而指针与引用相比又有很多不同点。其一,指针本身就是一个对象,允许对指针赋值和拷贝,而且在指针的生命周期内它可以先后指向几个不同的对象。其二,指针无须在定义时赋初值。和其他内置类型一样,在块作用域内定义的指针如果没有被初始化,也将拥有一个不确定的值。
定义指针类型的方法将声明符写成*d
的形式,其中d是变量名。如果在一条语句中定义了几个指针变量,每个变量前面都必须有符号*
指针存放某个对象的地址,要想获取该地址,需要使用取地址符(操作符&)
指针的值(即地址)应属下列4种状态之一:
- 指向一个对象。
- 指向紧邻对象所占空间的下一个位置。
- 空指针,意味着指针没有指向任何对象。
- 无效指针,也就是上述情况之外的其他值。
试图拷贝或以其他方式访问无效指针的值都将引发错误。编译器并不负责检查此类错误,这一点和试图使用未经初始化的变量是一样的。访问无效指针的后果无法预计,因此程序员必须清楚任意给定的指针是否有效。
可能同学们还听过另一个词叫“野指针”,这又是什么东西呢
野指针(dangling pointer)
是指指向已经释放或者无效的内存地址的指针。在程序中,当一个指针被分配了一块内存空间,然后这块内存被释放或者释放后重新分配给其他变量,但原指针并未修改,此时这个指针就成为了野指针。
野指针的存在可能导致程序崩溃、数据损坏或者未定义的行为,因为它们可能会导致对无效内存地址的访问(本质上就是一个无效指针)。
如果指针指向了一个对象,则允许使用解引用符(操作符)来访问该对象*
做一个小拓展
空指针(nullpointer)不指向任何对象,在试图使用一个指针之前代码可以首先检查它是否为空。
说到空指针,就得说一下空指针的制作方法。
#include <cstdlib>
int main()
{
int *p = nullptr;
int *q = NULL;
return 0;
}
nullptr
又是一个C11的新特性,它允许将被转化成各种类型的空指针,而NULL
则由cstdlib
头定义,它本质上就是一个为0
的宏
这是书中给出的建议
赋值和指针
指针和引用都能提供对其他对象的间接访问,然而在具体实现细节上二者有很大不同,其中最重要的一点就是引用本身并非一个对象。一旦定义了引用,就无法令其再绑定到另外的对象,之后每次使用这个引用都是访问它最初绑定的那个对象。指针和它存放的地址之间就没有这种限制了。和其他任何变量(只要不是引用)一样,给指针赋值就是令它存放一个新的地址,从而指向一个新的对象。
void*指针
void*
指针可以存储任何变量类型的地址,但是我们不能直接操作void*指针所指的对象,因为我们不清楚里面是什么东西
2.3.3 理解复合类型的声明
这一部分是最容易令人头大的部分,常见的复合对象有:指针的指针,指针的引用,到后面可能还有指针函数,函数指针,指针常量,常量指针等,本章中只讲指针的指针,指针的引用这两点。
指针的指针
这个很简单,让阿妮娅来教你
很简单嘛,指针的指针就是指向另一个指针的指针
一个demo
#include<iostream>
int main()
{
int a = 20;
int *p = &a;
int **q = &p;
std::cout << "q:" << q << " &p:" << &p << std::endl << "*q:" << *q << " p:" << p << " &a:" << &a << std::endl << "*p:" << *p << " a:" << a << std::endl;
return 0;
}
看输出
可以很容易理解指针的指针,指针,和指针指向对象的关系
指针的指针存的值是指针的地址,指针的指针指向的值是指针的值是指针指向变量的地址。
指针的引用
#include <iostream>
int main()
{
int i = 1;
int *p;
int *&r = p;
r = &i;
std::cout << *p <<std::endl;
return 0;
}
此时给r赋值i的地址就是给p指向的对象赋值,因此输出1。
2.4 const限定符
这个没啥好说的,就是常量,但是不意味着他就什么都干不了,大多数运算,转换都是可以的(只要不改变其值)
#include <iostream>
int main()
{
const int a = 12;
int b = a;
std::cout << a + 10 << std::endl;
std::cout << b << std::endl;
return 0;
}
注意:
const
限定的对象必须初始化- 默认状态下
const
对象仅在文件内有效,如果我们想让这类const
对象像其他(非常量)对象一样工作,也就是说,只在一个文件中定义const
,而在其他多个文件中声明并使用它。如果想只定义一次,只需要对于const
变量不管是声明还是定义都添加extern
关键字就可以了。
2.4.1 对const的引用
可以把引用绑定到const对象上,就像绑定到其他对象上一样,我们称之对常量的引用(referencetoconst)。与普通引用不同的是,对常量的引用不能被用作修改它所绑定的对象
初始化和对const的引用
当声明一个对const的引用时,有以下几个要点需要注意:
-
初始化:对const的引用必须在声明时进行初始化。这是因为引用一旦被声明,就必须指向一个已经存在的对象。初始化确保了引用在声明时就有一个有效的目标对象。
-
类型匹配:引用的类型必须与其引用的对象的类型相匹配。对于const引用,这意味着你不能将一个非常量类型的对象引用为const类型,反之亦然。但是,可以将一个const对象引用为一个非const类型,只要不通过这个引用修改对象。
-
常量引用的初始化:对于常量引用,C++允许使用任意表达式作为初始值,只要这个表达式的结果可以转换成引用的类型。这意味着你可以将一个非常量对象、字面值或任何表达式绑定到一个const引用上。编译器会隐式地创建一个临时对象(临时量),并将这个临时对象绑定到引用上,而不是直接绑定到原始表达式。
-
尽管const引用本身不允许修改其指向的对象,但对象本身可能是可修改的。这意味着你可以通过其他非const引用或直接对对象进行修改。
下面是一些示例:
#include <iostream>
int main()
{
const int &r1 = i; // 允许将const int&绑定到一个普通int对象上
const int &r2 = 42; // 正确:r2是一个常量引用,绑定到字面值42
const int &r3 = r1 * 2; // 正确:r3是一个常量引用,绑定到r1 * 2的结果
int &r4 = r1 * 2; // 错误:r4是一个普通的非常量引用,不能绑定到临时量
int i = 42;
int &r1 = i;
const int &r2 = i;
r1 = 0; // 通过非常量引用修改i的值
r2 = 0; // 错误:尝试通过常量引用修改i的值
return 0;
}
2.4.2const指针
常量指针(constpointer)必须初始化,而且一旦初始化完成,则它的值(也就是存放在指针中的那个地址)就不能再改变了。把*放在const关键字之前用以说明指针是一个常量,这样的书写形式隐含着一层意味,即不变的是指针本身的值而非指向的那个值
于是我们就可以理解为,常量指针,指针本身不变,但是指向的值可变。
#include <iostream>
int main()
{
int i = 200;
int *const p = &i;
*p = 10;
std::cout << *p <<std::endl;
return 0;
}
2.4.3 顶层const和底层const
顶层const如前所述,指针本身是一个对象,它又可以指向另外一个对象。因此,指针本身是不是常量以及指针所指的是不是一个常量就是两个相互独立的问题。用名词顶层const(top-levelconst)表示指针本身是个常量,而用名词底层const(low-levelconst)表示指针所指的对象是一个常量。
听起来是不是很复杂,但其实这与指针常量和常量指针的概念相似。
简单来说,顶层 const 表示被 const 修饰的对象本身是常量,而底层 const 表示被 const 修饰的对象所指向/引用的值是常量。
因此,顶层 const 可以是任何类型的常量,而底层 const 通常涉及指针或引用。
举个例子
#include <iostream>
int main() {
int x = 10;
const int* ptr1; // ptr是一个指向常量整数的指针
ptr1 = &x; // 允许,可以改变指针所指向的地址
std::cout << *ptr1 << std::endl; // 输出 10,可以读取指针指向的对象的值
// *ptr1 = 20; // 错误!无法通过ptr修改所指向的整数的值,因为ptr指向的对象是常量
int* const ptr2 = &x; // ptr是一个指向整数的常量指针
// ptr2 = &y; // 错误!无法改变指针本身的地址
*ptr2 = 20; // 允许,可以通过ptr修改所指向的整数的值
std::cout << *ptr2 << std::endl; // 输出 20,可以读取指针指向的对象的值
return 0;
}
2.4.4 constexpr和常量表达式
常量表达式(constexpression)是指值不会改变并且在编译过程就能得到计算结果的表达式。显然,字面值属于常量表达式,用常量表达式初始化的const对象也是常量表达式。
constexpr变量
在C11的标准下,允许将变量声明为constexpr类型以便由编译器来验证变量的值是否是一个常量表达式。声明为constexpr的变量一定是一个常量,而且必须用常最表达式初始化
指针和constexpr
在constexpr声明中如果定义了一个指针,限定符constexpr仅对指针有效,与指针所指的对象无关
2.5 处理类型
2.5. 1 类型別名
它的作用是生成一个某种类型的同义词,传统的方法是使用关键字typedef,比如
typedef double wages; //wages是double的同义词
typedef wages base, *p; //base是double的同义词,p是double*的同义词
新标准规定了一种新的方法,使用别名声明(aliasdeclaration)来定义类型的别名
usingSI = Sales_item; //SI是Sales_item的同义词
有人会觉得,这不是脱裤子放屁吗,但是显然,他在某些情况尤其好用,比如我们在定义字符数组char*
时,我们使用typedef char* pstring
语句将其取了个pstring
的别名,这样当我们在调用pstring
的时候本质上就在调用char*
。
很多人会想了,那这个和define
有什么区别,首先define时宏定义,不止可以定义类型,还可以定义类数字,字母等
typedef
定义的别名会进行类型检查,因为他被认作原类型的别名,而define
只会简单替换而不触发类型检查
2.5.2 auto类型说明符
这一章我原本以为会一起拖到c11专题的时候讲,没想到这个时候就引出来了。
首先,auto我们从字面意思上可以理解为一个自动的类型,它能根据你给这个变量的赋值来确定变量类型。
举个例子 如果我输入auto a = 0;
此时a
的类型会因为0
而变成int
。
这里就要引入第一个知识点了,auto
类型的变量定义的时候必须赋值,如果不赋值会报错未提供初始值。
第二个知识点是,auto的用法也可以和其他数据类型一样,一行定义多个变量,但必须是一个类型的。
举个例子
#include <iostream>
int main()
{
auto a = 1 , *b = &a;
auto d = 1 , e =2.2;
return 0;
}
在这种情况下,第一个auto
被成功赋值,因为*
和&
都是从属于某个声明符,基础数据类型还是int
但第二个一个是整型一个是浮点型。
第三个是上篇讲过的顶层const和底层const的拷贝
如果是顶层const
(本身元素是常量)在用auto
进行拷贝的时候会默认忽略其const
属性;而如果想让其具有常量属性,则需要用底层const
。
我知道这看起来很懵圈,下面举一个例子你就明白了。
#include <iostream>
int main()
{
const int a = 1; // 顶层 const
auto b = a;
b = 2;
std::cout << b << std::endl;
const auto* ptr = &a; // ptr 是一个指向常量 int 的指针(底层 const)
// *ptr = 5; // 这里尝试通过 ptr 修改所指向的值会编译错误,因为 *ptr 是一个底层 const
return 0;
}
2.5.3 decltype类型指示符
这个也是C11独有的,和上面的auto
相辅相成,如果你无法确定一个函数的返回值类型,但是你却需要接收这个函数的返回值作为计算对象应该怎么办呢?一种方法是直接用上文的auto
,使接收函数类型自动计算,第二种方法就是decltype
了。
它的用法是:decltype(fun ()) 函数名
其中,该函数的函数类型就是fun()
的返回值类型
而在处理顶层const
方面:decltype
处理项层const
和引用的方式与auto
有些许不同。如果decltype
使用的表达式是一个变量,则decltype
返回该变量的类型(包括项层const
和引用在内)。
需要指出的是,引用从来都作为其所指对象的同义词出现,只有用在decltype
处是一个例外。
如果decltype
使用的表达式不是一个变量,则decltype
返回表达式结果对应的类型。
另一方面,如果表达式的内容是解引用操作,则decltype
将得到引用类型。正如我们所熟悉的那样,解引用指针可以得到指针所指的对象,而且还能给这个对象赋值。
decltype
和auto
的另一处重要区别是,decltype
的结果类型与表达式形式密切相关。有一种情况需要特别注意:对于decltype
所用的表达式来说,如果变量名加上了一对括号,则得到的类型与不加括号时会有不同。如果decltype
使用的是一个不加括号的变量,则得到的结果就是该变量的类型:如果给变量加上了一层或多层括号,编译器就会把它当成是一个表达式。变量是一种可以作为赋值语句左值的特殊表达式,所以这样的decltype
就会得到引用类型。
举个例子
#include <iostream>
int main() {
int x = 5;
decltype((x)) y = x; // 声明一个引用y,类型是变量x的引用类型
decltype(x) &z = x; // 声明一个与变量x类型相同的引用y
y = 10; // 修改y也会修改x的值
std::cout << "x: " << x << std::endl; // 输出为10
return 0;
}
此处y和z都是引用,不同的是,因为(x)
是有括号的被视作表达式因此传递的就是引用了。
2.6 自定义数据结构
从最基本的层面理解,数据结构是把一组相关的数据元素组织起来然后使用它们的策略和方法。C++语言允许用户以类的形式自定义数据类型,而库类型 string、 stream 、ostream 等也都是以类的形式定义的。
这个我觉得这一章节讲还是过于早了,但是不妨碍我们稍作了解。
几个关键的点我提一下
- 一般来说,最好不要把对象的定义和类的定义放在一起。这么做无异于把两种不同实体的定义混在了一条语句里,一会儿定义类,一会儿又定义变量,显然这是一种不被建议的行为。
- 很多新手程序员经常忘了在类定义的最后加上分号(这条在书中是被加上⚠️标识的)
- C++11新标准规定,可以为数据成员提供一个类内初始值(in-class initializer)。创建对象时,类内初始值将用于初始化数据成员。没有初始值的成员将被默认初始化
- 为了确保各个文件中类的定义一致,类通常被定义在头文件中,而且类所在头文件的名字应与类的名字一样。
- 头文件一旦改变,相关的源文件必须重新编译以获取更新过的声明。
这里还涉及到一个点叫预处理器:
确保头文件多次包含仍能安全工作的常用技术是预处理器(preprocessor),它由C++语言从C语言继承而来。预处理器是在编译之前执行的一段程序,可以部分地改变我们所写的程序。之前已经用到了一项预处理功能#include,当预处理器看到#include标记时就会用指定的头文件的内容代替#include。
C++程序还会用到的一项预处理功能是头文件保护符(headerguard),头文件保护符依赖于预处理变量。预处理变量有两种状态:已定义和未定义。#define指令把一个名字设定为预处理变量,另外两个指令则分别检查某个指定的预处理变量是否已经定义:#ifdef当且仅当变量已定义时为真,#之Endef当且仅当变量未定义时为真。一旦检查结果为真,则执行后续操作直至遇到#endif指令为止。使用这些功能就能有效地防止重复包含的发生。
结构如下
#ifndef 类名_H
#define 类名_H
类实现
#endif 类名_H
在新版本中,为了防止头文件被重复定义,我们可以使用更加新的函数#pragma once
也能有一样的效果。
写在后面
终于写完了,没想到这一次第二章写了我一万两千字,两天才整理完,当然,这也意味着这一章节对于后面几节尤为重要,类型是c++的基础,大家要多加练习。
在最结尾处,同学们可以考虑使第一章我让你们做的计算器具备小数计算(浮点运算)和除法功能,并封装成类可以被其他类调用。