C++笔记

针对《C++八股文-小贺》这个pdf的内容记录的笔记

C++部分🔗

智能指针🔗

  • 智能指针能够在超出定义域时自动释放内存,防止内存泄漏的发生

常用函数🔗

  • 常用接口如下
    • release()用于将智能指针内部的指针置为nullptr
    • get()用于获得其中的原生指针
  • 有四种智能指针如下
    • auto_ptr:只能有一个auto_ptr指向一个原生指针,新的auto_ptr指向原生指针时,调用原来的auto_ptr会报错(但不会出现编译错误)
    • unique_ptr:和auto_ptr同理,一个原生指针只能被一个unique_ptr指向,若多个unique_ptr指向同一原生指针会出现编译错误
    • shared_ptr:可以有多个shared_ptr指向同一原生指针,每个shared_ptr相当于原生指针的一个引用,当原生指针的所有引用都被销毁时原生指针释放(通过一个计数器确定引用数),可通过use_count()查看资源引用个数
    • weak_ptr:该指针是配合shared_ptr使用的,它只能由shared_ptr或其他weak_ptr构造。该指针不会对计数进行影响。weak_ptr可用来解决shared_ptr的循环引用问题
  • 资源释放示例
  auto p = make_shared<int>(1);
  auto q = p;//此时1的引用数为2
  auto r = make_shared<int>(2);//此时2的引用值为1
  r = p;//此时1的引用值变为3,2的资源被回收,引用值为0
  • 循环引用示例
  class A {
  public:
      shared_ptr<int> b;
  };
  class B {
  public:
      shared_ptr<int> a;
  }
  
  int main() {
      auto a_ = make_shared<A>();
      auto b_ = make_shared<B>();
      a_.b = b_;
      b_.a = a_;//此时出现循环引用
      cout << a_.use_count() << " " << b_.use_count();
      //输出结果为2 2,在函数结束时两个共享指针的计数都减一,但仍不为0,所以不会释
      //放内存
  }

const🔗

  • const修饰指针
    const int* p = 10;//此处限定值不能改变,即p中的值10不能改变,但p可以指向其他地址
    int* const q = 10;//此处限定指针指向不能改变,即q不能指向其他地址,但值10可以改变
    const int* const r = 10;//以上两者的结合
    //可以根据const与*的位置判断
  • const修饰成员变量
    • 同一类中不同对象的const成员变量值可以不同,不能在类申明时确定值,一般在构造函数的初始化列表中初始化。
    #include <iostream>
    using namespace std;
    
    class A{
    private:
        const int p;
    
    public:
        A(int a): p(a){//在此初始化
        } 
    
        void print() {
            cout << p << endl;
        }
    }; 
    
    int main() {
        A a(2);
        A b(3);
        a.print();
        b.print();
        //输出结果为2 \n 3
    }
  • const修饰类成员函数
    • 若对象为常量对象,则只能访问常量成员函数,不能访问非常量函数。
    • 非常量对象则都可以访问。

常量函数🔗

  • 常量函数只有权读取外部数据,无法修改外部数据
    void func(int i) const {};
    int global;
    void func(int i) const {global = i;}//错误,不能修改外部数据

类型转换🔗

  • static_cast
    • 强制类型转换,一般用于基本类型转换与继承的向上转换
    double a = 1.1;
    int b = static_cast<int>(a);
  • dynamic_cast
    • 一般用于类型安全的向下转换
    • 该转换会检查向下转换是否安全,一般根据对象来进行判断
    class A{};
    class B: public A{};
    class C: public C{};
    
    int main() {
        A* pa = new A();
        B* pb = dynamic_cast<A*>(pa);//转型失败,因为pa指向的对象(A)无法安全转型
        
        pa = new C();
        B* pb = dynamic_cast<A*>(pa);//转型成功,pa指向的对象(C)能够安全转型
    }

malloc/free 与 new/delete区别🔗

  • 简单来说,mallocfree仅用于分配内存空间,而newdelete在声明/回收内存空间后/前还需要执行构造函数/析构函数

虚函数相关🔗

class Base {
public:
    virtual void funcA() {cout << "Base A";}
    void funcB() {cout << "Base B";}
};

class Derived : public Base {
public:
    void funcA() final {cout << "Derived A";}
    void funcB() {cout << "Derived B";}
};

int main() {
    Base* p = new Derived();
    p.funcA();
    p.funcB();
    //输出结果:
    //Derived A
    //Base B
    //定义为虚函数的函数会根据对象进行动态绑定
    //非虚函数则会根据指针类型进行早绑定,与对象所属类无关
}
  • 析构函数一般为虚函数,因为需要根据对象调用子类的析构函数,若非虚函数最后可能调用了基类的析构函数,导致子类的部分内存未完全释放

构造函数执行顺序🔗

  • 基类构造函数,若有多个基类,则按派生表中顺序
  • 成员类构造函数。将成员对象进行构造,顺序为声明顺序
  • 派生类自己的构造函数

析构函数执行顺序🔗

  • 派生类自己的析构函数
  • 成员类对象析构函数
  • 基类析构函数

调用拷贝函数的情况🔗

  • 对象以传值方式传入函数
  • 对象以传值方式从函数返回
  • 一个对象由另一个对象初始化

拷贝构造函数必须使用引用传递🔗

因为值传递方式会调用对象的拷贝构造函数,而调用拷贝构造函数又是值传递方式,会造成无限递归

生成二进制文件过程🔗

  • 预处理
    • #include的头文件内容插入程序中,将#define的内容进行替换等等,基本上是将所有以#开头的部分进行操作
    • 生成 .i 文件
  • 编译
    • 将预处理生成的文件编译为汇编代码
    • 生成 .s 文件
  • 汇编
    • 将汇编代码转换为二进制文件
    • 生成 .o 文件
  • 链接
    • 将多个编译好的目标文件与静态库进行链接,生成最终的可执行文件
    • 生成二进制文件

Deque底层原理🔗

  • deque的内存是由多段连续内存组成的,它设置了一个map用于储存连续地址的首地址,真正的数据缓存地址是在这些连续地址中。迭代器由first,last,cur,node四个部分组成,其中first指向当前缓冲区头部,last指向当前缓冲区尾部,cur指向缓冲区现行元素,node为map中指向当前缓冲区的node节点。

Vector🔗

  • 对于vector,使用push_back时,若内存不足,则会重新分配一块更大的空闲内存,并将当前内容拷贝过去,比较消耗资源。
  • push_back vs emplace_back
    • 首先,push_back只能传入符合模板的对象,而emplace_back可直接传入构造函数的参数来插入新的对象。
    • push_back插入对象的方式为:在临时内存空间创建对象,再将其拷贝至容器的内存中
    • emplace_back插入方式为:直接在容器对于的内存位置创建新对象,相对来说少了拷贝的一步
    • emplace_back在使用构造参数方式传入新对象时,效率会高于push_back

迭代器的删除🔗

  • 一般使用erase()函数删除迭代器
  • 对于不同的容器,迭代器的删除+遍历需要进行不同的操作
  • 对于vector,deque等内存地址连续的容器,删除方法如下
    vector<int> vec = {1, 2, 3, 4, 5, 6};
    vector<int>::iterator iter = vec.begin();
    while (iter != vec.end()) {
        if ((*it) % 2 == 0) {
            it = vec.erase(it);
        }
        else {
            it ++;
        }
    }
  • 由于删除迭代器后,后面的迭代器向前移动,所以原先的后继迭代器作废,不能直接++,但erase()函数会返回下一个有效的迭代器
  • 对于map,set这种关联容器,直接++即可
    map<string, int> mp;
    map<string, int>::iterator iter = mp.begin();
    while (iter != mp.end()) {
        if ((*it) % 2 == 0) {
            it = vec.erase(it);
        }
        it ++;
    }
  • list这种不连续分配内存的容器,以上两种方法都可以

在main函数之前执行的函数🔗

  • ___attribute(constructor) void before() {}
  • static int a = before();//全局静态变量在main函数之前初始化
  • int a = []() {cout << "before main";}\\lambda表达式

判断计算机是大端存储还是小端存储🔗

  int a = 0x12345678;
  int *b = &a;
  cout << b[0];//若为0x12则为大端,若为0x78则为小端
  int main() {
      union{
          int a;
          char b;
      } data;
      data.a = 1;
      
      cout << data.b;//若输出1,则代表a的低位与b的部分共用,及低字节在低地址,为小端
  }

lambda表达式🔗

  • 完整语法如下
    [ capture-list ] ( params ) mutable(optional) constexpr(optional) \
    (c++17) exception attribute -> ret { body }
  • []代表捕捉列表,即从当前作用域中捕获变量,捕获的变量可在函数体内直接使用
    • [=]: 表示以值方式捕获所有变量
    • [&]: 表示以引用方式捕获所有变量
    • [x]: 值捕获x
    • [&x]: 引用捕获x
    • [=, &x]: 所有变量值捕获,除x使用引用捕获
    • [&, x]: 所有变量引用捕获,除x值捕获
    • 值捕获和引用捕获的区别:前者无法在lambda函数修改外部的变量,后者可以
  • ()代表参数列表
  • mutable使得值捕获的变量可以被修改
  • constexpr可指定该lambda函数为常量函数
  • exception可指定该lambda函数抛出的异常
  • attribute可指定lambda表达式的特性
  • ret指定返回值类型
  • {}函数体

右值🔗

  • 左值右值的根本区别在于能否获取内存地址
  • 一般来说,临时变量为右值,有名变量为左值
  • 右值的意义在于可以配合move函数,直接将资源进行转移,而不是创建临时对象后进行拷贝
  • 根据以下例子可以体现右值的特点
    A get() {
        A p;
        return a;
    }
    Person p = get();
  • 以上过程中,调用了三个构造函数和两个析构函数
  • 可以改为以下形式
    class A {
        int val;
        A(int x) {val = x;}
        A(A&& rhs) {//移动构造函数
            val = rhs.val;
        }
    };
    
    int main() {
        A a = new A(std::move(A(10)));
    }

final和override🔗

  • final的意义是为了禁止派生类的派生类对基类的虚函数进行重写,即禁止继续重写
  • override的意义在于若标记override后在父类中找不到可重写的函数,则会编译报错
    class A {
        virtual void func(){}
    };
    
    class B : public A {
        void func() override final {}
    }
    
    class C : public B {
        //void func() {} 禁止,因为B的func函数已经声明final
    }

静态assert🔗

  • 静态static_assert一般用于模板,因为模板是编译期的概念,无法使用assert

  • 用例如下

    template< class T >
    struct Check {
    static_assert( sizeof(int) <= sizeof(T), "T is not big enough!" ) ;
    } ;//T在编译期间确定,所以使用static_assert

STL部分🔗

new, delete🔗

  • 一般来说,使用new创建一个对象,首先调用operator new分配内存空间,再调用对象构造函数初始化这段内存
  • new operator,operator new,placement new
    • new operator不可更改,作用同上
    • operator new可在类中重载
    • placement new语法:T *p = new (ptr) T(),其中ptr为一个缓冲区指针。placement new的意义是将缓冲区的分配与对象的构造分离。你可以先构造一个缓冲区如下char *ptr = new char[sizeof(T)],然后再使用placement new,这样就让缓冲区的创建和对象的创建分离。

STL的construct与destroy🔗

  • STL的构造和销毁都使用这两个函数,函数体如下
    template<typename T1, typename T2>
    inline void construct(T1* p, const T2& value) {
        new (p) T1(value);
    }
    
    
    template<typename T1>
    inline void construct(T1* p) {
        new (p) T1;
    }
    template<typename T1>
    inline void destroy(T1* p)
    {
        p->~T1();
    }
  • destroy还有其他模式,暂且不谈

STL Alloc🔗

  • 代码位于sgi_alloc.h
  • alloc为STL动态分配空间
  • alloc分为两级配置器,其目的是解决内存碎片问题
  • 分配内存时分为两种情况,若需要的内存大于128B,则使用第一级配置器,直接使用malloc(),free()等函数分配内存,并在内存不足时调用oom_malloc,oom_realloc等函数尝试释放内存
  • 若使用第二级配置器,则更加复杂。
  • 首先存在一个叫free_list的结构,它是一个长度为16的指针数组,用于维护一个链表,这段内存空间由一组union组成
    union Obj{
        union Obj *p;
        char data[1];//两个等长指针,可用于存储数据或作为list指针
    };
  • 不存储数据时,Obj作为指针指向下一个内存块;存储数据时,作为缓冲区储存数据
  • free_list的i位分别表示其指向的内存块大小为(i + 1) * 8B
  • 若申请的内存为nB(n < 128),则首先将n扩大至最近的8的倍数
    define __ALIGN = 8;
    static size_t ROUND_UP(size_t bytes) {
        return (bytes + __ALIGN - 1) & ~(__ALIGN - 1);
    }
  • 接着在free_list中寻找合适的内存块并取出
  • 若对于大小的链表为空,则开始为该链表申请内存
    • 首先向内存池申请内存。若当前链表对于内存块大小为size,则默认申请大小为20 * size的内存,即默认分配20个内存块。
    • 若不能分配20块,则分配能够分配数量的内存块
    • 若一块都不能分配了,则向堆申请内存(申请前会将内存池中剩余的零头分配给free_list
    • 若堆中内存都不够用了,则在free_list中拿出一块作为内存池

C++泛型🔗

  • C++有两种编程范式,面向对象编程与泛型编程
  • 面向对象编程为动态多态,利用虚函数标来确定数据类型
  • 泛型编程为静态多态,在编译期间确定数据类型,执行效率相对较高

Tamplate类型推导🔗

  • 对于某个泛型函数func(I iter),需要接受各种类型的迭代器。若此时func函数的实现中需要知道*iter对于的数据类型(比如需要一个临时变量,但不知道类型不方便声明),则有以下方法
    • 若在函数体内需要知道数据类型,则在func中内嵌如下函数,则可以通过C++的类型推导得到对应的类型
      template<class I>
      inline void func(I iter) {
          func_imp(iter, *iter);
      }
      
      template<class I, class T>
      void func_imp(I iter, T t) {
          //执行func需要执行的操作
      }
  • 若在返回值需要知道数据类型,则需要一系列操作
  • 首先在每个迭代器中定义一个内嵌型别
      template<typename T>
      class MyIter {
      public:
      	typedef T value_type; //内嵌类型声明
      	MyIter(T *p = 0) : m_ptr(p) {}
      	T& operator*() const { return *m_ptr;}
      private:
      	T *m_ptr;
      };
  • 则在返回值处可用以下方式
      template<class MyIter>
      typename MyIter::value_type func(MyIter iter) {
          return *iter;
      }
  • 但以上方法对原生指针无效(原生指针也是一种迭代器,但其中没有value_type这一内嵌类型)
  • 此时需要使用模板的偏特化
      template<typename I>
      class A{}//泛化版本
      
      template<typename I>
      class A<I*>{}//特化版本,只针对原生指针
  • 此时可以准备两个iterator_traits类(traits,萃取)
      template<typename Iterator>
      struct Iterator_traits {
      //类型萃取机
      typedef typename Iterator::value_type value_type; //value_type 就是 Iterator 的类型型别
      }
      
      template<typename Iterator>
      struct Iterator_traits<Iterator*> {
          typedef Iterator value_type;
      }
  • 加入以上两个萃取类后,之前的func改为以下形式
      template<typename Iterator>
      typename Iterator_traits<Iterator>::value_type func(Iterator iter) {
          return *iter;
      }
  • 这样就可以处理之前的原生指针问题了,这种方式被称为Traits编程技法

迭代器型别🔗

  • 迭代器有五个常用型别
    • value_type:迭代器所指对象的类型
    • difference_type:表示迭代器之间的距离
    • reference_type:表示迭代器所指对象的引用
    • pointer_type:表示迭代器所指对象的指针
    • iterator_category:表示迭代器的类型,迭代器有五种类型
      • Input Iterator:只读迭代器,支持* -> == != ++等操作
      • Output Iterator:只写迭代器,支持++ *等操作
      • Forward Iterator:单向移动的读写迭代器,支持只读只写的所有操作
      • Bidirectional Iterator:双向移动的读写迭代器,支持单向读写器所有操作加上-
      • Random Iterator:随机访问的读写迭代器,支持前四种迭代器所以操作加上[]