[C++]菜狗程序员 C++ 万字总结,从入门到入土(上篇)

117 阅读16分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

菜狗程序员 C++ 万字总结,从入门到入土(上篇)

C++ 语法

namespace

namespace 用来解决 C 语言命名缺陷问题,通过使用 namespace 可以定义一个域。因为 C 语言容易产生命名冲突,也就是变量或函数的重定义,故可用 namespace 将同名变量或函数隔离,从而解决命名冲突问题。

缺省参数

缺省参数就是可以在函数定义或声明时给函数从右向左连续的参数提供一个默认值。缺省参数不能再声明和定义时同时存在。

函数重载

函数重载就是在同一个域中,存在函数名相同,参数类型或者个数不同的函数。C 语言是不支持函数重载的,C++ 通过函数名修饰规则来解决这个问题。

C/C++ 编译过程;

  1. 预处理:头文件展开、去注释、宏替换、条件编译,生成 .i文件
  2. 编译:检查语法,生成 .s汇编文件
  3. 汇编:把汇编代码转换成 .o二进制代码文件
  4. 链接:将.o文件链接到一起生成可执行文件,也就是在上一步生成文件中找到对应指令的地址。

C++ 不同于 C 语言的是:C++ 在汇编阶段生成的二进制文件中所包含的符号表中的函数命名方式不是直接使用函数名,而是使用函数名进行修饰,也就是将函数参数的类型和个数两者的信息通过某种方式添加到函数名中,从而可以调用函数名相同的函数。

引用

引用从编译角度看,引用和指针是一样的。从语法角度看,引用是个变量取别名。因为 C 语言指针使用过于复杂,C++ 通过引用简化了指针的使用。引用在定义时必须先初始化。在赋值符号两端存在类型偏差时,就会产生临时变量,此时引用的就是临时变量。

引用使用场景有两个:

  1. 作函数参数,作用类似于 C 语言的传出参数
  2. 作函数返回值,当函数采用传值返回时,return 的变量会被拷贝一份作为临时变量,函数返回的是临时变量,临时变量具有常性;当函数采用引用返回时,也会产生临时变量,只不过此时这个临时变量是 return 变量的引用(别名),并且这个临时变量不占用内存。

指针和引用的区别:

  1. 引用必须在定义时初始化,指针不用
  2. 引用在初始化后就不能引用其它实体,指针可以
  3. sizeof 引用的大小为引用类型的大小,指针永远都是4/8字节
  4. 引用没有 NULL 引用,指针有 NULL 指针
  5. 引用加 1 就是引用的实体类型 加 1,指针加 1 则是指针向后偏移一个类型的大小

内联函数

通过在函数前添加关键字 inline 可以将函数变成内联函数,内联函数在调用时,会直接在调用的地方展开指令,因此对于一些频繁调用的小函数适合定义成内联。内联函数主要为了解决 C 语言宏函数的缺陷:

  1. 宏函数不支持调试
  2. 宏函数容易出错
  3. 宏函数没有类型安全检查

直接在类中定义的函数,默认就是内联函数。

auto

自动推导类型。不能用作函数形参和定义数组。

范围 for

自动遍历可迭代对象,直到结束。

nullptr

空指针,用来替换 NULL。因为 NULL 就是 0,不是指针类型。

C++ 类与对象

类的定义

class 类名
{
public:
    // 成员函数声明
    void Init();
    ...
private:
    // 成员变量声明
    int age;
    char* name;
};
// 成员函数定义
void 类名::Init()
{

}
// 类的实例化,同时也完成了成员变量的定义
类名 S;

其中,成员函数也可以在类域中直接定义。public 和 private 为访问限定符,一般想要给外部访问用 public,外部不能访问的用 private。

C++ 也可以用 struct 来声明类,不同的是:struct 成员默认访问方式为 public,class 默认访问方式为 private。

面向对象三大特性

  1. 继承:保持原有类基础上进行扩展。
  2. 多态:不同类型的对象同一行为的多种形态。
  3. 封装:将对象的数据和操作数据的方法进行结合,隐藏对象的属性和实现细节,仅通过开发的接口来和对象进行交互。

类对象只保存成员变量,成员函数存放在公共代码段。

this 指针

C++ 给类对象的非静态成员函数提供了一个隐藏的 this 指针,指针指向调用该方法的类对象,在成员函数中对成员变量的操作都是通过该指针去完成的。

默认(缺省)成员函数

  1. 构造函数:用于初始化对象,不是开辟空间创建对象。对于 C++ 内置类型的成员变量,默认构造函数不会初始化;对于自定义类型,默认构造函数会调用自定义类型的默认构造函数。默认的构造函数就是不用传递参数的构造函数。
  2. 析构函数;用于对象销毁时完成类的一些资源清理工作。对于 C++ 内置类型的成员变量,默认析构函数不处理;对于自定义类型,默认析构函数会调用自定义类型的默认析构函数。
  3. 拷贝构造函数:拷贝构造函数也是一种构造函数(构造函数的重载),函数名与类名相同,参数是对本类型的类型对象的引用,并且一般用 const 修饰。对于 C++ 内置类型的成员变量,默认拷贝函数完成值拷贝(浅拷贝);对于自定义类型,默认拷贝构造函数会调用自定义类型的拷贝构造函数。函数自定义类型参数传递时,
  4. 赋值运算符重载:也是拷贝行为,不同的是,赋值拷贝是两个对象已经初始化过了,拷贝构造是拿一个已经初始化过了的对象去拷贝一个未初始化的对象。对于内置类型和自定义类型和拷贝构造函数的处理一致。在运算符重载时,有 5 个运算符不能重载:.*、?:、.、sizeof、::。当重载前置++和后置++时,后置++需要在增加一个int参数,构成重载。

const

用 const 修饰的类成员函数称之为 const 成员函数,const 实际修饰的是 this 所指向的对象,因此在该成员函数中不能对任何成员进行修改。

友元

友元打破类的封装,通过友元可以在类外部访问类的私有成员。

初始化列表

在 C++ 中创建对象时,会调用构造函数进行初始化,严格地讲,应该叫赋初值,因为构造函数体内可以多次赋值,而初始化只能执行一次。在初始化列表中,每个成员只出现一次,因此只能初始化一次。当成员变量包含引用类型、const 类型、以及没有默认构造函数的自定义类型时,必须在初始化列表中对这些变量进行初始化。成员变量在类中的声明顺序就是初始化顺序。

单参数的构造函数支持隐式类型转换。

class A
{
public:
    A(int a):_a(a)
    {}
private:
    int _a;
};
// 实际上会先用 2 构造一个临时对象,然后用这个临时对象去拷贝构造 a,可以用 explicit 关键字来屏蔽上述操作
A a = 2;

static 成员

static 修饰的类成员为静态成员,静态成员为所有类对象共享,不属于某个具体实例,静态成员可用类名::静态成员或者对象.静态成员访问。静态成员变量必须在类外定义,静态成员函数没有隐藏的 this 指针,不能访问非静态成员。

C++ 11 成员初始化

非静态成员变量在声明时,可以添加缺省值,给成员初始化时赋值。在初始化列表中没有内置类型成员初始化,就用缺省值初始化,若没有缺省值,那就是随机值。

内部类

内部类是其外部类的友元。sizeof(外部类)和内部类无关。内部类可以直接访问外部类静态成员。

内存管理

虚拟内存分布

栈(局部变量)、堆(动态申请空间)、静态区(数据段)包含全局数据和静态数据、常量区(代码段)包含代码生成的指令和常量。

C/C++ 内存管理

C 语言:

  1. malloc 在堆上动态开空间
  2. calloc 在堆上动态开空间,并且初始化为 0
  3. realloc 针对已有的空间进行扩容 C++:采用 new 和 delete 动态申请和释放内存。对于内置类型,和 malloc、free 没有区别。针对自定义类型,new 还会调用自定义类型的构造函数,delete 会先调用析构函数再释放空间。

在 32 位平台下,一般能动态申请 2G 的内存空间。在 64 位下,能申请 4G 的内存空间。

new = operator new + 构造函数(.ctor),而 operator new = malloc + 失败抛异常 delete = 析构函数 + operator delete

定位 new:已经分配的空间上调用构造函数初始化一个对象。

malloc/free 和 new/delete 区别:

  1. malloc/free 是函数,new/delete 是操作符
  2. malloc 申请的空间不会初始化, new 可以初始化
  3. malloc 申请空间需要手动计算大小,new 只需传递申请空间的类型
  4. malloc 返回值是 void*, 在使用时必须强转,new 不需要
  5. malloc 申请空间失败返回 NULL,在使用时需要判空,new 不需要,但 new 必须捕获异常
  6. 申请自定义类型时,malloc 和 free 会申请和释放空间,new 和 delete 则是会额外调用构造函数和析构函数

模板

// 函数模板
template<class T>
void swap(T& a, T& b)
{
    T tmp(a);
    a = b;
    b = tmp;
}

根据不同类型 T,函数模板会进行推演,进行函数模板实例化,生成不同的函数。实例化分为显式实例化和隐式实例化。类模板同理。

模板的参数可以分为类型模板参数和非类型模板参数,非类型模板参数必须是整型常量。

// 类模板
template<class T = int, size_t N = 1>
class Array
{
private:
    T a[N];
};
Array<int, 100> aa1;
Array<int, 1000> aa2;
// 模板参数也可以用默认值
Array<> aa3;

模板特化:模板不能正确处理一些特殊逻辑,需要特殊化处理。

// 函数模板特化
template <class T>
bool IsEqual(const T& a, const T& b)
{
    return a == b;
}
// 模板特化
bool IsEqual(const char*& a, const char*& b)
{
    return strcmp(a, b);
}

// 类模板特化
template <>
class Date <int, int>
{

};
Date<int, int> d1;

根据参数是否都需要特殊处理,可以分为全特化和偏特化。

模板不支持分离编译。分离编译会报链接错误,原因是:拿函数模板举例,分离编译后,在链接时要根据函数名去符号表中找对应的地址,然而函数模板没有实例化,因此无法在符号表中生成调用函数地址。

模板优点在于代码复用、增加代码灵活性,缺点则是会导致代码膨胀。

C++ IO 流

自定义类型想要用 cin 和 cout 需要重载 >> 和 <<。

getline() 用于获取以换行结束的字符串。

oj中获取多行输入:

while (cin >> str)
{}

ifstream(读) 和 ofstream(写):


struct ServerInfo
{
    char _ip[32];
    int _port;
};
// 二进制读写
void WriteBin(char* filename, const ServerInfo& info)
{
    ofstream ofs(filename);
    ofs.write((char*)&info, sizeof(info));
    ofs.close();
}
void ReadBin(char* filename, ServerInfo& info)
{
    ifstream ifs(filename);
    ifs.read((char*)&info, sizeof(info));
    ifs.close();
}
// 文本读写
void WriteTxt(char* filename, const ServerInfo& info)
{
    ofstream ofs(filename);
    // 方法 1
    ofs << info._ip << endl;
    ofs << info._port << endl;
    // 方法 2
    ofs.write(info._ip, strlen(info._ip));
    ofs.put("\n");
    string portStr = to_string(info._port);
    ofs.write(portStr.c_str(), portStr.size());

    ofs.close();
}
void ReadTxt(char* filename, ServerInfo& info)
{
    ifstream ifs(filename);
    // 方法 1
    ifs >> info._ip;
    ifs >> info._port;
    // 方法 2
    ifs.getline(info._ip, 32);
    char portbuf[20];
    ifs.getline(portbuf, 20);
    info._port = stoi(portbuf);
    ifs.close();
}

序列化和反序列化:

// 将对象转化成字符串(序列化),C 语言采用 sprintf
ServerInfo info = {"192.168.1.12", 8888};
char buf[128];
sprintf(buff, "%s %d", info._ip, info._port);
// 将字符串转换成对象(反序列化),C 语言采用sscanf
ServerInfo rinfo;
sscanf(buf, "%s%d", rinfo._ip, &rinfo._port);

// C++ 采用 stringstream 完成上述工作
// 序列化
stringstream ssm;
ssm << info._ip << info._port;
// 获取字符串
string buf = ssm.str();

// 反序列化
ServerInfo rinfo;
stringstream ssm;
ssm.str("127.0.0.1 90");
ssm >> rinfo._ip >> rinfo._port;

继承

class Person
{
public:
    void Print()
    {
        cout << "name:" << _name;
        cout << "age:" << _age;
    }
protected:
    int _age = 18;
    string _name = "Schuyler";
};
class Student:public Person
{
protected:
    int _stuid;
};
class Teacher:public Person
{
protected:
    int _jobid;
};

继承方式可以是 public、private、protected,类成员访问权限同样有这三种,基类成员在派生类中的访问方式 = Min(成员在基类中的访问方式,继承方式)。当访问方式为 private 时,内存上子类对象有这个成员,但语法规定不能访问。

赋值兼容规则(切片):子类可以赋值给父类,父类可以是指针类型也可以是对子类的引用,父类不能赋值给子类。

子类和父类有同名成员,子类成员会屏蔽父类对同名成员的直接访问(隐藏或重定义),采用基类::基类成员可以显式访问。

class Person
{
public:
    Person(const char* name):_name(name)
    {}
    Person(const Person& p):_name(p._name)
    {}
    Person& operator=(const Person& p)
    {
        if (this != &p)
            _name = p._name;
        return *this;
    }
    ~Person()
    {}
protected:
    string _name;
};

class Student:public Person
{
public:
    // 当基类没有默认构造函数时,派生类的构造函数必须显示调用基类构造函数
    Student(const char* name, int num)
        :Person(name)
        ,_num(num)
    {}
    // 拷贝构造函数同理
    Student(const Student& s)
        :Person(s)
        ,_num(s._num)
    {}
    // 赋值拷贝同理
    Student& operator=(const Student& s)
    {
        if (this != &s)
            Person::operator=(s);
            _num = s._num;
        return *this;
    }
    // 析构函数默认在调用完毕后会自动调用父类析构函数,
    // 保证先构造后析构的顺序
    ~Student()
    {
        // 因为析构函数被编译器处理成destructor,构成隐藏,
        // 因此若想调用基类析构函数,必须指定类域
        Person::~Person();
    }

protected:
    int _num;
};

子类和父类成员函数的函数名相同就构成隐藏。

为了让一个类不能被继承,只需将构造函数访问权限改成 private。

友元关系不能被继承,也就是基类友元不能访问子类私有和保护成员。

基类中的静态成员无论被继承多少次,都只有一份。

多继承:一个子类有连两个或以上的父类。

多继承会产生菱形继承,从而导致数据冗余、二义性。指定作用域可以解决二义性,但还不能完全解决。C++ 通过虚继承来解决数据冗余。

C++ 如何通过虚继承解决数据冗余? 当一个子类有多个父类时,父类又共同继承一个类时,这就构成菱形继承。通过父类在继承时,添加 virtual 构成虚继承,这样父类对象就会有虚基表指针,指针指向虚基表,父类的虚基表中存放各自相对共同继承的父类的偏移地址。这样,就能找到共同的成员,从而解决数据冗余。

继承和组合;

  1. 继承是“白箱复用”,父类对于子类而言是“透明”的,父类和子类的关系位 is-a
  2. 组合是“黑箱复用”,组合之间的关系是“不透明”的,只能使用对方的公有方法,二者的关系为 has-a

多态

静态多态:函数重载 动态多态:父类指针或引用调用重写的虚函数

  1. 父类指针或引用指向父类,调用的就是父类的虚函数
  2. 父类指针或引用指向哪个子类,调用的就是子类的虚函数

在继承中构成多态的两个条件:

  1. 必须通过基类的指针或引用调用虚函数
  2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写

虚函数:非静态成员函数前加 virtual 关键字

重写(覆盖):派生类和基类完全相同的虚函数(函数名、返回值和参数都相同)

重写的例外:

  1. 协变,函数名和参数相同,返回值是派生类的的指针或引用
  2. 析构函数函数名看起来不相同,但是构成重写,把基类的析构函数定义成虚函数方便子类重写父类的虚函数
Person* p1 = new Person;
Person* p2 = new Student;
// 当析构函数定义成虚函数时,构成多态行为,调用各自指向的对象的析构函数
delete p1;
delete p2;

final 关键字修饰虚函数,让虚函数不被重写; override 修饰子类虚函数,检查子类是否重写父类虚函数

// 包含纯虚函数的类叫做抽象类
class Car
{
public:
    // 形如下面函数叫做纯虚函数
    virtual void Drive() = 0;
};

抽象类不能实例化出对象。用来表现现实世界中无法实例化的抽象类型。只有通过子类继承,并重写纯虚函数,才能通过子类实列化出对象,否则子类也无法实例化出对象。

多态原理:

class Base
{
public:
    virtual void func()
    {

    }
private:
    int _n;
};

Base 类的大小不是 4 个字节,因为只要包含虚函数,对象中就会包含一个虚函数表指针(简称虚表指针)。该指针用来实现多态,指针会指向虚函数表(虚函数指针数组),虚函数表中存放虚函数的地址。

当构成多态时,程序运行时,指针或引用到指定对象的虚表中找到对应的虚函数,所以指向的是父类对象,调用的就是父类,指向子类就调用子类。

不构成多态,调用的就是指针或引用类型的函数。

为什么构成多态的条件需要通过基类的指针或引用? 因为基类的指针和引用,切片时,指向或引用父类和子类对象中切出来的那一部分。 当用基类对象代替基类的指针和引用时,切片时,只会将成员变量拷贝,不会拷贝虚表指针。

同类型对象共享一张虚表。

对象中的虚表指针是在构造函数初始化列表初始化的,虚表是在编译时就生成好了。

虚函数和普通函数一样存放在代码段,虚表中存放的是虚函数的地址。

一个类中的所有虚函数的地址存放在虚表中。

虚函数的重写,也叫做虚函数的覆盖。因为派生类会拷贝基类虚表,派生类中哪个虚函数被重写,需表中的虚函数地址就会重新被覆盖,虚表中派生类未重写的虚函数保持父类中虚函数的地址。

多继承时,继承多个基类,就有多张虚表,重写的虚函数会在每张表中体现,派生类自己的虚函数会放在第一张表中。