Cherno c++教程个人笔记(持续更新)

本文介绍了C++的编译原理,包括预处理、编译和链接的过程,以及在VisualStudio中进行调试的方法。详细讨论了变量、函数、头文件的使用,以及如何处理编译和链接错误。此外,还深入探讨了指针、引用、面向对象编程的概念,如类、静态成员、枚举、构造函数和析构函数,以及继承、虚函数和接口。最后,解释了C++中覆盖、重载和重写的区别。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >


常用字符的英文

& :ampersand
~ :tilde [ˈtɪldə]

c++ 编译原理

c++ 的工作流程:include中的头文件中的内容被拷贝进本文件,并和本文件的其他代码一起形成一个cpp文件。cpp文件被编译为特定机器的机器码,这些代码以obj格式存储。随后一整个项目的不同cpp文件编译成的几个obj文件需要使用linker来整合为一个可执行的exe文件。

  • 如果要使用其他源文件的函数,需要在使用前添加声明,声明中仅包含函数头即可(为增强可读性往往也写上变量名)。

c++编译器和链接器是如何工作的

  • 发生在编译阶段的错误代码以C开头,链接阶段的以LNK开头。

编译部分 (ctrl+F7)

c++编译器负责将一个文件翻译成机器码。对于不同的文件种类,c++编译器有不同的翻译方式,翻译方式按照约定根据文件后缀名来选取。比如.cpp,.c,.h文件都对应着不同的翻译方式。

编译器并不具有文件这一概念,只负责将输入的代码进行翻译。一次的输入(一个文件)被称为一个编译单元(translation unit),一个编译单元经编译后生成一个obj文件。一个编译单元里可能包含着多个具有相关关系的cpp文件。编译步骤为:

  1. 首先进行的步骤为预处理,在这一步中,编译器将代码中的一些特殊标记进行处理,这些标记以#开头。
    比如:#include 头文件名,会将头文件中的代码原封不动地拷贝到改行处;#if <判断条件> [代码块] #endif 在判断条件为假时该代码块不会进入后续编译中;#define A B 会将该文件中所有的A替换为B。

  2. 在预处理完成后,编译器将代码翻译为机器码(机器指令(mov,push,call等)+内存地址),最终这些机器码都将转换为对应的二进制码储存在.obj文件中。
    常数之间的代数运算在编译时将会直接计算出结果。
    对于不输出任何东西或更改变量的纯函数的调用,编译器会直接忽略。

  3. 修饰符:是在编译器进行编译的过程中,给编译器一些“要求”或“提示”,但修饰符本身,并不产生任何实际代码。如inline,static,const等。inline负责将函数的操作直接替换在调用它的地方(用代码空间的增大换取为函数开辟栈的时间),const用于定义常量。

  4. 常数折叠:在编译时对于可以计算和确定的代码进行处理,如

    x = 3if(x == 5{} //if控制的分支代码块不会被编译
    

链接部分(右键项目-生成/运行)

两种常见LNK error:

  1. unresolved external symbol:在一个文件里声明的函数在其他文件里没有找到(函数名称、变量、返回值都必须相同)。
    • 如果该文件里没有调用这个声明了的函数,且其他文件也不可能调用包含这个函数的函数(即将函数变为静态),则链接器不会报错。
  2. XXX already defined in XX.obj, one or more multiply defined symbols found:在不同的编译单元里存在重名的变量或函数。
    这种错误的一个不容易发现的产生方式,是在不同的编译单元里#include同一个头文件,而那个头文件里有函数不是静态的。解决办法1是将其变为静态函数,静态函数只在每个编译单元里生效;2是加inline修饰符,作用是在调用函数的地方直接拷贝一份函数体进去。3是只拷贝一份函数定义放到一个编译单元里,在头文件里只放函数定义,并在其他文件里#include头文件。

变量、函数、头文件

变量

变量本质上来说储存的都是数据,不同类型的变量之间的主要区别是其所占用的内存空间。其余区别包括使用print函数时输出的结果等等不同的行为。

不同种类变量占用的内存空间由编译器的设置决定,可通过sizeof()函数查看。对于c++的几种常用的变量类型来说,char、bool占用1个字节(实际上bool只需要1个比特,但内存访问的最小单位是1个字节),short 2个,int、long、float 4个, long long、double 8个。

  • 在变量类型前加unsigned可以将数据的绝对值表示范围扩大。
  • 给float变量赋值时需要在数字后加f或F。

函数

  • 分清什么时候应该把代码块封装成函数需要大量的编程经验。总的来说封装成函数是为了减少代码重复,但在程序调用函数时会在内存里做额外的操作而花费额外的时间(inline修饰的内联函数除外)。

头文件

头文件的基础用法就是声明一些常用函数,相应的定义会放在cpp文件里。

  • 当自己创建一个头文件时,VS会自动在最上面加一行#pragma once。#开头的指令是指导预处理过程的指令;而#pragma设定预处理器或编译器的状态,称为header guard。这里pragma once是为了确保头文件嵌套时有可能出现的重复错误。
  • #include ""#include <>的区别是前者常用于表示该头文件位于当前文件的相对路径下,后者用于用户设定的头文件夹的地址中。

如何在VS中debug

设置断点(F9)后使用逐语句(step into)、逐过程(step over)、跳出(step out)。跳出只用于跳出函数。如果想跳出循环语句,需要在循环外的下一行设置断点然后F5。

  • 实际debug中开发者会检视程序所使用的内存空间。在debug模式下程序会做额外的一些工作来帮助我们debug,比如对于初始化但未赋值的变量采用cc来填充。
  • 反汇编视图可以展示源码和对应汇编码之间的对应关系。

控制语句

条件与分支

在代码中创造分支会导致程序在内存的执行不连续,进而影响运行速度。所以开发者在优化程序时有时会尝试用数学计算来代替逻辑判断。

循环

for 和 while 没有本质上的区别。习惯上while判断语句里的变量是现有的,而for处理有序数组等需要索引的情况。do while中的代码块至少会执行1次。

控制流语句

continue进入下一迭代, break终止循环, return终止函数体。


指针与引用

指针

本质上来说,一个指针就是一个整数,这个整数代表一个内存地址。在此基础上附加的类型int*, char*,以使得编译器在对该地址进行读写操作时知道要数据所占用的内存大小。

  • 可以对指针直接赋值 void* ptr = 8,也可使用“&变量名”的形式获取某个变量的地址 void* = &x

借助指针进行内存写入时,使用*+指针名,如*ptr = 10。这里星号被称为逆向引用符(dereference operator)。

  • 因为所有的变量本质都是数据,指针所存储的代表地址的数据也具有地址,可以被另一个指针所代表,即双指针。 如
    char* buffer = new char[8]; //返回一个8个字节的堆的第一个地址
    char** ptr = &buffe; //ptr是指向buffer所储存的地址的指针
    

引用

  • 本质上来说,引用是指针的一种语法糖。
  • int& ref = a; 这里的ref称为a的引用,它并不是一个变量,而只是变量a的一个假名(alias),给ref赋值就相当于给a赋值。
  • 如果想将变量a传入某个函数并在函数体内对a的地址的内存做读取,则在函数体定义的变量名上写int& XXX。
  • 使用“类型名+&”定义的引用没办法更换目标。如果想让引用可以更换变量,需要创建一个指针,然后更改指针所指向的变量地址。

面向对象编程

类的本质是把数据和函数功能组合起来。

类和结构体的根本区别:类成员默认是私有的,结构体成员默认是公有的。结构体是为了向下兼容C语言而存在的类型。

静态static

  1. 在类或结构体外:static修饰符表示在该变量或函数仅在该编译单元内生效。一般来说,除非需要变量和函数能跨编译单元生存,否则都需要声明为静态。external修饰符告诉链接器在本编译单元外寻找被修饰的变量或函数。

  2. 在类或结构体内:static表示该变量或函数在所有实例中只有一个。该变量或函数在该类的命名空间里,从功能上来说,类内和类外定义这些变量或函数是等效的。静态方法不能访问非静态变量,因为静态方法没有类实例参数。

  3. 局部静态:在某个局部环境里,使用static修饰的变量的生命周期/作用域将扩展到全局,但其可见范围仍局限在局部环境中。在某些只需要对变量做一次初始化的场景中使用局部静态写法可以使代码更加简洁。

枚举

enum的本质是一些整型值(默认从0开始递增)的集合,在定义一个enum类型时为这些值分别命不同的变量名,从而提升代码可读性和整洁性。

//在定义后加上“:类型”(必须是整数类型)可以替换掉默认的32位整型,从而减少内存开销。如本例中替换为8位的unsigned char。
enum Level : unsigned char 
{
    //可以自定义对应的整数值,后面的变量会相应自增(5,6,7)
    Error = 5; Warning, Info 
}
//使用枚举时语法类似于声明一个类型变量,赋值时需要符合可能的命名取值
Level m_LogLevel = Info; 

与后面会讲到的enum类不同,本节中的enum只是对变量作捆绑,并没有形成新的命名空间,因此其中的变量名可以之间在当前类中使用:

if (m_LogLevel >= Warning)
...

构造函数与析构函数

构造函数(constructor)

构造函数本质上是一种特殊类型的方法,在每次实例化对象时运行。

  • 在C++中,所有基本类型都必须手动初始化,否则其值是内存中现有的某个值。

构造函数的写法为:类名(){}。注意构造函数没有返回类型。构造函数可以重载,接收不同种类的初始化参数。
构造函数有多种写法,如:

//类名(参数1,参数2) : 变量1(参数1), 变量2(参数2), 变量3(初始值) {}
struct ListNode {
    int val;  // 节点上存储的元素
    ListNode *next;  // 指向下一个节点的指针
    ListNode(int x) : val(x), next(NULL) {}  // 节点的构造函数
};
  • 如果在创建类时不写构造函数,C++会默认创建一个空的构造函数。而如果开发者写了某些静态类,只想让使用者调用静态方法而不想让其对某个类进行实例化,一种做法是将构造函数设为私有,另一种是手动删除构造函数 类名() = delete;

析构函数(destructor)

析构函数在销毁对象时运行,负责将构造函数调用时初始化的一些东西卸载/销毁,否则可能造成内存泄漏。

如果类是在栈上创建的,在退出栈时会自动调用析构函数销毁。如果是new出来的(在堆上创建),在调用delete时会运行析构函数。

析构函数的写法为:~类名(){}


继承、虚函数、接口

继承

继承可以有效地避免代码重复,开发者可以将公共代码放到基类中。写法为:class 子类 : public 基类。继承后,子类自动拥有基类的所有公有成员。

  • 如果继承了多个基类,可同时写在public后,并用逗号隔开。

子类可以视为同时是子类和基类两种类型(多态特征)。这就意味着以基类为输入参数的函数,这个输入参数同样可以接受子类类型的变量。

虚函数

虚函数可以让开发者在子类中覆写(override)方法。做法为将基类要被覆写的方法变为虚函数,具体代码为在基类函数前加virtual:virtual 返回值类型 函数名(){};,这会告诉编译器为这个函数生成一个v表(v table)。并在子类覆写方法的参数和函数体之间加入override:函数名(参数) override {};(不加也可以执行,但可读性降低、在出错时无法及时发现)。这样当函数被重写时就可以找到对应的函数。

虚函数在运行时具有额外的开销,一是需要额外的内存来储存v表,二是当调用虚函数时需要在表中查找正确的重写函数。

接口(纯虚函数)

当在基类里定义一个没有实现的函数,强制子类去实现该函数时,该函数称为纯虚函数。在OOP中,经常会创建一个只由未实现的方法组成的类,称为接口。接口可以视为一个模板。由于接口类不包含方法实现,所以不能被实例化。

将一个函数变为纯虚函数的方式为 virtual 返回值类型 函数名() = 0;

  • 一个接口使用实例:假设我们要写一个函数,参数是某个类的实例,作用是打印它的类名。

    void Print(??? obj){
      std::cout << obj->GetName() << std::endl;
    }
    

    这时为了确保输入的类确实实现了GetName()方法,就可以创建一个Printable的接口:

    class Printable{
    public:
        virtual std::string GetClassName() = 0;
    }
    

    并让其他类继承并实现这个接口:

    class Entity : public Printable{
    public:
      ...
       virtual std::string GetClassName() override {return "Entity";}
    }
    
    class Player : public Entity{ //由于Player的基类继承自Entity,所以无需再实现这个接口。
      ...
      virtual std::string GerClassName() override {return "Player";}
    }
    

    这时在Print里就可以指明输入参数的类为Printable:void Print(Printable obj){}

  • 其他语言有interface关键字,但c++没有,只是将接口视为一种特殊的类。

c++中覆盖、重载、重写的区别

面试的时候被问了类似的问题,于是参考这篇博文学习一下。
覆盖(override)指子类实现父类的virtual方法,上面虚函数一节有讲。
重载(overload)指在同一个类的命名空间里,定义函数名相同但接受的参数形式不同的多个方法。
重写(overwrite)的情况比较复杂,一种情况是子类定义了名字与父类相同但参数形式不同的方法,另一种情况是子类定义了函数声明与父类完全相同的方法且父类方法不是虚函数,这两种情况下实际调用时会根据指针的类型为父类/子类来调用类内函数。
在《Effective C++》中提到,按照类之间继承关系的根本设计思路(子类 is a 父类),实际上重写的情况不应该出现。(这里还需要实际项目积累经验来体会)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值