本博客内容主要来源于《暗黑风格-C++八股文》「作者:小贺,公众号:herongwei」,外附博主查阅其他资料整理所得(来源已标注)。

C++语言基础


智能指针

在C++中,动态内存的管理是用一对运算符完成的:new和delete,new:在动态内存中为对象分配一块空间并返回一个指向该对象的指针,delete:指向一个动态独享的指针,销毁对象,并释放与之关联的内存。动态内存管理经常会出现两种问题:一种是忘记释放内存,会造成内存泄漏;一种是尚有指针引用内存的情况下就释放了它,就会产生引用非法内存的指针。
原文链接:https://blog.csdn.net/qq_44139121/article/details/125937906

智能指针就是一个类,当超出了类的作用域时,类会自动调用析构函数,析构函数会自动释放资源。所以智能指针的作用原理就是在函数结束时自动释放内存空间,不需要手动释放内存空间。

  • auto_ptr

(C++98的方案,C11已抛弃)采用所有权模式。

缺点:存在潜在的内存崩溃问题。

  • unique_ptr(替换 auto_ptr )

所有权(ownership)概念:对于特定的对象,只能有一个智能指针可拥有,这样只有拥有对象的智能指针的析构函数会删除该对象。然后让赋值操作转让所有权。这就是用于 auto_ptr 和 unique_ptr 的策略,但 unique_ptr 的策略更严格。

不支持普通的拷贝或赋值操作,可以通过调用release或reset将指针所有权从一个(非const)unique_ptr转移给另一个unique。

保证同一时间内只有一个智能指针可以指向该对象,对于避免资源泄露特别有用,比auto_ptr更安全。

  • shared_ptr(共享型,强引用)

实现共享式拥有概念,多个智能指针可以指向相同对象,该对象和其相关资源会在“最后一个引用被销毁”时候释放。

可以通过成员函数 use_count() 来查看资源的所有者个数。当我们调用 release() 时,当前指针会释放资源所有权,计数减一。当计数等于 0 时,资源会被释放。

除了可以通过 new 来构造,还可以通过传⼊auto_ptr, unique_ptr,weak_ptr 来构造。

1
2
shared_ptr<int> p1 = new int(1024);//错误:必须使用直接初始化形式
shared_ptr<int> p2(new int(1024));//正确:使用了直接初始化形式
  • weak_ptr(弱引用)

weak_ptr是一种不控制所指向对象生存期的智能指针,它指向一个由shared_ptr管理的对象,进行该对象的内存管理的是那个强引用的 shared_ptr。将一个weak_ptr绑定到一个shared_ptr不会改变shared_ptr的引用计数。一旦最后一个指向对象的shared_ptr被销毁,对象就会被释放,即使有weak_ptr指向对象,对象还是会被释放。
原文链接:https://blog.csdn.net/flowing\_wind/article/details/81301001

weak_ptr 是用来解决 shared_ptr 相互引用时的死锁问题:

当两个智能指针都是 shared_ptr 类型的时候,析构时两个资源引用计数会减一,但是两者引用计数还是为 1,导致跳出函数时资源没有被释放(的析构函数没有被调用),解决办法:把其中一个改为weak_ptr就可以。


C++ 中内存分配情况

  • 栈:由编译器管理分配和回收,存放局部变量和函数参数

  • 堆:由程序员管理,需要手动 new malloc delete free 进行分配和回收,空间较⼤,但可能会出现内存泄漏和空闲碎⽚的情况。

  • 全局/静态存储区:全局变量和静态变量被分配到同一块内存,分为初始化和未初始化两个相邻区域,存储初始化和未初始化的全局变量和静态变量。

  • 常量存储区∶存储常量,一般不允许修改。

  • 代码区:存放程序的二进制代码。


C++ 中的指针参数传递和引用参数传递

指针参数传递本质上是值传递,它所传递的是一个地址值

值传递过程中,被调函数的形式参数作为被调函数的局部变量处理,会在栈中开辟内存空间以存放由主调函数传递进来的实参值,从而形成了实参的一个副本(替身)。值传递的特点是,被调函数对形式参数的任何操作都是作为局部变量进行的,不会影响主调函数的实参变量的值(形参指针变了,实参指针不会变)。

引用参数传递过程中,被调函数的形式参数也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址。被调函数对形参(本体)的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量(根据别名找到主调函数中的本体)。因此,被调函数对形参的任何操作都会影响主调函数中的实参变量。

下图摘录于:【C++】指针和引用的区别及指针传递和引用传递的区别Jacky_Feng的博客-CSDN博客指针传递和引用传递的区别

image-20221017152821574

image-20221017152900526

image-20221017152921798

思考:

1
2
3
4
5
6
7
8
9
//指针传递
fun(&a); //&a表示a的地址
fun(int *p){} //将a的地址&a传给p(指针传递的是实参的值),*p指向a的值
//引用传递
fun2(a);
fun2(int &p){} //将a的地址传给p(引用传递的是实参的地址),p的地址&p就是a的地址,即p就是a,所以改变p就是改变a
//值传递
fun3(a);
fun3(int p){} //a的值转给p,但是p的地址不是a的地址

如果想通过指针参数传递来改变主调函数中的相关变量(地址),那就得使用指向指针的指针或者指针引用。

符号表生成之后就不会再改,因此指针可以改变其指向的对象(指针变量中的值可以改),而引用对象则不能修改。


const 和 static 关键字

static 作用:控制变量的存储方式和可⻅性。

  • 作用一:修饰局部变量:一般情况下,对于局部变量在程序中是存放在区的,并且局部的生命周期在包含语句块执行结束时便结束了。但是如果用 static 关键字修饰的话,该变量便会存放在静态数据区,其生命周期会一直延续到整个程序执行结束。

  • 作用二:修饰全局变量:对于一个全局变量,它既可以在本⽂件中被访问到,也可以在同一个工程中其它源⽂件被访问(添加 extern进行声明即可)。用 static 对全局变量进行修饰改变了其作用域范围,由原来的整个工程可⻅变成了本⽂件可⻅。

  • 作用三:修饰函数:用 static 修饰函数,情况和修饰全局变量类似,也是改变了函数的作用域。

  • 作用四:修饰类:如果 C++ 中对类中的某个函数用 static 修饰,则表示该函数属于一个类而不是属于此类的任何特定对象;如果对类中的某个变量进行 static 修饰,则表示该变量以及所有的对象所有,存储空间中只存在一个副本,可以通过;类和对象去调用。

    补充:静态⾮常量数据成员,其只能在类外定义和初始化,在类内仅是声明而已。

  • 作用五:类成员类函数声明static

    • 函数体内 static 变量的作用范围为该函数体,不同于 auto 变量,该变量的内存只被分配一次,因此其值在下次调用时仍维持上次的值;

    • 在模块内的 static 全局变量可以被模块内所用函数访问,但不能被模块外其它函数访问;

    • 在模块内的 static 函数只可被这一模块内的其它函数调用,这个函数的使用范围被限制在声明它的模块内;

    • 在类中的 static 成员变量属于整个类所拥有,对类的所有对象只有一份拷贝;

    • 在类中的 static 成员函数属于整个类所拥有,这个函数不接收 this 指针,因而只能访问类的 static 成员变量。

    • static类对象必须要在类外进行初始化,static 修饰的变量先于对象存在,所以static 修饰的变量要在类外初始化;

    • 由于 static修饰的类成员属于类,不属于对象,因此 static 类成员函数是没有 this 指针,this 指针是指向本对象的指针,正因为没有 this指针,所以static类成员函数不能访问非static 的类成员,只能访问 static修饰的类成员;

    • static 成员函数不能被 virtual 修饰,static 成员不属于任何对象或实例,所以加上 virtual没有任何实际意义;静态成员函数没有 this 指针,虚函数的实现是为每一个对象分配一个vptr指针,而 vptr 是通过 this 指针调用的,所以不能为 virtual;虚函数的调用关系,this->vptr->ctable->virtual function。

    下图摘录于:C/C++面试:static修饰类成员和类函数_OceanStar的学习笔记的博客-CSDN博客_c++ static修饰类

image-20221018153522634

image-20221018153532931

image-20221018155628229

image-20221018155647962

image-20221018155745359

image-20221018155816897

const 关键字:含义及实现机制

  • 修饰基本类型数据类型:修饰符 const 可以用在类型说明符前,也可以用在类型说明符后,其结果是一样的。在使用这些常量的时候,只要不改变这些常量的值即可。

  • 修饰指针变量和引用变量:如果 const 位于⼩星星的左侧,则 const 就是用来修饰指针所指向的变量,即指针指向为常量;如果 const 位于⼩星星的右侧,则 const 就是修饰指针本身,即指针本身是常量。

1
2
3
4
const int *A; //const修饰指向的对象,A可变,A指向的对象不可变 
int const *A; //const修饰指向的对象,A可变,A指向的对象不可变
int *const A; //const修饰指针A, A不可变,A指向的对象可变
const int *const A;//指针A和A指向的对象都不可变
  • 应用到函数中:作为参数的 const 修饰符:调用函数的时候,用相应的变量初始化const 常量,则在函数体中,按照 const 所修饰的部分进行常量化,保护了原对象的属性。

    [注意]:参数 const 通常用于参数为指针或引用的情况; 作为函数返回值的 const 修饰符:声明了返回值后,const 按照”修饰原则”进行修饰,起到相应的保护作用。

    1
    2
    const int Fun1(); 
    const MyClass Fun2();
  • 在类中的用法

  • const 成员变量,只在某个对象生命周期内是常量,而对于整个类而言是可以改变的。因为类可以创建多个对象,不同的对象其const数据成员值可以不同。所以不能在类的声明中初始化 const 数据成员,因为类的对象在没有创建时候,编译器不知道 const数据成员的值是什么。const数据成员的初始化只能在类的构造函数的初始化列表中进行。const成员函数的主要目的是防止成员函数修改对象的内容。要注意,const关键字和static关键字对于成员函数来说是不能同时使用的,因为static关键字修饰静态成员函数不含有this指针,即不能实例化,const成员函数又必须具体到某一个函数。

1
2
3
4
5
class ClassName { 
public:
int Fun() const;
};
//这样,在调用函数Fun时就不能修改类里面的数据

const 修饰类对象,定义常量对象:常量对象只能调用常量函数,别的成员函数都不能调用。⾮常量对象可以调用类中的 const 成员函数,也可以调用⾮ const 成员函数。

补充:const 成员函数中如果实在想修改某个变量,可以使用 mutable 进行修饰。成员变量中如果想建⽴在整个类中都恒定的常量,应该用类中的枚举常量来实现或者 static const

  • const类成员函数

    常量对象可以调用类中的 const 成员函数,但不能调用⾮ const 成员函数;(原因:对象调用成员函数时,在形参列表的最前面加一个形参 this,但这是隐式的。this 指针是默认指向调用函数的当前对象的,所以,很自然,this 是一个常量指针 test * const,因为不可以修改this 指针代表的地址。但当成员函数的参数列表(即⼩括号)后加了 const 关键字(void print() const;),此成员函数为常量成员函数,此时它的隐式this形参为 const test * const,即不可以通过 this 指针来改变指向对象的值


C和 C++区别(函数/类/struct/class)

  • C++有新增的语法和关键字,语法的区别有头文件的不同和命名空间的不同,C++允许我们自己定义自己的空间,C中不可以。

  • 关键字方面比如C++与C动态管理内存的方式不同,C++中在malloc和free的基础上增加了new和delete,而且C++中在指针的基础上增加了引用的概念,关键字例如C++中还增加了 auto,expliclt 体现显示和隐式转换上的概念要求,还有dynamic_cast增加类型安全方面的内容。

  • 函数方面 C++ 中有重载和虚函数的概念:C++ 支持函数重载而 C 不支持,是因为 C++ 函数的名字修饰与 C 不同,C++ 函数名字的修饰会将参数加在后面,例如,int func(int,double)经过名字修饰之后会变成_func_int_double,而 C 中则会变成 _func,所以 C++ 中会支持不同参数调用不同函数。

    虚函数的概念用以实现多态。

  • 类方面,C的 struct 和C++的类也有很大不同∶C++中的 struct 不仅可以有成员变量还可以成员函数,而且对于 struct 增加了权限访问的概念,struct 的默认成员访问权限和默认继承权限都是 public,C++中除了 struct 还有 class 表示类,struct 和 class 还有一点不同在于 class 的默认成员访问权限和默认继承权限都是 private。

  • C++中增加了模板还重用代码,提供了更加强大的 STL 标准库。//STL( standard template libaray-标准模板库

最后补充一点就是C 是一种结构化的语言,重点在于算法和数据结构。C 程序的设计首先考虑的是如何通过一个代码,一个过程对输入进行运算处理输出。而 C++首先考虑的是如何构造一个对象模型,让这个模型能够契合与之对应的问题领域,这样就能通过获取对象的状态信息得到输出。

1
C 的 struct 更适合看成是一个数据结构的实现体,而 C++ 的 class 更适合看成是一个对象的实现体。

C++ 和 Java 区别(语言特性,垃圾回收,应用场景等)

  • 指针∶Java没有指针的概念,让程序员没法找到指针直接访问内存,有内存的自动管理功能,有效防止C++语言中的指针操作失误的影响。但java虚拟机内部用了指针保证程序的安全。

  • 多重继承:Java不支持多重继承,但支持一个类继承多个接口,实现C++种多重继承的功能,避免了C++的多重继承带来的不便。

  • 数据类型和类: Java是完全面向对象的语言,所有的函数和变量必须是类的一部分。除了基本数据类型之外,其余的都作为类对象,对象将数据和方法结合起来,把它们封装在类中,这样每个对象都可以实现自己的特点和行为。Java 中取消了 C++ 中的 struct 和 union。

  • 自动内存管理∶Java 程序中所有对象都是用 new 操作符建⽴在内存堆栈上,Java 自动进行无用内存回收操作,不需要程序员进行手动删除。而 C++ 中必须由程序员释放内存资源,增加了程序设计者的负担。Java 中当一个对象不再被用到时, 无用内存回收器将给他们加上标签。Java ⾥无用内存回收程序是以线程方式在后台运行的,利用空闲时间工作来删除。

  • Java 不支持操作符重载操作符重载被认为是 C++ 的突出特性。

  • Java 不支持预处理功能。C++ 在编译过程中都有一个预编译阶段,Java 没有预处理器,但它提供了 import 与 C++ 预处理器具有类似功能。

  • 类型转换:C++ 中有数据类型隐含转换的机制,Java 中需要限时强制类型转换。

  • 字符串∶ C++中字符串是以 Null 终⽌符代表字符串的结束,而 Java 的字符串是用类对象(string 和 stringBuffer)来实现的。

  • Java 中不提供 goto 语句,虽然指定 goto 作为关键字,但不支持它的使用,使程序简洁易读。

  • Java 的异常机制用于捕获例外事件,增强系统容错能⼒。


C++如何定义常量,常量存放位置

  • 宏定义

    1
    # define DEMO 200

    这种方式定义的常量,在编译时,编译器看不到DEMO这个名称,在预处理的时候都被替换了。于是DEMO没有进入符号表内,这样,在运用此常量的地方出现编译错误时,错误信息不会提到DEMO,会导致难以定位错误信息。另外在进行调试时,也看不到DEMO名称。

  • const常量定义

    1
    2
    const int demo = 10;   
    const char* const pDemo = "hello world";
  • enum定义常量

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
      enum { DEMO = 10, };

      去const常量的地址是合法的,但是取enum的地址不合法。

    - **存放的内存位置**

    对于局部常量,存放在栈区;

    对于全局常量,编译期一般不分配内存,放在符号表中以提高访问效率;(符号表不占内存)

    字面值常量,比如字符串,放在常量区。字面值常量包括整型字面值,浮点型字面值,字符和字符串字面值,布尔字面值,指针字面值。

    ---

    ## 重载,重写,重定义的区别

    ### 重载

    Overload,是指同一可访问区内被声明的几个<u>具有不同参数列表</u>的<u>同名函数</u>,依赖于C++函数名字的修饰会将参数加在后面,可以是参数类型,个数,顺序的不同。根据参数列表决定调用哪个函数,<u>重载不关心函数的返回类型。</u>

    ### 重写

    Overwrite,派生类中重新定义父类中除了函数体外完全相同的<u>虚函数</u>,注意<u>被重写</u>的函数不能是 static 的,一定要是虚函数,且其他一定要完全相同。

    要注意,重写和被重写的函数是在<u>不同的类</u>当中的,重写函数的<u>访问修饰符是可以不同的</u>,尽管 virtual 中是 private 的,派生类中重写可以改为 public。

    \**虚函数,是指被virtual关键字修饰的成员函数。核心目的是**通过基类访问派生类定义的函数**。虚函数的作用,用专业术语来解释就是实现多态性。*

    \* *如果父类中含有一个<u>静态</u>方法,且在子类中也含有一个返回类型、方法名、参数列表均与之相同的静态方法,那么该子类实际上只是 **将父类中的该同名方法进行了隐藏,而非重写。***

    ```c++
    #include <iostream>//示例来源 https://www.dotcpp.com/course/80
    using namespace std;
    #define PI 3.1415926

    class Point
    {
    private:
    int x,y;

    public:
    Point(int x=0,int y=0)
    {
    this->x = x;
    this->y = y;
    }
    virtual double area()//被重写的虚函数
    {
    return 0.0;
    }
    };
    class Circle:public Point
    {
    private:
    int r;
    public:
    Circle(int x,int y,int R):Point(x,y)
    {
    r = R;
    }
    double area()//重写
    {
    return PI*r*r;
    }
    };

    int main()
    {

    Point A(10,10);
    cout<<A.area()<<endl;//0
    Circle B(10,10,20);
    cout<<B.area()<<endl;//1256.64
    Point *p;
    p = &B;//通过重写,可以实现动态多态,就是当父类的指针或引用指向被重写的虚函数时,父类的指针或引用指向谁就调用谁的虚函数,而不是说根据类型。
    cout<<p->area()<<endl;//1256.64
    Point &pp=B;
    cout<<pp.area()<<endl;//1256.64
    return 0;
    }

重定义(也叫隐藏)

派生类重新定义父类中相同名字的⾮ virtual 函数参数列表和返回类型都可以不同,即父类中除了定义成 virtual 且完全相同的同名函数才不会被派生类中的同名函数所隐藏(重定义)。

重定义也叫隐藏,指的是在继承关系中,子类实现了一个和父类名字一样的函数,(只关注函数名,和参数与返回值无关)这样的话子类的函数就把父类的同名函数隐藏了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
//示例来源: https://blog.csdn.net/han8040laixin/article/details/81703244
#include <iostream>
using namespace std;

class A{
public:
void f()
{
cout << "A" << endl;
}

public:
int _x;
};

class B : public A{
public:
void f(int a)
{
cout << "B" << endl;
}

public:
int _x;
};

int main()
{
B b;
b.f();//子类隐藏了父类的f函数,这个题目比较迷惑人的是子类的f函数有参数,所以会以为调的是父类的f函数;
//但是隐藏只与函数名有关,与参数是没关系的,所以调用的还是子类的f函数,这个程序就跑不过。
}

构造函数

类的对象被创建时,编译系统为对象分配内存空间,并自动调用构造函数,由构造函数完成成员的初始化工作。

即构造函数的作用初始化对象的数据成员

  • 构造函数名与类名相同,且无返回值(void也不行)
  • 在每一个类中都有存在有默认的构造函数(系统自行提供),且在实例化对象时都会调用,只是默认不做任何事情
  • 构造函数会在实例化对象时自动调用,不允许显式调用

构造函数的调用顺序
基类构造函数、对象成员构造函数、派生类本身的构造函数

  • 缺省构造函数时,系统将自动调用该缺省构造函数初始化对象,缺省构造函数会将所有数据成员都初始化为零或空
  • 创建一个对象时,系统自动调用构造函数

析构函数的调用顺序
派生类本身的析构函数、对象成员析构函数、基类析构函数(与构造顺序正好相反)
对于栈对象和全局对象,类似于入栈与出栈的顺序,最后构造的对象被最先析构。

无参数构造函数

默认构造函数,如果没有明确写出无参数构造函数,编译器会自动生成默认的无参数构造函数,函数为空,什么也不做,如果不想使用自动生成的无参构造函数、必需要自己显示写出一个无参构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include<iostream>
//https://blog.csdn.net/guishangppy/article/details/125876729
using namespace std;
class Triangle
{
public:
Triangle()
{
cout<<"无参构造"<<endl;
a=3;
b=4;
c=5;
}
void getValue() //成员函数(打印成员变量值)
{
cout<<"a="<<a<<endl;
cout<<"b="<<b<<endl;
cout<<"c="<<c<<endl;
}
private:
int a;
int b;
int c;
};
int main()
{
Triangle A;
A.getValue(); //调用成员函数
}

image-20221019153519403

一般构造函数

也称重载构造函数,一般构造函数可以有各种参数形式,一个类可以有多个一般构造函数,前提是参数的个数或者类型不同,创建对象时根据传⼊参数不同调用不同的构造函数。

1
2
3
4
5
6
7
8
Triangle(int a,int b,int c)  //参数名随意
{
cout<<"有参构造"<<endl;
this->a=a; //为每个成员赋值
this->b=b;
this->c=c;
}
Triangle A(3,4,5) //分别为对应参数传参

拷贝构造函数

1
2
3
4
Object(Object& obj)
{
}
//上面的形参里面需要加上“&”,目的是为了防止无限的构造,形成死递归。

拷贝构造函数的函数参数为对象本身的引用,用于根据一个已存在的对象复制出一个新的该类的对象,一般在函数中会将已存在的对象的数据成员的值一一复制到新创建的对象中。如果没有显示的写拷贝构造函数,则系统会默认创建一个拷贝构造函数,但当类中有指针成员时,最好不要使用编译器提供的默认的拷贝构造函数,最好自己定义并且在函数中执行深拷贝。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include<iostream>
//https://blog.csdn.net/guishangppy/article/details/125876729
using namespace std;
class Triangle
{
public:
Triangle(int a,int b,int c) //有参构造
{
this->a=a;
this->b=b;
this->c=c;
cout<<"有参构造"<<endl;
}
Triangle(Triangle &obj) //拷贝构造
{
a=obj.a; //通过目标对象对每个成员变量赋值
b=obj.b;
c=obj.c;
cout<<"拷贝构造"<<endl;
}
void getValue() //成员函数
{
cout<<"a="<<a<<endl;
cout<<"b="<<b<<endl;
cout<<"c="<<c<<endl;
}

private:
int a;
int b;
int c;
};
int main()
{
Triangle A(3,4,5); //A调用了有参构造
Triangle B(A); //B调用了拷贝构造
B.getValue();
}

image-20221019155604252

类型转换构造函数

根据一个指定类型的对象创建一个本类的对象,也可以算是一般构造函数的一种,这里提出来,是想说有的时候不允许默认转换的话,要记得将其声明为 explict 的,来阻止一些隐式转换的发生。

赋值运算符的重载

注意,这个类似拷贝构造函数,将=右边的本类对象的值复制给=左边的对象,它不属于构造函数,=左右两边的对象必需已经被创建。如果没有显示的写赋值运算符的重载,系统也会生成默认的赋值运算符,做一些基本的拷贝工作。

区分:

1
2
A al,A a2; a1 = a2;//调用赋值运算符
A a3 = al;//调用拷贝构造函数,因为进行的是初始化工作,a3 并未存在

C++的四种强制转换

static_cast静态转换

明确指出类型转换,一般建议将隐式转换都替换成显示转换,因为没有动态类型检查,上行转换(派生类->基类:把派生类的指针或引用转换成基类表示)安全,下行转换(基类->派生类)不安全,所以主要执行非多态的转换操作。

dynamic_cast 动态转换

专⻔用于派生类之间的转换,type-id 必须是类指针,类引用或 void*,对于下行转换是安全的,当类型不⼀致时,转换过来的是空指针;而static_cast当类型不⼀致时,转换过来的事错误意义的指针,可能造成⾮法访问等问题。

const_cast常量转换

专⻔用于 const 属性的转换,去除 const 性质,或增加 const 性质, 是四个转换符中唯⼀⼀个可以操作常量的转换符

reinterpret_cast

不到万不得已,不要使用这个转换符,高危操作。使用特点: 从底层对数据进行重新解释,依赖具体的平台,可移植性差; 可以将整形转换为指针,也可以把指针转换为数组;可以在指针和引用之间进行肆无忌惮的转换。


指针和引用的区别

指针和引用都是一种内存地址的概念。

区别:指针是一个实体,引用只是一个别名。

  • 指针指向⼀块内存,指针的内容是所指向的内存的地址,在编译的时候,则是将“指针变量名-指针变量的地址”添加到符号表中,所以说,指针包含的内容是可以改变的,允许拷贝和赋值,有 const 和⾮ const 区别,甚至可以为空。

  • 引用只是⼀块内存的别名,在添加到符号表的时候,是将”引用变量名-引用对象的地址”添加到符号表中,符号表⼀经完成不能改变,所以引用必须而且只能在定义时被绑定到⼀块内存上,后续不能更改,也不能为空,也没有 const 和⾮ const 区别。

sizeof 引用得到代表对象的⼤⼩,而 sizeof 指针得到的是指针本身的⼤⼩。另外在参数传递中,指针需要被解引用后才可以对对象进行操作,而直接对引用进行的修改会直接作用到引用对象上

作为参数时也不同,传指针的实质是传值,传递的值是指针的地址;传引用的实质是传地址,传递的是变量的地址。


野(wild)指针与悬空(dangling)指针有什么区别?如何避免

  • 野指针(wild pointer)∶就是没有被初始化过的指针。用 gcc -wal1 编译,会出现used uninitialized警告。

  • 悬空指针∶是指针最初指向的内存已经被释放了的一种指针。

都是指向无效内存区域(这里的无效指的是”不安全不可控”)的指针。访问”不安全可控”(invalid)的内存区域将导致”Undefined Behavior”。

如何避免使用野指针?在平时的编码中,养成在定义指针后且在使用之前完成初始化的习惯或者使用智能指针


const 修饰指针如何区分

1
2
3
4
5
const int*p1;//指向整形常量的指针,它指向的值不能修改

int*const p2;//指向整形的常量指针,它不能在指向别的变量,但指向(变量)的值可以修改。

const int *const p3;//指向整形常量 的常量指针。它既不能再指向别的常量,指向的值也不能修改。

函数指针

如果在程序中定义了一个函数,那么在编译时系统就会为这个函数代码分配一段存储空间,这段存储空间的首地址称为这个函数的地址。而且函数名表示的就是这个地址。既然是地址我们就可以定义一个指针变量来存放,这个指针变量就叫作函数指针变量,简称函数指针。

  • 定义

函数指针是指向函数的指针变量。函数指针本身首先是⼀个指针变量,该指针变量指向⼀个具体的函数。

在编译时,每⼀个函数都有⼀个入口地址,该入口地址就是函数指针所指向的地址。有了指向函数的指针变量后,可用该指针变量调用函数,就如同用指针变量可引用其他类型变量⼀样,在这些概念上是⼤体⼀致的。

  • 用途

调用函数和做函数的参数,比如回调函数。

示例:

1
2
3
4
char * fun(char * p) {…} // 函数fun
char * (*pf)(char * p); // 函数指针pf
pf = fun; // 函数指针pf指向函数fun
pf(p); // 通过函数指针pf调用函数fun

堆和栈的区别

由编译器进行管理,在需要时由编译器自动分配空间,在不需要时候自动回收空间,⼀般保存的是局部变量函数参数等。

连续的内存空间,在函数调用的时候,首先⼊栈的主函数的下⼀条可执行指令的地址,然后是函数的各个参数。

⼤多数编译器中,参数是从右向左⼊栈(原因在于采用这种顺序,是为了让程序员在使用C/C++的“函数参数⻓度可变”这个特性时更方便。如果是从左向右压栈,第⼀个参数(即描述可变参数表各变量类型的那个参数)将被放在栈底,由于可变参的函数第⼀步就需要解析可变参数表的各参数类型,即第一步就需要得到上述参数,因此,将它放在栈底是很不方便的。)本次函数调用结束时,局部变量先出栈,然后是参数,最后是栈顶指针最开始存放的地址,程序由该点继续运行,不会产生碎片。

栈是高地址向低地址扩展,栈低高地址,空间较小

由程序员管理,需要手动 new malloc delete free 进行分配和回收,如果不进行回收的话,会造成内存泄漏的问题。

不连续的空间,实际上系统中有一个空闲链表,当有程序申请的时候,系统遍历空闲链表找到第一个大于等于申请大小的空间分配给程序,一般在分配程序的时候,也会空间头部写入内存大小,方便 delete 回收空间大小。当然如果有剩余的,也会将剩余的插入到空闲链表中,这也是产生内存碎片的原因。

堆是低地址向高地址扩展,空间较大,较为灵活。


函数传递参数的⼏种方式

值传递

形参是实参的拷贝,函数内部对形参的操作并不会影响到外部的实参。

指针传递

也是值传递的⼀种方式,形参是指向实参地址的指针,当对形参的指向操作时,就相当于对实参本身进行操作。

引用传递

实际上就是把引用对象的地址放在了开辟的栈空间中,函数内部对形参的任何操作可以直接映射到外部的实参上面。


new / delete ,malloc / free区别

都可以用来在堆上分配和回收空间。

new /delete 是操作符,malloc/free 是库函数。

  • 执行 new 实际上执行两个过程∶
  1. 分配未初始化的内存空间(malloc);
  2. 使用对象的构造函数对空间进行初始化;返回空间的首地址。

​ 如果在第⼀步分配空间中出现问题,则抛出std::bad_alloc 异常,或被某个设定的异常处理函数捕获处理;如果在第二步构造对象时出现异常,则自动调用 delete 释放内存。

  • 执行 delete 实际上也有两个过程
  1. 使用析构函数对对象进行析构;
  2. 回收内存空间(free)。

以上也可以看出 new 和 malloc 的区别,new 得到的是经过初始化的空间,而 malloc 得到的是未初始化的空间。所以 new 是 new ⼀个类型,而 malloc 则是malloc ⼀个字节⻓度的空间。delete 和 free 同理,delete 不仅释放空间还析构对象,delete ⼀个类型,free ⼀个字节⻓度的空间。

为什么有了malloc/free还需要 new/delete?

因为对于⾮内部数据类型而言,光用 malloc/free 无法满⾜动态对象的要求。对象在创建的同时需要自动执行构造函数,对象在消亡以前要自动执行析构函数。由于 malloc/free 是库函数而不是运算符,不在编译器控制权限之内,不能够把执行的构造函数和析构函数的任务强加于 malloc/free,所以有了 new/delete 操作符。


volatile 和 extern 关键字

volatile 三个特性

volatile关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。

  • 易变性:

    在汇编层面反映出来,就是两条语句,下⼀条语句不会直接使用上⼀条语句对应的volatile 变量的寄存器内容,而是重新从内存中读取。

  • 不可优化性:

    volatile 告诉编译器,不要对我这个变量进行各种激进的优化,甚至将变量直接消除,保证程序员写在代码中的指令,⼀定会被执行。

  • 顺序性:

    能够保证 volatile 变量之间的顺序性,编译器不会进行乱序优化。

extern

在 C 语言中,修饰符 extern 用在变量或者函数的声明前,用来说明 “此变量/函数是在别处定义的,要在此处引用”

注意 extern 声明的位置对其作用域也有关系,如果是在 main 函数中进行声明的,则只能在main 函数中调用,在其它函数中不能调用。其实要调用其它文件中的函数和变量,只需把该文件用 #include 包含进来即可,为啥要用 extern? 因为用extern会加速程序的编译过程,这样能节省时间

在 C++ 中 extern 还有另外⼀种作用,用于指示 C 或者 C++函数的调用规范。比如在 C++ 中调用 C 库函数,就需要在 C++ 程序中用 extern “C” 声明要引用的函数。这是给链接器用的,告诉链接器在链接的时候用C 函数规范来链接。主要原因是 C++ 和 C 程序编译完成后在目标代码中命名规则不同,用此来解决名字匹配的问题。


define和const区别(编译阶段、安全性、内存占用)

define

宏定义实际上是在预编译阶段进行处理,没有类型,也就没有类型检查,仅仅做的是遇到宏定义进行字符串的展开,遇到多少次就展开多少次,而且这个简单的展开过程中,很容易出现边界效应,达不到预期的效果。因为 define 宏定义仅仅是展开,因此运行时系统并不为宏定义分配内存,但是从汇编的角度来讲,define 却以⽴即数的方式保留了多份数据的拷贝。

const

const 是在编译期间进行处理的,const 有类型,也有类型检查,程序运行时系统会为 const 常量分配内存,而且从汇编的⻆度讲,const 常量在出现的地⽅保留的是真正数据的内存地址,只保留了⼀份数据的拷贝,省去了不必要的内存空间。而且,有时编译器不会为普通的 const 常量分配内存,而是直接将const常量添加到符号表中,省去了读取和写⼊内存的操作,效率更高。


计算类的大小

1
2
3
4
5
class A{}; sizeof(A) = 1; //空类在实例化时得到⼀个独⼀无二的地址,所以为 1.
class A{virtual Fun(){} }; sizeof(A) = 4(32bit)/8(64bit) //当 C++ 类中有虚函数的时候,会有⼀个指向虚函数表的指针(vptr),一个指针4字节/8字节
class A{static int a; }; sizeof(A) = 1;
class A{int a;}; sizeof(A) = 4;
class A{static int a; int b; }; sizeof(A) = 4;
  • 类大小的计算遵循结构体的对齐原则
  • 类的大小与普通数据成员有关与成员函数和静态成员无关。即普通成员函数,静态成员函数,静态数据成员,静态常量数据成员均对类的大小无影响
  • 虚函数对类的大小有影响,是因为虚函数表指针带来的影响
  • 虚继承对类的大小有影响,是因为虚基表指针带来的影响
  • 空类的大小是一个特殊情况,空类的大小为1
    –[原文链接:https://blog.csdn.net/GreedySnaker/article/details/112762932]

面向对象的三大特性

封装

就是把客观事物封装成抽象的类,并且类可以把自⼰的数据和⽅法只让信任的类或者对象操作,对不可信的进行信息隐藏。⼀个类就是⼀个封装了数据以及操作这些数据的代码的逻辑实体。在⼀个对象内部,某些代码或某些数据可以是私有的,不能被外界访问。通过这种⽅式,对象对内部数据提供了不同级别的保护,以防⽌程序中无关的部分意外的改变或错误的使用了对象的私有部分。

继承

是指可以让某个类型的对象获得另⼀个类型的对象的属性和⽅法。它⽀持按级分类的概念。继承是指这样⼀种能⼒:它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。通过继承创建的新类称为“⼦类”或者“派生类”,被继承的类称为“基类”、“⽗类”或“超类”。继承的过程,就是从⼀般到特殊的过程。要实现继承,可以通过“继承”和“组合”来实现。

继承概念的实现方式有两类∶

  • 实现继承∶实现继承是指直接使用基类的属性和方法而无需额外编码的能力。
  • 接口继承∶ 接口继承是指仅使用属性和方法的名称、但是子类必需提供实现的能力。

多态

就是向不同的对象发送同一个消息,不同对象在接收时会产生不同的行为(即方法)。即一个接口,可以实现多种方法。


多态的实现

继承+虚函数实现

静态多态其实就是重载,因为静态多态是指在编译时期就决定了调用哪个函数,根据参数列表来决定;

动态多态是指通过⼦类重写⽗类的虚函数来实现的,因为是在运行期间决定调用的函数,所以称为动态多态,⼀般情况下我们不区分这两个时所说的多态就是指动态多态。

动态多态的实现与虚函数表,虚函数指针相关。

虚函数相关(虚函数表,虚函数指针),虚函数的实现原理

C++中多态的表象,在基类的函数前加上 virtual 关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数,如果是基类,就调用基类的函数。

当⼀个类中包含虚函数时,编译器会为该类生成⼀个虚函数表,保存该类中虚函数的地址,同样,派生类继承基类,派生类中自然⼀定有虚函数,所以编译器也会为派生类生成自⼰的虚函数表。当我们定义⼀个派生类对象时,编译器检测该类型有虚函数,所以为这个派生类对象生成⼀个虚函数指针,指向该类型的虚函数表,这个虚函数指针的初始化是在构造函数中完成的。


编译器处理虚函数表

对于派生类来说,编译器建⽴虚函数表的过程其实⼀共是三个步骤:

  • 拷贝基类的虚函数表,如果是多继承,就拷贝每个有虚函数基类的虚函数表

  • 当然还有⼀个基类的虚函数表和派生类⾃身的虚函数表共用了⼀个虚函数表,也称为某个基类为派生类的主基类

  • 查看派生类中是否有重写基类中的虚函数, 如果有,就替换成已经重写的虚函数地址;查看派生类是否有⾃身的虚函数,如果有,就追加⾃身的虚函数到⾃身的虚函数表中。

image-20221105161717144

析构函数一般写成虚函数的原因

降低内存泄漏的可能性。

举例来说就是,⼀个基类的指针指向⼀个派生类的对象,在使用完毕准备销毁时,如果基类的析构函数没有定义成虚函数,那么编译器根据指针类型就会认为当前对象的类型是基类,调用基类的析构函数 (该对象的析构函数的函数地址早就被绑定为基类的析构函数),仅执行基类的析构,派生类的自身内容将无法被析构造成内存泄漏

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <iostream>
using namespace std;

class A
{
public:
A() { cout << "A::constructor" << endl; };
~A() { cout << "A::deconstructor" << endl; };
};

class B
{
public:
B() { cout << "B::constructor" << endl; };
~B() { cout << "B::deconstructor" << endl; };
};

class C : public A
{
public:
C() { cout << "C::constructor" << endl; };
~C() { cout << "C::deconstructor" << endl; };
private:
// static B b;
B b;
};

class D : public C
{
public:
D() { cout << "D::constructor" << endl; };
~D() { cout << "D::deconstructor" << endl; };
};

// application
int main()
{
cout << "---------start---------" << endl;
C* pd = new D();
delete pd;
cout << "----------end----------" << endl;


//return 0;
system("pause");
}

image-20221105115854438

如果基类的析构函数定义成虚函数,那么编译器就可以根据实际对象,执行派生类的析构函数,再执行基类的析构函数,成功释放内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <iostream>
using namespace std;

class A
{
public:
A() { cout << "A::constructor" << endl; };
virtual ~A() { cout << "A::deconstructor" << endl; };
};

class B
{
public:
B() { cout << "B::constructor" << endl; };
~B() { cout << "B::deconstructor" << endl; };
};

class C : public A
{
public:
C() { cout << "C::constructor" << endl; };
virtual ~C() { cout << "C::deconstructor" << endl; };
private:
// static B b;
B b;
};

class D : public C
{
public:
D() { cout << "D::constructor" << endl; };
virtual ~D() { cout << "D::deconstructor" << endl; };
};

// application
int main()
{
cout << "---------start---------" << endl;
C* pd = new D();
delete pd;
cout << "----------end----------" << endl;


//return 0;
system("pause");
}

image-20221105135950996


构造函数为什么一般不定义为虚函数

1)因为创建一个对象时需要确定对象的类型,而虚函数调用只需要知道“部分的”信息,即只需要知道函数接⼝,而不需要知道对象的具体类型,编译器无法知道对象的实际类型是类本身还是类的派生类等等;

2)虚函数的调用需要虚函数表指针,而该指针存放在对象的内存空间中;若构造函数声明为虚函数,那么由于对象还未创建,还没有内存空间,更没有虚函数表地址用来调用虚函数即构造函数了
————————————————
原文链接:https://blog.csdn.net/libaineu2004/article/details/85684224


构造函数或析构函数中调用虚函数会怎么样

从语法上来说,,构造函数是可以调用虚函数的, 编译并不会有问题,但是这么做起不到并起不到虚函数本身的动态绑定作用,只会调用自身所在类的对应虚函数, 也就失去了这么做的意义, 同时从结果上也看出了派生类对象构造前先调用基类构造。

毕竟,如果我们需要这样的结果的话,为什么不在一开始就把该虚函数声明为普通函数呢?这样可以达到同样的效果:

  • 将该虚函数声明为普通函数的话,同样达到了上述实验一样的效果

这便是所谓的:

  • 但是从效果上看,往往不能达到需要的目的

析构函数的作用

析构函数与构造函数的作用相反,用于撤销对象的一些特殊处理了任务,可以是释放对象分配的内存空间

析构函数没有参数,没有返回值,且并不能重载,在一个类中只能有一个析构函数。当撤销对象时,编译器也会自动调用析构函数。

每个类必须有一个析构函数,可以用户自定义,也可以编译器自动生成。一般析构函数定义为类的公有成员。


构造函数的执行顺序

  • 基类构造函数:如果有多个基类,则调用顺序时某类在类派生表中出现的顺序,而不是它们在成员初始化表中的顺序。
  • 成员类对象构造函数:如果有多个成员类对象,则构造函数的调用顺序是对象在类中被声明 的顺序,而不是它们出现在成员初始化表中的顺序。
  • 派生类构造函数。

析构函数的执行顺序

  • 调用派生类的析构函数;
  • 调用成员类对象的析构函数;
  • 调用基类的析构函数。

纯虚函数

声明格式:

1
virtual  类型  函数名(参数列表)= 0

声明一个纯虚函数的目的就是为了让派生类只继承函数的接口,而且派生类中必需提供一个这个纯虚函数的实现,否则含有纯虚函数的类将是抽象类,不能进行实例化。

对于纯虚函数来说,我们其实可以给它提供实现代码的,但是由于抽象类不能实例化,调用这个实现的唯一方式是在派生类对象中指出其class名称来调用。


静态绑定,动态绑定

首先,静态类型就是它在程序中被声明时所采用的类型,在编译期间确定。动态类型则是指“目前所指对象的实际类型”,在运⾏期间确定。

静态绑定,绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发⽣在编译期间

动态绑定,所对应的函数或属性依赖于动态类型,发⽣在运⾏期间

virtual 函数是动态绑定的,非虚函数是静态绑定的,缺省参数值也是静态绑定的


深拷贝,浅拷贝

当出现类的等号赋值时,会调用拷贝函数,在未定义显示拷贝构造函数的情况下, 系统会调用默认的拷贝函数-即浅拷贝,它能够完成成员的⼀⼀复制。当数据成员中没有指针时,浅拷贝是可⾏的。

但当数据成员中有指针时,如果采用简单的浅拷贝,则两类中的两个指针指向同⼀个地址,当对象快要结束时,会调用两次析构函数,而导致指野指针的问题。

这时必需采用深拷贝。深拷贝与浅拷贝之间的区别就在于深拷贝会在堆内存中另外申请空间来存储数据,从而也就解决来野指针的问题。简而言之,当数据成员中有指针时,必需要用深拷贝更加安全。

* 指针变量中的值是非法的内存地址,进而形成野指针
野指针不是NULL指针,是指向不可用内存地址的指针。

  • 浅拷贝

浅拷贝只复制某个对象的引用,而不复制对象本身,新旧对象还是共享同一块内存

  • 深拷贝

深拷贝会创造一个一摸一样的对象,新对象和原对象不共享内存,修改新对象不会改变原对对象。


调用拷贝构造函数的情况

类的对象需要拷贝时,拷贝构造函数将会被调用:

  • 一个对象以值传递的方式传入函数体,需要拷贝构造函数创建一个临时对象压入到栈空间中。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class Complex
    {
    };
    void Fun(Complex c1)
    {
    }

    int main()
    {
    Complex c1(1,2);
    Fun(c1);
    }
  • 一个对象以值传递的方式从函数返回,需要执行拷贝构造函数创建一个临时对象作为返回值。

    1
    2
    3
    4
    5
    Complex Fun()
    {
    Complex c(10,20);
    return c;
    }
  • 一个对象需要通过另外一个对象进行初始化

    1
    2
    3
    4
    5
    6
    int main()
    {
    Complex c1(1,2);
    Complex c2(c1);
    Complex c3=c1;
    }

为什么拷贝构造函数必须引用传递,不能是值传递

为了防止递归调用。

当一个对象需要以值的方式进行传递时,编译器就会生成代码调用它的拷贝构造函数生成一个副本,如果类A的拷贝构造函数的参数不是引用传递,而是采用值传递,那么就又需要为了传递给拷贝构造函数的参数创建临时对象,而又一次调用类A 的拷贝构造函数,形成一个无限递归。


结构体内存对齐方式

  • 规则

    对于结构体中的各个成员,第一个成员位于偏移为0的位置,以后的每个数据成员的偏移量必须是min的倍数;

    在所有的数据成员完成各自对齐之后,结构体或联合体本身也要进行对齐,整体长度是min的倍数。

    *min : #pragma pack()制定的数,长度最长的数据成员

  • 作用

    经过内存体对齐之后,CPU的内存访问速度大大提升。因为CPU把内存当成是一块一块的,块的大小可以是2,4,8,16个字节,因此CPU在读取内存的时候是一块一块进行读取的,块的大小成为内存读取粒度。比如说CPU要读取一个4个字节的数据到寄存器中(假设内存读取粒度是4),如果数据是从0开始的,那么直接将0-3四个字节完全读取到寄存器中进行处理即可。

    如果数据是从1字节开始i,就首先要将前4个字节读取到寄存器,并再次读取4-7个字节数据进入寄存器,接着把0字节,5,6,7字节的数据剔除,最后合并1,2,3,4字节的数据进入寄存器,所以说,当内存没有对齐时,寄存器进行了很多额外的操作,大大降低了CPU的性能。

    另外,有点CPU遇到未进行内存对齐的处理直接拒绝处理,不是所有的硬件平台都能访问任意地址上的任意数据,某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。所以内存对齐还有利于平台移植。


内存泄漏如何避免

内存泄漏简单的说就是申请了一块内存空间,使用完毕之后没有释放。一般表现方式是程序运行时间越长,占用内存越多,最终用尽全部内存,整个系统崩溃。由程序申请的一块内存,且没有任何一个指针指向它,那么这块内存就泄漏了。

检测避免

  • 检测:Linux中使用swap命令观察还有多少可用的交换空间;

  • 还可以使⽤ 其他⼀些 /usr/bin/stat ⼯具如 netstat、vmstat 等。如发现波段有内存被分配且从不释放,⼀个可能的解释就是有个进程出现了内存泄漏。

  • 当然也有⽤于内存调试,内存泄漏检测以及性能分析的软件开发⼯具 valgrind 这样的⼯具来进⾏内存泄漏的检测。

  • 不要手动管理内存,可以尝试在适用的情况下使用智能指针。


二叉树

  1. 二叉树中,第 i 层最多有 $2^i$-1 个结点。
  2. 如果二叉树的深度为 K,那么此二叉树最多有 $2^K$-1 个结点。(满二叉树)
  3. 二叉树中,终端结点数(叶子结点数)为 $n_0$,度为 2 的结点数为 $n_2$,则 $n_0$=$n_2$+1。

二叉搜索树

提供对数时间的元素插入和访问。

节点的放置规则:任何节点的键值一定大于其左子树的每一个节点的键值,并小于其右子树中的每一个节点的键值。所以,一直向左走可以取得最小值,一直向右走可用得到 最大值。

插入:从根节点开始,遇键值较大的向左,较小的向右,直到尾端,即插入点。

删除:如果删除点只有一个子节点,则直接将其子节点连至父节点,如果删除点有两个子节点,以右子树中的最小值代替要删除的位置。

平衡二叉树

没有任何一个节点过深。

AVL-tree:高度平衡的平衡二叉树,要求任何节点的左右子树高度相差最多为1的平衡二叉树。当插入新的节点破坏平衡性的时候,从下往上找到第一个不平衡点,需要进行单旋转,或者双旋转进行调整。

先序 中序 后序

img

  • 先序遍历

    D-L-R 根-左-右

    A-B-E-F-C-D

  • 中序遍历

    L-D-R 左-根-右

    E-B-F-A-C-D

  • 后序遍历

    L-R-D 左-右-根

    E-F-B-D-C-A

在二叉树遍历中先序和中序或者中序和后序可以确定唯一的一棵二叉树。

满二叉树 完全二叉树

  • 满二叉树
1
2
一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K,且结
点总数是(2^k) -1 ,则它就是满二叉树。

​ 满二叉树除了满足普通二叉树的性质,还具有以下性质:

  1. 满二叉树中第 i 层的节点数为$2^{n-1}$个。
  2. 深度为 k 的满二叉树必有 $2^k$-1 个节点 ,叶子数为 $2^{k-1}$。
  3. 满二叉树中不存在度为 1 的节点,每一个分支点中都两棵深度相同的子树,且叶子节点都在最底层。
  4. 具有 n 个节点的满二叉树的深度为 log$_2$(n+1)。

如果二叉树中除去最后一层节点为满二叉树,且最后一层的结点依次从左到右分布,则此二叉树被称为完全二叉树。

  • 完全二叉树

    如果二叉树中除去最后一层节点为满二叉树,且最后一层的结点依次从左到右分布,则此二叉树被称为完全二叉树。

image-20221106204307639

完全二叉树除了具有普通二叉树的性质,它自身也具有一些独特的性质,比如说,n 个结点的完全二叉树的深度为 ⌊log2n⌋+1。

  1. 当 i>1 时,父亲结点为结点 [i/2] 。(i=1 时,表示的是根结点,无父亲结点)
  2. 如果 2*i>n(总结点的个数) ,则结点 i 肯定没有左孩子(为叶子结点);否则其左孩子是结点 2*i 。
  3. 如果 2*i+1>n ,则结点 i 肯定没有右孩子;否则右孩子是结点 2*i+1 。

红黑树

红黑树是一种自平衡的二叉查找树,是一种高效的查找树。

性质1:每个节点要么是⿊⾊,要么是红⾊。

性质2:根节点是⿊⾊。

性质3:每个叶⼦节点(NIL)是⿊⾊。

性质4:每个红⾊结点的两个⼦结点⼀定都是⿊⾊。

性质5:任意⼀结点到每个叶⼦结点的路径都包含数量相同的⿊结点。


define、const、typedef、inline 使⽤⽅法

const与#define的区别

  • const 定义的常量是变量带类型,⽽ #define 定义的只是个常数不带类型

  • define 只在预处理阶段起作⽤,简单的⽂本替换,⽽ const 在编译、链接过程中起作⽤;

  • define 只是简单的字符串替换没有类型检查。⽽const是有数据类型的,是要进⾏判断的,可以避免⼀些低级错误;

  • define 预处理后,占⽤代码段空间,const 占⽤数据段空间

  • const 不能重定义,⽽ define 可以通过 #undef 取消某个符号的定义,进⾏重定义;

  • define 独特功能,比如可以⽤来防⽌⽂件重复引⽤

#define与别名typedef的区别

  • 执⾏时间不同,typedef 在编译阶段有效,typedef 有类型检查的功能;#define 是宏定义,发⽣在预处理阶段,不进⾏类型检查;
  • 功能差异,typedef ⽤来定义类型的别名,定义与平台⽆关的数据类型,与 struct 的结合使⽤等;
1
2
3
4
5
6
7
struct students
{
char sex;
char name[120];
int ages;
};

结构体重新定义数据名常用的方法有:

1
2
3
4
5
6
7
struct students
{
char sex;
char name[120];
int ages;
}std;
std.name[20]="zhangsan"

另外也可以用typedef定义:

1
2
3
4
5
6
7
8
struct students
{
char sex;
char name[120];
int ages;
};
typedef struct students std;
std.name[20]="zhangsan"
  • #define 不只是可以为类型取别名,还可以定义常量、变量、编译开关等。

  • 作用域不同,#define没有作用域的限制,只要是之前预定义过的宏,在以后的程序中都可以使用,而typedef有自己的作用域。

define与inline的区别

  • define是关键字,inline是函数;
  • 宏定义在预处理阶段进⾏⽂本替换,inline 函数在编译阶段进⾏替换;
  • inline 函数有类型检查,相比宏定义比较安全;
1
2
3
4
5
6
7
8
9
10
11
inline int add(int a, int b)
{
return a + b;
}
int main()
{
int ret = 0, a = 1, b = 2;
ret = a + b;
ret = add(a, b);
}


预处理、编译、汇编、链接程序

⼀段高级语言代码经过四个阶段的处理形成可执⾏的目标二进制代码:

预处理器→编译器→汇编器→链接器

  • 预处理阶段: .c -> .i

    写好的高级语言的程序⽂本比如 hello.c,预处理器根据 #开头的命令,修改原始的程序,如#include<stdio.h> 将把系统中的头⽂件插⼊到程序⽂本中,通常是以 .i 结尾的⽂件。

  • 编译阶段: .i -> .s

    编译器将 hello.i ⽂件翻译成⽂本⽂件 hello.s,这个是汇编语言程序。高级语言是源程序。所以注意概念之间的区别。汇编语言程序是⼲嘛的?每条语句都以标准的⽂本格式确切描述⼀条低级机器语言指令。不同的高级语言翻译的汇编语言相同。

  • 汇编阶段: .s -> .o

    汇编器将 hello.s 翻译成机器语言指令。把这些指令打包成可重定位目标程序,即.o⽂件。hello.o是⼀个二进制⽂件,它的字节码是机器语言指令,不再是字符。前面两个阶段都还有字符。

  • 链接阶段

​ 比如 hello 程序调⽤ printf 程序,它是每个 C 编译器都会提供的标准库 C 的函数。这个函数存在于⼀个名叫 printf.o 的单独编译好的目标⽂件中,这个⽂件将以某种⽅式合并到 hello.o 中。链接器就负责这种合并。得到的是可执⾏目标⽂件


fork,wait,exec函数

进程常用的三个函数。

  • fork: 使⽤ fork 拷⻉出来⼀个⽗进程的副本产生子进程,此时只拷贝了父进程的页表,两个进程都读同一块内存。fork 从⽗进程返回⼦进程的 pid,从⼦进程返回 0。

  • exec: 当有进程写的时候使⽤写实拷⻉机制分配内存,exec 函数可以加载⼀个 elf ⽂件去替换⽗进程,从此⽗进程和⼦进程就可以运⾏不同的程序了。exec 执⾏成功则⼦进程从新的程序开始运⾏,⽆返回值,执⾏失败返回 -1。

  • wait: 用在父进程中等待子进程结束后,回收子进程,解除阻塞;若子进程一直没有退出,则阻塞住父进程。


动态联编与静态联编

静态联编是指联编⼯作在编译阶段完成的,这种联编过程是在程序运⾏之前完成的。静态联编对函数的选择是基于指向对象的指针或者引⽤的类型。其优点是效率高,但灵活性差

动态联编是指联编在程序运⾏时动态地进⾏,根据当时的情况来确定调⽤哪个同名函数,实际上是在运⾏时虚函数的实现。动态联编对成员函数的选择是基于对象的类型,针对不同的对象类型将做出不同的编译结果。

C++中⼀般情况下的联编是静态联编,但是当涉及到多态性和虚函数时应该使⽤动态联编。动态联编的优点是灵活性强,但效率低

实现动态联编三个条件

  • 必须把动态联编的⾏为定义为类的虚函数

  • 类之间应满⾜⼦类型关系,通常表现为⼀个类从另⼀个类公有派⽣⽽来;

  • 必须先使⽤基类指针指向⼦类型的对象,然后直接或间接使⽤基类指针调⽤虚函数