What & How & Why

对象与基本类型

第 2 章笔记


初始化与赋值

  • 初始化:将某个值与某个对象关联起来
    • 在内存中开辟空间,保存数值
    • 在编译器中构造变量的符号表,将其与对应的值关联起来
  • 赋值:将某个已关联的变量关联到新的值
    • 不需要开辟新的空间和构造符号表
  • 初始化和类型可能会涉及类型转换
value
  • 字面值(literal):一眼就能看出来是值的,比如 10
  • 对象:变量、常量,或者引用。通过标识符定义。比如 x = 10x 就是对象
  • 值和对象都有类型

类型详述

  • 类型是编译器概念,是在编译阶段检测的。引入类型可以更好的描述程序,防止误用。
  • C++ 是强类型语言

类型描述的信息

  • 存储的尺寸:
    • 使用 sizeof() 测量
    • 大小与编译环境有关系,C++ 不做限制。
  • 取值空间:
    • 使用 std::numeric_limits 查找,范围为 $2^{bit length}$
    • 超过取值空间的值会溢出,跟二进制码有关
  • 对齐信息:CPU 读写数据会读到 cache 中。cache line 会有大小(32 / 64 位),也就是每次读取 32 或者 64 位数据。如果存储的数据位置处于两次读取位置的之间,那么需要两次读写才可以完整的读取存取的数据。这种情况被称为内存对齐不良。这种情况可以通过安排数据的位置进行改良。
    • 查看类型的对齐信息:alignof(type)
    • 对齐信息为了保证对其,会影响结构体的大小。
  • 类型可以执行的操作

#include <limits>
int main(int argc, char const *argv[])
{
    int x{0};
    std::cout << "Size of: " << sizeof(x) << std::endl; 
    std::cout << "Int min: " << std::numeric_limits<int>::min() <<std::endl;
    std::cout << "Int max: " << std::numeric_limits<int>::max() <<std::endl;
    std::cout << "Alignof int" << alignof(int) << std::endl;
    return 0;
}

类型

基本(内建)类型

  • 数值类型:
    • 字符类型(char, whcar_t, char16_t, char32_t):char 以外的 char 是针对其他编码的特殊字型的;比如 Unicode
    • 整数类型
      • 带符号整数:short, int, long, longlong
      • 无符号整数:unsigned,只能取正数

只有 unsigned 指代 unsigned int

  • 浮点类型
    • float, double, long double:double 的精度更高,但占用空间更大,计算的代价也更高
  • void 类型

复杂类型

  • 由基本类型组合生成的类型
未定义部分
  • C++ 中并未指定 char 是 signed 还是 unsigned
    • 带符号:[-128, 127]
    • 不带符号:[0, 255]
    • 解决方案:直接指定 char 是否有符号

unsigned char c1;
signed char c2;

  • 整数在内存中的表示方法未确定:
    • 不同机器之间传输需要确定保存方式为大端还是小端
  • C++ 中类型的大小不确定
    • 为了最大限度利用硬件的处理,但会影响程序的可移植性
    • C++11 中提供了固定大小的类型:比如 int32_t (32 bit, 4 byte) (fixed width integer types)

字面值

  • 字面值:在程序中表示为一个具体的数值或者字符串的值
    • 整数字面值(int): 十进制 / 8 进制 / 16 进制
    • 浮点字面值:
      • 普通写法:3.14
      • 科学计数法: e 后的数字代表了乘以 e 的多少次方(一定是 double)
    • 字符字面值:单引号内的内容,一定是 char 类型,比如转义字符 \n, \x4d
    • 字符串字面值:双引号内的内容,是 char[] 类型,以 \0 作为结尾。因此,Hello 的长度是 6 位,而不是 5 位。
    • 布尔字面值:true / false
    • 指针字面值:nullptrnullptr_t 类型
指定字面值的类型

// 指定 float 类型的字面值
float x = 1.3f;
// 指定一个 unsigned long long 类型的字面值
unsigned long long y = 2ULL;

用户自定义的字面值

C++ 11 允许用户自定义字面值的类型。该定义通过重写运算符 "" 进行定义。该功能可以方便我们定义一些单位(比如时间 / 重量)等等。下面的字面值代表了将接收到的 double 乘以 2 再转化为 int 的结果

int operator "" _customType(long double x)
{
    return (int)x * 2;
}

int main(int argc, char const *argv[])
{
    //using _customType as an literal type
    int x = 3.14_customType;
    std::cout << x << std::endl;
}
需要注意的是,User-defined literals 能接收的参数类型是有限的(引入的函数接收的类型是有限的)。

变量及其类型

变量的声明与定义

  • 变量的类型在首次声明的时候确定
  • 变量的定义和声明的区别:
    • 定义需要为 name 分配具体的空间
extern keyword

extern:实现的是定义只有一处,声明可以有多处的概念,用于外部文件引用实现文件中的定义。

为什么要使用 extern 关键字?

假设我们有两个文件需要一起编译。A 中使用调用 B 中已经定义的变量 x。如果没有 extern,那么会出现两种情况:

  • A 中没有定义 x,B 中定义了 x:此时会报编译错误,因为 x 对 A不可见
  • A 中定义了 x, B 中也定义了 x:此时会报链接错误,因为 A, B 中的 x 重定义

因此,如果希望在 A 中使用 B 中定义的 x,那么必须使用 extern 关键字。extern 会让编译器将定义视作外部文件变量在本地的声明,从而达到我们希望的效果:

//source.cpp
int x;

//main.cpp
extern int x;
int main()
{
    std::cout << g_x << std::endl;
}

extern 引入的变量如果进行初始化,那么就会从声明转化为定义。

变量的初始化和赋值

  • 初始化:构造变量之初为其赋予一个初始值
  • 赋值:改变值
初始化的方式
  • 默认初始化:
    • 算术类型:
      • 函数外部(全局):0
        • 全局变量只会在程序开始之初进行初始化,因此代价并不昂贵。
      • 函数内部:undefined
        • 为函数内部的变量设置默认值是有消耗的。在不清楚函数被调用多少次的情况下,提供该项服务是不利于性能的。 C++ 只会提供一块内存给该变量,但不保证该变量的内容。
    • 直接 / 拷贝初始化
    • List 初始化:强制检查类型

//direct init
int x(10);
//copy init
int x = 10;
//list init
int x{10};

(隐式)类型转换

  • promotion (小类型转化为大类型) 不会导致数据的损失
  • signed 和 unsigned 之间的转换:都会转换为 unsigned
  • bool 与整型的转换
    • 除了 0 以外的整数的 bool 值都是 1
    • bool 转换为整数结果为: 1(true) 和 0(false)
  • 隐式转换会出现在其他地方:
    • 条件判断时
    • 数值比较时(之前提到的 signed 和 unsigned 情况)
      • 牵涉不同类型数据比较,可以使用 std::cmp_XXX 库(C++ 20)

复合类型

指针

  • 间接类型:开辟了一个空间,存储的是指定内存序列的第一个地址
  • 可以指向不同的变量
  • 所有指针的尺寸都是相同的
指针的定义

//p points to the address of the val
int* p = &val;

指针的操作
  • 取地址 &
  • 解引用 *:不能访问没有内容的地址(0 地址)一般情况下会给一个 0 地址来保证错误的解引用一定会报错:

// 指针指向内容随机,解引用结果无法预测
int *p;
// null pointer
// 访问报错,但是内容稳定,方便查错
// 缺点是存在整型到指针的隐式转换
int *p = 0;
int *p = NULL;
// 防止隐式转换,C++ 11提供的 nullptr 指针对象
int *p = nullptr;

  • 自增自减 ++ / –:移动到下一个(上一个)指向的位置,距离取决于指针指向变量的类型
  • 指针相等==:判断两个两个指针是否指向同一个内存单元
  • 比较大小:只能在两个指针指向同一个数组时使用,判断两个指针的位置
void* 指针
  • 尺寸与普通指针相同,可用于验证传入的数据类型是否是指针(判断两个指针是否指向同样的对象)
  • 可以转换为任意类型的指针,通常会作为接口参数使用
  • 没有指向内容的占位大小信息,因此不能使用指针的算术运算
指向指针的指针

int x = 42;
int *p = &x;
int **pp = &p;

指针是对对象的间接引用:

  • 其好处在于可以减少程序传递的成本。这点在复制比较大的对象上尤其明显。其次,某些对象不可复制,因此只能通过复制其地址来达到复制的效果。
  • 但带来的缺点是读写成本高:因为读写之前都需要解引用
  • 指针可以修改指向对象的值:
    • 普通情况下,argument 传递给 parameter 是通过值(拷贝)传递,修改 parameter 的值不会影响 argument
    • 传指针时,argument 和 parameter 指向的是同样的内存地址,因此修改 parameter 也会导致 argument 的改变
指针与 bool 的隐式转换
  • 空指针(指向 0 地址) false
  • 反之为真
  • 通常使用 if (p) 方式来书写条件判断

引用

指针可能存在的问题:指向的对象为空或者非法。采用引用是另外一个较好的选择。引用是变量的别名

int x = 42;
//& is an type modifier here
int &refX = x;

  • 字面值没有地址,因此不能使用引用绑定字面值
  • 引用一旦绑定后,在声明周期类不可再更改(指针可以重新指向别的 name,引用不行)

int x = 0;
int y = 1;
int *ptr = &x;
//打印 ptr 结果相同,但实际上是不同的操作
//改变 x 所在内存的值为 y, 1 in x
*ptr = y;
//改变了 ptr 指向的位置,从 x 到 y,1 in y
ptr = &y;

从上面的例子来看,引用只能改变其指向内存地址中的内容,而不能改变其指向的位置。这要求引用在初始化的时候必须绑定一个实际的对象(的地址)

需要注意非法引用的问题:通过函数返回局部变量的引用可能出现问题。

  • 底层还是通过指针来实现
指针的引用
  • 指针是对象,因此存在指针的引用:
  • 引用不是对象,因此不存在引用的应用

int* &refPx = ptr;

常量与常量表达式

  • 常量:不可修改的对象
    • 可以被视作由编译器保证其不被修改的变量
    • 防止非法操作:比如条件判断时可以避免误写 = 代替 == 带来的问题
    • 编译器可以优化:常量不需要每次都判断(读取)值,但变量需要

常量指针与顶层常量

指针与常量结合时,需要考虑哪个部分不能被修改:

  • 指针本身
  • 指针指向的部分

如果限制的是指针本身,那么:

// 指针本身无法改变
int* const ptr = &x;
如果限制的是指针指向的内容:,那么:
//指针可以改指其他位置,不能通过该指针改变其指向的内容
const int* ptr = &x;

const 出现在 * 左边代表指针本身不能指向其他地方。

顶层常量
  • 顶层常量限制的是常量本身无法被修改
  • 底层常量限制的是指向的内容不能修改
底层常量的隐式转换
  • 读写 → 只读是可以的

// int * to const int *
int x = 4;
// &x 由 int* 转化为了 const int*,该指针之前可以对 x 进行读写,但现在只能读取 x
const int *ptr = &x;

  • 只读 → 可写是不行的

// const int * to int *
const int x = 4;
// error, &x 无法写 x,因此 ptr 也不能对 x 进行写操作
// low-level const 是有传递性的,如果之前只读,那么之后也必须只读(不管通过什么样的途径,比如赋值,参数传递等等)
int* ptr = &x;
// good
const int* ptr = &x;

常量引用
  • const int&:无法通过该引用修改其绑定的值
  • 主要是用于高效的函数传递参数:
    • 传递某个大对象,但又不想改变该对象,就用 const int&
    • 传递某些无法拷贝的对象
    • 相比指针,引用不需要判断参数的有效性(空指针什么的)
    • built-in 数据不需要使用引用,值传递更效率一些
  • 常量引用可以绑定字面值
    • 常量引用通常作为函数的 parameter,当函数采用字面值作为函数参数的时候,需要保证引用可以绑定该字面值,才能完成调用。

常量表达式

不同的常量确定的时机不同。比如下面的例子:

int y;
std::cin >> y;
// y1 是在运行期确定的(运行期常量)
const int y1 = y;
// y2 是在编译期确定的(编译器常量)
const int y2 = 3;
由于这样的问题,编译器在处理 y1y2 时,就会有不同的方式。比如以上述的变量做条件判断 if (y1 == 3)

  • y1 会以“期待运行期有输入的”方式进行汇编
  • y2 对编译器可见,那么 y2 == 3 一定为真。因此,编译器不会进行 if 的判断,而会直接执行该 if 下的代码段。(优化)

也就是说,编译器常量是可以被编译器优化的。这也是常量表达式的由来:C++ 11 中通过 constexpr 关键字,将常量直接声明为编译期常量。也就是说,上述的 const int y2 = 3 可以写为:

// y2 被显式的声明为编译期常量
// y2 的类型是 const int
// constexpr 是一种带指导意义的限定符(//specifier//),不属于类型声明的一部分
constexpr int y2 = 3;

常量表达式指针

指针也可以作为编译器常量。这种情况下,需要满足两个条件:

  • 指针是常量
  • 指针指向的内容也是常量

只有满足这两个条件,指针才能作为编译器常量对编译器可见。也就是说,这种情况下的指针类型是 const type* const

constexpr const int* ptr = nullptr;
//字符串
constexpr const char* str = "abc";

可以使用 std::is_same_v<type1, type2> 来判断类型是否相同。定义于 <type_traits> 头文件

类型别名与自动推导

类型别名

类型可以引入别名,便利使用(比如 size_t 实际上是一个 unsigned int)。别名的引入有两种方式:

  • typedef + type + alias
  • using alias = type

//myInt 是 int 类型的别名
//推荐使用 using
//别名实际上是 myCharArr, 但 typedef 的引用看起来非常 confusing
typedef char myCharArr[4];
using myCharArr = char[4];

类型别名与指针和引用
  • 当使用别名定义指针时,别名代表了指针的整体。此时如果使用 const 修饰别名,那么 const 修饰的是指针,也就是将指针定义为常量指针。换句话说,下面两者等价:

using intP = int*;
int x = 3;

//别名定义
const intP constIntPAlias = &x;
//等同于 top-const pointer
int* const constIntP = &x;

  • 不能使用别名来创建引用的引用

类型推导

C++11 允许使用 auto 关键字让编译器自动通过初始化表达式的结果推断变量的类型:

auto x = 3.5 + 15l;

auto
  • auto 推导出的类型是强类型(定义时已经确定)
  • 因为需要初始化表达式推导类型,因此自动推导下,表达式必须初始化
  • auto 可能会导致类型退化

int x1 = 3;
int& ref = x1;
//类型退化,ref 作为右值,此处由 int& 退化为 Int, 而不是 int&
int y = ref;
//auto 导致类型退化, ref2 是 int 类型
auto ref2 = ref;

  • const / constexprauto 组合会推导出常量 / 常量表达式类型

//const int 
const auto x1 = 3;
//const int& 
const auto& x2 = 3;

const int x3 = 3;
//const int
const auto y1 = x3;
//int (top-const 的退化)
auto z = x3;

  • auto 加引用会避免退化

//不会退化,const int&
auto& y2 = x3;

  • 数组的推导会退化为指向数组首元素的指针

int x[3] = {1,2,3};
//int*
auto x1 = x;
//加引用不会退化:int(&)[3] 
auto& x2 = x;

decltype

decltype 获取一个表达式,并返回表达式的类型。decltype 与 auto 的区别在于,decltype 不会产生类型退化

  • decltype(val):val 代表 entity(变量名称),那么 val 是什么类型,那么返回的就是什么类型

//x is an variable name
int x = 3;
//int
decltype(x);
//注意加括号的形式,(x) 是表达式, x 是变量名
//int&
decltype((x));

  • decltype(exp(r-value)) :如果表达式是非变量名称的表达式,且为右值,那么返回的是表达式评估后的类型:

int x = 3;
int& y1 = x;
//int
auto y2 = y1;
//int&
decltype(y1) y3 = y1;

  • decltype(exp(l-vaule)):如果表达式为非变量名称的左值(通常带运算符),返回的类型会自动加上一个引用

int x = 3;
int* ptr = &x;
//*ptr 是包含了解引用操作符的表达式:此处是 l-value,此处得到的是 int& 
decltype(*ptr);

  • decltype(auto) [C++14]:用于简化 decltype 的使用

//非常繁琐的写法
decltype(3.5+15l) x  = 3.5 + 15l;
//C++ 14 的写法
// 用 auto 来代表繁琐的表达式,并不会导致退化
// 编译器会将 auto 替换为赋值运算符右边的内容
decltype(autol) x  = 3.5 + 15l;

  • concept auto [C++20]:任何 concept 都会包含一系列类型,这些类型的共同特征都可以用 concept 表示。比如 int, long 都可以表示为 std::intergal
    • concept auto 让自动推导受 concept 的范围限制,比如让 auto 只能推断出整数类型的类型:

#include <concepts>
int main()
{
    //int
    std::integral auto z = 3;
    //error, 3.5 is not an integral
    std::integral auto z = 3.5;
}

域和对象生命周期

域 Scope

域代表了程序的一部分,域中的 name 有唯一的含义

  • 全局域:程序最外部的域,全局对象
  • 块域:大括号限定的域,局部对象
  • 其他类型的域:Namespace, class 等等

域可以进行嵌套,内部域中的 Name 会掩盖外部域中的 Name:

int x = 3;
int main()
{
   int x = 4;
   //call local x
   std::cout << x << '\n';
}

对象生命周期

生命周期指对象从被初始化到被销毁的区间。

  • 全局对象:生命周期为程序的运行期
  • 局部对象:起始于初始化,结束于域的执行完成