云计算百科
云计算领域专业知识百科平台

深入浅出理解计算机核心知识系列【C++语言特性合集-语法基础与拓展篇】

本人志在持续更新计算机系统、计算机网络、C++语言的核心知识点的系列合集,以易懂、全面的方式讲解底层知识。对于正在准备面试八股的朋友来说,本系列涵盖了本人面试中遇到的所有考点以及许多相关拓展知识,读完后能帮助你从容面对大部分面试拷打;对于想要深入学习计算机知识的朋友来说,本系列比较系统地介绍了操作系统和网络等重点内容,也举了不少例子,大大有助于你从底层的视角去理解计算机系统。
先说明,本系列恐怕不是计算机小白或是想速通期末的朋友们的目标,它需要一定系统和语言基础,也并不是面向教材和考试要求去讲解,所以更适合那些实操过代码、了解一些计算机系统知识、并且想要深入底层和扎实基础的朋友们去耐心学习。如果你是这样的人,欢迎阅读该系列文章,并分享自己的理解或提出文章中的模糊、错误的地方(不排除有)。
想要阅读系列中其他内容或想要持续关注本系列更新可移步:https://github.com/feiyangyang11/Cpp-Core-CS-Interview-Guide.git。


C++ 语法基础与拓展

本篇既讲述了一些基本的 C++ 语法,又从内存布局的视角拓展了一些语法的底层机制

类型与表达式

C++ 类型决定一个实体/表达式在 C++ 语言中的语义,表达式是一段可以被求值的代码

它们是 C++ 大厦的基石,同时有着丰富的含义,非常值得作为我们深入认识 C++ 的第一步

C++ 基础类型

主要有 void、空类型、整型、浮点型三类,不同类型占用不同大小的内存、有着不同的计算方式

除了基础类型外,程序员还可以自己定义类型,这里不展开,后面会讲

fundamental types//大小主要以64位系统为基准
├── void//表示无具体类型

├── nullptr_t

├── integral types
│ ├── bool//一字节
│ ├── char / signed char / unsigned char//一字节
│ ├── wchar_t//一字节
│ ├── char8_t//一字节
│ ├── char16_t//二字节
│ ├── char32_t//四字节
│ └── short / int / long / long long //二字节~八字节

└── floatingpoint types
├── float//四字节
├── double//八字节
└── long double

表达式

表达式是一段可以被求值的代码

比如:

10
a
a + b//计算表达式
func()//函数调用表达式
a = 10//赋值表达式,表达式的值就是 a 被赋值后的值

表达式的类型

表达式同样具有类型,它的类型取决于表达式组成成员的类型

比如 a+b,如果 a 和 b 类型不同,C++ 会优先考虑转换类型,将表达式中所有子表达式先转换成同一类型,转换规则是小类型适配大类型。若 a 是 int(4字节),b 是 double(8字节),就会把 a 转换成 double,然后整个表达式的结果类型就是 double

隐式类型转换

表达式存在隐式类型转换,有一些常见的特殊表现如下:

整数提升:char + char 或 short + short,会先将两个数都变成 int 后再相加,结果也是 int

signed / unsigned 混合:对 signed 和 unsigned 进行比较运算时,signed 会被转换成 unsigned int,可能导致值变大,结果错误

size():vector、string 等的 size()函数返回的都是无符号整型,这点要注意

表达式的性质

表达式除了有类型,还有性质。主要分为左值、右值

类型相同,不代表表达式性质相同

  • 左值指的是能被稳定取地址、生命周期确定、有名字的表达式,比如 int x
  • 右值指的是不能被稳定取地址、生命周期不确定、匿名的表达式,比如 x + 1、10、new object()

左值、右值又可以被细分为一下几种

expression
├── glvalue//左值
│ ├── lvalue//纯正的左值。能被稳定取地址、生命周期确定、有名字,我们代码中定义的变量 int x; 就属于这种
│ └── xvalue//将亡值。也能被稳定取地址、生命周期确定、有名字,但是允许资源被转移出去,典型将亡值如 std::move(x)

└── rvalue//右值
├── xvalue//将亡值。
└── prvalue//用来产生值/初始化对象,如 42、a + b

左值、右值常见的作用就是搭配引用等进行资源的转移和复用,后面再详细展开

指针

指针是一个栈上的变量,它保存另一个对象的内存地址

如int* p=&a;表示变量 p 保存了变量 a 的内存地址(栈上或堆上)

因此通过指针变量可以访问它指向的变量

64 位环境中,指针变量本身一般是占 8 字节大小

解引用

*是解引用符号,*p表示指针指向的对象本身

或者理解成指向的那片内存地址,修改*p就表示直接修改那片内存中的数据,自然就修改了该变量

指针变量的类型描述了“这个地址所指向对象的类型”,用于限制访问这片内存时怎么解释这片内存

int*p=&a;表示用*p访问a所在内存时,将它解释为 int 类型,读 4 个字节

悬空指针与空指针

悬空指针,意思是指针变量没有绑定有效的地址,或绑定了对象地址后对象被销毁,此时指针变量保存的就是垃圾值

空指针,nullptr,值为 0,是进程虚拟地址的起址,禁止访问,解引用会报错

引用

引用是已有对象的别名。它不在内存上创建新的值对象,而是让另一个名字绑定到原对象,如下:

a ─┐
├── 同一个 int 对象
ref ─┘

和指针的实际意义差不多,本质都是访问同一片内存区域,不过访问时不需要解引用

左值引用T&

创建一个引用变量,绑定到一个左值表达式

int a = 10;
int& ref = a;

注意:

左值引用不能绑定到右值,如int& ref = 10;,但 const T& 可以绑定右值(这种情况下会发生生命周期延长)

可以用常量左值引用绑定到左值,如const int& ref = a;,表示以只读方式引用对象

右值引用T&&

创建一个引用变量,绑定到一个右值表达式

int&& ref = 10;

右值引用主要服务于移动语义、完美转发,后面细讲

引用折叠与完美转发

C++ 规定了一套规则,用来处理“引用和引用叠在一起”的情况,因为**模板类型推导、typedef、using 等场景下,编译器内部可能组合出这种“引用的引用”**这种情况。规定两层引用碰到一起,最后应该变成什么,这就是引用折叠

模板推导/别名展开后出现引用的引用时,规则是:

T& & → T&
T& && → T&
T&& & → T&
T&& && → T&&
//只要有一个是左值引用,折叠后就是左值引用
//只有两个都是右值引用,折叠后才是右值引用

以 std::move 为例讲解

template<class T>
constexpr std::remove_reference_t<T>&& move(T&& t) noexcept {
return static_cast<std::remove_reference_t<T>&&>(t);
}
//假设外部传入的是 int x;它的类型是 int,类别是左值
//参数进入 move,因为把左值传给转发引用 T&&,所以模板推导得到 T = int&
//根据引用折叠规则把 T&& & 折叠成 T&,所以可知此时 t 类型是左值引用 int&,类别是左值
//remove_reference_t 将引用去掉,得到 int 类型,然后此处就变成了 return static_cast<int&&>(t);
//也就是将 t 强转成了右值引用 int&& 类型,所以 move 返回的就是一个右值引用

什么是完美转发?就是把一个参数“原封不动地”继续传给下一个函数,既保留它的类型,也保留它是左值引用还是右值引用,依靠的是模板类型推导 + 引用折叠 + std::forward,比如:

//写一个包装函数
template<class T>
void Wrapper(T&& value)
//由于引用折叠的存在,保证了传入的 value 如果原来是左值那在这里是左值引用,如果原来是右值在这里是右值引用
{
Func(std::forward<T>(value));
}
//但是 value 本身是个左值,所以再往下直接 Func(value),那么在下一层参数就一定会被推导为左值引用,这不符合完美转发
//所以引入 std::forward<T>(value)。它内部做的是,根据 T 去还原引用状态
//这里以外部调用 ① std::string s;Wrapper(s); 和 ② Wrapper(std::string("hello")); 为例,forward 的源码如下:
template<class T>
T&& forward(std::remove_reference_t<T>& value)
{
return static_cast<T&&>(value);
}
//1. 如果 value 是左值,那么推导为 T = std::string&
//在 forward 内 value 被强转为 std::string& &&,折叠为 std::string&,即返回一个左值引用
//2. 如果 value 是右值,那么推导为 T = std::string
//在 forward 内 value 被强转为 std::string&&,即返回一个右值引用
//那么这样传给 func 的参数的引用性质就没变,达成了完美转发

和指针的区别

引用必须初始化。因为引用的意义就是某个对象的别名,所以定义时一定要绑定某个对象,不能悬空

引用绑定以后不能改绑,这是引用和指针非常重要的区别

引用不能为空

引用变量本身不拥有独立内存,但指针变量通常自己拥有 8 字节大小内存。编译器优化时可能将引用变量直接替换成被引用对象本身

类与对象

类:程序员自定义的一种类型,用来描述一类对象有哪些数据,以及能进行哪些操作。

对象:某个类实际创建出来的一个具体实例,它真正占用内存,并拥有自己的成员数据

this 指针

每个对象被创建时会带上一个 this 指针,表示对象内存起址。每个对象都有自己的 this 指针

调用成员函数或访问成员变量时,都是通过 this 指针访问的

//成员函数中:
hp = value;
//可以理解为:
this->hp = value;

Player a;
//外部调用:
a.SetHP(100);
//可以理解为:
Player::SetHP(&a, 100);

class 和 struct

唯一区别是,class 成员默认是 protected 权限,struct 成员默认是 public 权限

const 成员函数

成员函数加 const 修饰,会限制修改对象的成员变量,如:

class Player{
public:
int GetHP() const{//该函数内禁止修改成员变量 hp
return hp;
}
private:
int hp;
};

普通成员函数中的 this 可以理解为 Player* const this,表示不可改变 this 的指向

而 const 成员函数中的 this 是 const Player* const this,表示既不可改变 this 的指向,也不可改变 this 指向的变量的值

对象的内存对齐

类成员的不同排布顺序,会影响对象的内存大小,因为要遵循内存对齐原则:

//顺序1:sizeof(A) == 12
class A
{
public:
char c;
int x;
char d;
};

//顺序2:sizeof(A) == 8
class A
{
public:
int x;
char c;
char d;
};

为什么会产生这样的差异?基于上述两例子分析:

类需要内存对齐,其对齐要求通常等于其成员中最大的对齐要求

  • int 要求 4 字节对齐,表示要求任何一个 int 变量的起址 % 4 = 0
  • char 要求 1 字节对齐,表示要求任何一个 char变量的起址 % 4 = 0

所以对于顺序 1,在定义A a;时会填充字节以对齐:

offset 0
┌─────────────┐
│ char c │ 1
├─────────────┤
│ padding │ 3
├─────────────┤
│ int x │ 4
└─────────────┘
│ char d │ 1
├─────────────┤
│ padding │ 3
offset 12

填充了两处:

  • 第一处是为了 int 的起址对齐,填充了 3 字节
  • 第二处是为了类对象对齐,填充了 3 字节。主要是为了兼容A a[5];这种情况。如果只对齐了第一处,可能符合a[0].x % 4 == 0,但由于末尾有 char 且没有填充,所以a[0].d % 4 == 1,导致a[1] % 4 == 1,最终导致a[1].x % 4 == (a[1] + 4) % 4 == 1,这个 int 就没有符合对齐规则。所以,要求类对象也要以 4 字节标准对齐,因此第二处填充也是必要的
  • 空类对象的内存大小

    空类对象的内存占用不为 0,而是 1,也就是:

    class Empty
    {
    };
    sizeof(Empty) == 1

    原因是Empty a 和 Empty b 是两个独立对象,需要拥有各自的对象身份,因此实现一般需要让空对象也占据至少一定存储

    构造 / 赋值 / 析构

    它们对应对象的三种状态:

    • 构造:对象怎么出生
    • 赋值:已经存在的对象怎么更新
    • 析构:对象怎么死亡

    如果定义一个空类,编译器隐式声明默认构造函数、拷贝 / 移动构造函数、拷贝 / 移动赋值函数、析构函数

    如果类显式定义了构造函数,编译器隐式声明拷贝 / 移动构造函数、拷贝 / 移动赋值函数、析构函数(抑制默认构造)

    如果类显式定义了析构函数,编译器隐式声明默认构造函数、拷贝构造函数、拷贝赋值函数(抑制移动)

    如果类显式定义了拷贝构造函数,编译器隐式声明拷贝赋值函数、析构函数(抑制移动)

    如果类显式定义了移动构造函数,编译器隐式声明析构函数(抑制默认构造和拷贝)

    如果类显式定义了拷贝赋值函数,编译器隐式声明默认构造函数、拷贝构造函数、析构函数

    如果类显式定义了移动赋值函数,编译器隐式声明默认构造函数、析构函数

    注意,编译器隐式声明的拷贝是浅拷贝,也就是直接拷贝值。如果成员是指针,且析构有free / delete逻辑,那么浅拷贝可能导致两个对象的指针值相同,在析构时产生重复释放——报错

    所以在成员为指针这样资源的情况下,应该手动定义拷贝赋值 / 构造,实现深拷贝逻辑——即拷贝时申请新的一片内存区域和一个新的指针,把被拷贝对象指向的内存区域中的值拷贝到新内存区域,相当于创建一个新的副本

    构造

    创建对象时自动调用,无论是栈上创建MyClass c;还是堆上创建MyClass* c = new MyClass()

    分为:默认构造、(有参构造)、拷贝构造、移动构造

    • 默认构造:没有任何参数,默认初始化对象以及成员,如MyClass c;
    • 有参构造:带有参数,根据传入参数初始化对象以及成员,如MyClass c(a,b);
    • 拷贝构造:传入同类型的左值对象,将它的成员拷贝到自身成员,如MyClass c(c2);
    • 移动构造:传入同类型的右值对象,将它的成员转移 / 拷贝到自身成员,如MyClass c(std::move(c2));
    赋值

    将对象赋值给已存在对象时调用,分为:拷贝赋值、移动赋值

    • 拷贝赋值:将传入的同类型左值对象的成员拷贝到自身成员,如c = c2;
    • 移动赋值:将传入的同类型右值对象的成员拷贝到自身成员,如c = std::move(c2);
    析构

    对象生命周期结束时自动调用,也可主动调用以销毁对象,如c.~MyClass();

    在函数内部进行资源释放,比如当成员变量为有效的指针时,析构中应free / delete

    移动语义

    移动语义的意思是让新对象尽量接管旧对象内部可转移的资源,避免昂贵拷贝

    什么是可转移资源?

    比如对象 A 有指针成员,指向一大片内存区域。不希望拷贝这片内存中的数据,而是通过拷贝指针值的方式来转移资源

    这样就与拷贝中的深拷贝原则相反了。可以理解为拷贝依赖深拷贝,而移动依赖浅拷贝

    原因是,移动发生时,被移动的对象被认为是即将弃用的,所以不应再复制一份它的副本出来,而是直接拿走它的资源

    移动语义的基础:右值

    当对一个对象进行构造或赋值时,如果传入的参数是右值引用,那么就会触发移动构造 / 赋值

    示例:

    Class A{
    A(){}
    ~A(){}
    A(A&& other){
    p = other.p;//指针值浅拷贝
    other.p = nullptr;//将旧对象的指针资源标记为无效
    }
    char* p;
    }

    A a2;
    A a1 = std::move(a2);//声明为右值,触发移动构造

    std::move没有移动资源,它做的是返回一个对传入对象的右值引用,可以理解为static_cast<A&&>(a);,前面有细讲

    然后 a1 知道了传入的 a2 是右值,触发了移动构造函数,将资源所有权从 a2 转移到自身

    这种方式和浅拷贝的区别是:浅拷贝只做了值拷贝,但移动语义还置空了旧对象的资源,保证资源只有一份

    移动语义退化

    当传入移动构造 / 赋值函数的参数是int、char、long这种基本类型时,移动会退化为拷贝

    因为这种类型的资源就是它的值,而不像指针变量它的指针值和资源不是一体的,

    继承

    继承是面向对象的一大特性,就是让一个新类在已有类的基础上继续扩展,使派生类自动拥有基类的一部分数据和行为

    基本示例:

    class Base
    {
    public:
    int x;
    void Func(){}
    };

    class Derived : public Base
    {
    public:
    //还继承了父类的 x 和 Func(),且继承于父类的成员变量会存放在子类对象的前半部分,后半部分才是子类成员,以支持类型转换
    int y;
    };
    //可以理解为:
    Derived d
    ┌─────────────────┐
    │ Base 子对象 │
    │ ┌─────────────┐ │
    │ │ int a │ │
    │ └─────────────┘ │
    ├─────────────────┤
    int b │
    └─────────────────┘

    权限继承
    public 对象在子类中protected 对象在子类中private 对象在子类中备注
    public 继承 public protected 不可访问 权限不变
    protected 继承 protected protected 不可访问 公开变保护
    private 继承 private private 不可访问

    总结:父类私有成员在子类中不可访问,保护权限优先级 > 公开优先级

    class 默认权限是 private,默认继承权限是 private;struct 默认权限是 public,默认继承权限是 public

    隐藏和重写

    隐藏:子类定义了与父类函数重名但参数不同的函数,父类函数被隐藏

    class Base
    {
    public:
    void Func(int);
    };

    class Derived : public Base
    {
    public:
    void Func(double);
    };

    重写:子类继承后定义了与父类虚函数重名且参数相同的函数,后面虚函数中细讲

    构造与析构顺序

    创建子类对象时:

    先构造父类,再构造子类

    先分配子类对象大小的内存。在这片内存的前半部分使用父类对象大小的空间,先构造父类成员,然后执行父类构造函数;再在父类成员后构造子类成员,执行子类的构造函数

    所以Base* d = new Derived();是合法的,因为来自父类的成员就是放在对象靠前的那部分内存,可以正常访问,这叫向上转型

    而Derived* b = new Base();通常不行,因为 new base 出来的缺少后半的子类成员部分

    销毁子类对象时:

    先调用子类析构函数,子类析构中隐式调用父类析构函数,对象内存是否释放取决于创建方式

    多态

    核心意义:同一个接口,面对不同实际对象时,表现出不同的行为

    虚函数

    虚函数就是在类中被 virtual 修饰的成员函数:

    class Base
    {
    public:
    virtual void Func()
    {
    std::cout << "Base\\n";
    }
    };

    存在虚函数的类,在编译时会生成一张虚函数表,里面存放着这个类的所有虚函数的函数指针,指针指向虚函数实际实现代码

    虚函数表存放在 .rodata,虚函数代码在 .text

    创建类对象实例时,类对象会隐式增加一个虚表指针 vptr(8 字节),指向类的虚函数表

    vptr 一般在对象内存的起始位置。这种情况下,访问对象成员就要多 8 字节偏移,但是编译器会解决

    继承时,如果子类重写了父类的虚函数,会用子类虚函数指针覆盖子类虚函数表中的该虚函数

    运行时多态

    同一套继承体系中,通过基类指针/引用操作对象时,虚函数最终调用哪个版本,由对象的实际类型在运行时决定,如:

    Derived d;
    Base* p = &d;
    p->Func();//实际调用的是子类的 Func(),这就是运行时多态

    底层实现是:调用虚函数接口时,根据对象中的 vptr 找到虚函数表,然后找到对应虚函数指针,然后执行虚函数逻辑

    由于运行时才动态绑定实际对象,所以是运行时多态

    多态的核心设计思想:基类定义统一接口,派生类提供不同实现

    纯虚函数与抽象类

    如果基类只是想规定接口,不提供任何实现,可以声明为纯虚函数:

    class Animal//只要有一个成员函数是纯虚函数,该类变成抽象类,无法实例化;如果子类没有 override 这个纯虚函数,也会变成抽象类
    {
    public:
    virtual void Speak() = 0;
    };

    虚析构

    继承多态体系中的析构函数通常声明为 virtual

    目的是:对象析构时多态调用到实际子类的析构函数,在自然顺着逆向析构链向上析构,保证资源正确释放

    多继承

    当子类继承了多个含有虚函数的父类时,子类对象就会拥有多个 vptr,布局可以理解为:

    //无虚函数
    C object
    ┌──────────────┐
    │ A subobject │
    int a │
    ├──────────────┤
    │ B subobject │
    int b │
    ├──────────────┤
    int c │
    └──────────────┘

    //有虚函数
    C object
    offset 0
    ┌──────────────────┐
    │ A::vptr │ 8
    ├──────────────────┤
    │ A::a │ 4
    ├──────────────────┤
    │ padding │
    ├──────────────────┤
    │ B::vptr │ 8
    ├──────────────────┤
    │ B::b │ 4
    ├──────────────────┤
    │ padding │
    ├──────────────────┤
    │ C::c │ 4
    ├──────────────────┤
    │ padding │
    └──────────────────┘

    这种情况下,C* C = new C();、B* C = new C();、A* C = new C();都能正确访问其对应的部分,不会错误

    多继承会产生一种叫“菱形继承”的情况,解决方法是虚继承,这里不展开讲

    多态约束

    final 可以帮助表达多态约束

    class Derived final : public Base//表示 Derived 不允许继续继承
    {
    virtual void Func() final;//声明后面的派生类不能再 override
    };
    //两种写法都可以约束多态泛滥或不确定,帮助编译器更容易确定最终调用目标

    静态多态

    调用哪个函数,在编译期就已经确定

    C++ 里常见的静态多态主要有三种,这里不展开讲了:

    • 函数重载:同名不同参的函数
    • 模板
    • CRTP(更典型的“面向对象式静态多态”)

    本篇收官。。。后面会说说智能指针

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 深入浅出理解计算机核心知识系列【C++语言特性合集-语法基础与拓展篇】
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!