ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

深入浅出理解计算机核心知识系列【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/signedchar/unsignedchar//一字节│ ├──wchar_t//一字节│ ├──char8_t//一字节│ ├──char16_t//二字节│ ├──char32_t//四字节│ └──short/int/long/longlong//二字节~八字节│ └── floating-point types ├──float//四字节├──double//八字节└──longdouble表达式表达式是一段可以被求值的代码比如10a ab//计算表达式func()//函数调用表达式a10//赋值表达式表达式的值就是 a 被赋值后的值表达式的类型表达式同样具有类型它的类型取决于表达式组成成员的类型比如ab如果 a 和 b 类型不同C 会优先考虑转换类型将表达式中所有子表达式先转换成同一类型转换规则是小类型适配大类型。若 a 是 int4字节b 是 double8字节就会把 a 转换成 double然后整个表达式的结果类型就是double隐式类型转换表达式存在隐式类型转换有一些常见的特殊表现如下整数提升char char 或 short short会先将两个数都变成 int 后再相加结果也是 intsigned / 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* pa;表示变量 p 保存了变量 a 的内存地址栈上或堆上因此通过指针变量可以访问它指向的变量64 位环境中指针变量本身一般是占 8 字节大小解引用*是解引用符号*p表示指针指向的对象本身或者理解成指向的那片内存地址修改*p就表示直接修改那片内存中的数据自然就修改了该变量指针变量的类型描述了“这个地址所指向对象的类型”用于限制访问这片内存时怎么解释这片内存int*pa;表示用*p访问a所在内存时将它解释为 int 类型读 4 个字节悬空指针与空指针悬空指针意思是指针变量没有绑定有效的地址或绑定了对象地址后对象被销毁此时指针变量保存的就是垃圾值空指针nullptr值为 0是进程虚拟地址的起址禁止访问解引用会报错引用引用是已有对象的别名。它不在内存上创建新的值对象而是让另一个名字绑定到原对象如下a ─┐ ├── 同一个 int 对象 ref ─┘和指针的实际意义差不多本质都是访问同一片内存区域不过访问时不需要解引用左值引用T创建一个引用变量绑定到一个左值表达式inta10;intrefa;注意左值引用不能绑定到右值如int ref 10;但const T可以绑定右值这种情况下会发生生命周期延长可以用常量左值引用绑定到左值如const int ref a;表示以只读方式引用对象右值引用T创建一个引用变量绑定到一个右值表达式intref10;右值引用主要服务于移动语义、完美转发后面细讲引用折叠与完美转发C 规定了一套规则用来处理“引用和引用叠在一起”的情况因为**模板类型推导、typedef、using等场景下编译器内部可能组合出这种“引用的引用”**这种情况。规定两层引用碰到一起最后应该变成什么这就是引用折叠模板推导/别名展开后出现引用的引用时规则是T→ TT→ TT→ TT→ T//只要有一个是左值引用折叠后就是左值引用//只有两个都是右值引用折叠后才是右值引用以std::move为例讲解templateclassTconstexprstd::remove_reference_tTmove(Tt)noexcept{returnstatic_caststd::remove_reference_tT(t);}//假设外部传入的是 int x它的类型是 int类别是左值//参数进入 move因为把左值传给转发引用 T所以模板推导得到 T int//根据引用折叠规则把 T 折叠成 T所以可知此时 t 类型是左值引用 int类别是左值//remove_reference_t 将引用去掉得到 int 类型然后此处就变成了 return static_castint(t);//也就是将 t 强转成了右值引用 int 类型所以 move 返回的就是一个右值引用什么是完美转发就是把一个参数“原封不动地”继续传给下一个函数既保留它的类型也保留它是左值引用还是右值引用依靠的是模板类型推导 引用折叠 std::forward比如//写一个包装函数templateclassTvoidWrapper(Tvalue)//由于引用折叠的存在保证了传入的 value 如果原来是左值那在这里是左值引用如果原来是右值在这里是右值引用{Func(std::forwardT(value));}//但是 value 本身是个左值所以再往下直接 Func(value)那么在下一层参数就一定会被推导为左值引用这不符合完美转发//所以引入 std::forwardT(value)。它内部做的是根据 T 去还原引用状态//这里以外部调用 ① std::string s;Wrapper(s); 和 ② Wrapper(std::string(hello)); 为例forward 的源码如下templateclassTTforward(std::remove_reference_tTvalue){returnstatic_castT(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 指针访问的//成员函数中hpvalue;//可以理解为this-hpvalue;Player a;//外部调用a.SetHP(100);//可以理解为Player::SetHP(a,100);class 和 struct唯一区别是class 成员默认是 protected 权限struct 成员默认是 public 权限const 成员函数成员函数加 const 修饰会限制修改对象的成员变量如classPlayer{public:intGetHP()const{//该函数内禁止修改成员变量 hpreturnhp;}private:inthp;};普通成员函数中的 this 可以理解为Player* const this表示不可改变 this 的指向而 const 成员函数中的 this 是const Player* const this表示既不可改变 this 的指向也不可改变 this 指向的变量的值对象的内存对齐类成员的不同排布顺序会影响对象的内存大小因为要遵循内存对齐原则//顺序1sizeof(A) 12classA{public:charc;intx;chard;};//顺序2sizeof(A) 8classA{public:intx;charc;chard;};为什么会产生这样的差异基于上述两例子分析类需要内存对齐其对齐要求通常等于其成员中最大的对齐要求int 要求 4 字节对齐表示要求任何一个 int 变量的起址 % 4 0char 要求 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也就是classEmpty{};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(Aother){pother.p;//指针值浅拷贝other.pnullptr;//将旧对象的指针资源标记为无效}char*p;}A a2;A a1std::move(a2);//声明为右值触发移动构造std::move没有移动资源它做的是返回一个对传入对象的右值引用可以理解为static_castA(a);前面有细讲然后 a1 知道了传入的 a2 是右值触发了移动构造函数将资源所有权从 a2 转移到自身这种方式和浅拷贝的区别是浅拷贝只做了值拷贝但移动语义还置空了旧对象的资源保证资源只有一份移动语义退化当传入移动构造 / 赋值函数的参数是int、char、long这种基本类型时移动会退化为拷贝因为这种类型的资源就是它的值而不像指针变量它的指针值和资源不是一体的继承继承是面向对象的一大特性就是让一个新类在已有类的基础上继续扩展使派生类自动拥有基类的一部分数据和行为基本示例classBase{public:intx;voidFunc(){}};classDerived:publicBase{public://还继承了父类的 x 和 Func()且继承于父类的成员变量会存放在子类对象的前半部分后半部分才是子类成员以支持类型转换inty;};//可以理解为Derived d ┌─────────────────┐ │ Base 子对象 │ │ ┌─────────────┐ │ │ │inta │ │ │ └─────────────┘ │ ├─────────────────┤ │intb │ └─────────────────┘权限继承public 对象在子类中protected 对象在子类中private 对象在子类中备注public 继承publicprotected不可访问权限不变protected 继承protectedprotected不可访问公开变保护private 继承privateprivate不可访问总结父类私有成员在子类中不可访问保护权限优先级 公开优先级class 默认权限是 private默认继承权限是 privatestruct 默认权限是 public默认继承权限是 public隐藏和重写隐藏子类定义了与父类函数重名但参数不同的函数父类函数被隐藏classBase{public:voidFunc(int);};classDerived:publicBase{public:voidFunc(double);};重写子类继承后定义了与父类虚函数重名且参数相同的函数后面虚函数中细讲构造与析构顺序创建子类对象时先构造父类再构造子类先分配子类对象大小的内存。在这片内存的前半部分使用父类对象大小的空间先构造父类成员然后执行父类构造函数再在父类成员后构造子类成员执行子类的构造函数所以Base* d new Derived();是合法的因为来自父类的成员就是放在对象靠前的那部分内存可以正常访问这叫向上转型而Derived* b new Base();通常不行因为 new base 出来的缺少后半的子类成员部分销毁子类对象时先调用子类析构函数子类析构中隐式调用父类析构函数对象内存是否释放取决于创建方式多态核心意义同一个接口面对不同实际对象时表现出不同的行为虚函数虚函数就是在类中被 virtual 修饰的成员函数classBase{public:virtualvoidFunc(){std::coutBase\n;}};存在虚函数的类在编译时会生成一张虚函数表里面存放着这个类的所有虚函数的函数指针指针指向虚函数实际实现代码虚函数表存放在 .rodata虚函数代码在 .text创建类对象实例时类对象会隐式增加一个虚表指针 vptr8 字节指向类的虚函数表vptr 一般在对象内存的起始位置。这种情况下访问对象成员就要多 8 字节偏移但是编译器会解决继承时如果子类重写了父类的虚函数会用子类虚函数指针覆盖子类虚函数表中的该虚函数运行时多态同一套继承体系中通过基类指针/引用操作对象时虚函数最终调用哪个版本由对象的实际类型在运行时决定如Derived d;Base*pd;p-Func();//实际调用的是子类的 Func()这就是运行时多态底层实现是调用虚函数接口时根据对象中的 vptr 找到虚函数表然后找到对应虚函数指针然后执行虚函数逻辑由于运行时才动态绑定实际对象所以是运行时多态多态的核心设计思想基类定义统一接口派生类提供不同实现纯虚函数与抽象类如果基类只是想规定接口不提供任何实现可以声明为纯虚函数classAnimal//只要有一个成员函数是纯虚函数该类变成抽象类无法实例化如果子类没有 override 这个纯虚函数也会变成抽象类{public:virtualvoidSpeak()0;};虚析构继承多态体系中的析构函数通常声明为 virtual目的是对象析构时多态调用到实际子类的析构函数在自然顺着逆向析构链向上析构保证资源正确释放多继承当子类继承了多个含有虚函数的父类时子类对象就会拥有多个 vptr布局可以理解为//无虚函数C object ┌──────────────┐ │ A subobject │ │inta │ ├──────────────┤ │ B subobject │ │intb │ ├──────────────┤ │intc │ └──────────────┘//有虚函数C object offset0┌──────────────────┐ │ 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可以帮助表达多态约束classDerivedfinal:publicBase//表示 Derived 不允许继续继承{virtualvoidFunc()final;//声明后面的派生类不能再 override};//两种写法都可以约束多态泛滥或不确定帮助编译器更容易确定最终调用目标静态多态调用哪个函数在编译期就已经确定C 里常见的静态多态主要有三种这里不展开讲了函数重载同名不同参的函数模板CRTP更典型的“面向对象式静态多态”本篇收官。。。后面会说说智能指针
返回列表