【C++ 再回顾】面向对象编程
2021-03-13 08:42:37 #CPP 

大一学习 c++ 的时候只是简单的学习了面向对象编程,了解了相关的知识点,但是很多实用技巧、要点都没有了解到。偶然间翻到侯捷大佬的 C++ 大系视频,看了两三集,知道了好多东西。于是决定把系列刷完,同时辅以网上的一些资料,做一个整理笔记

此系列持续更新

面向对象编程

头文件与类

整体工程如下:

1
2
3
4
5
6
7
8
9
bin
+-----text
include
+-----Test.h
lib
src
+-----Test.cpp
+-----main.cpp
CMakeList.txt
  1. 头文件包含函数和类的声明,以及用到的标准库文件;函数和类的定义写入源文件,需要 include 头文件
  2. 模板类在头文件中声明及定义

    原因是类可以由编译器直接实例化,定义在源文件可以进行检查;模板类由于不知道类型,无法检查,直接写入头文件最好

  3. 头文件一定要写#define保护,防止被多重包含
    1
    2
    3
    4
    5
    6
    7
    #ifndef EXAMPLE_TEST_H
    #define EXAMPLE_TEST_H

    // 函数及类的声明
    // 模板函数及模板类的声明和定义

    #endif
    命名规则:CMakeList 中的项目工程名(大写)_类名(大写)_H
    例子:EXAMPLE_TEST_H
  4. 函数名称首字母小写,第二个单词首字母大写,如getValue;类首字母大写,第二个单词首字母大写。如IntTest
  5. 定义成员函数时尽可能inline

    类中直接定义的函数不需要inline修饰

    类中声明的成员函数不需要用inline修饰,在类外定义时需要inline修饰

    inline只是对编译器的建议,能不能内联要看编译器让不让,会自行决定

构造函数

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
// Test.h

#ifndef TEST_TEST_H
#define TEST_TEST_H

class Test {
public:
Test() = default;

Test(int a = 1, double b = 1.5) : a(a), b(b) {}

void setA(int a);

void setB(double b);

int getA() const;

double getB() const;

private:
int a;
double b;
};

#endif //TEST_TEST_H
  1. 默认构造函数在声明中直接 default
  2. 构造函数中尽量使用初始化列表

    初始化列表不会进入构造函数的函数体,相当于少一层赋值的步骤,提高了效率

  3. 初始化成员变量时,按照声明顺序,而不是按照构造函数的顺序;所以初始化列表的顺序按照声明顺序构造

拷贝构造与拷贝赋值

  1. 为什么有的类需要写拷贝构造函数,有的类不需要写

    有的类成员变量就是简简单单的值,编译器会自动写拷贝构造函数用来拷贝构造对象;有的类成员变量是一个指针,例如 string 类,指针指向字符数组,如果也是使用编译器写的拷贝构造函数,新对象的指针记录的是原来对象指针记录的内存地址,即两个对象指针指向同一个字符数组,那就不是拷贝了,不符合建立拷贝对象时的想法

  2. 浅拷贝:拷贝的对象中的指针(成员变量)指向被拷贝对象中的指针指向的内存地址

    深拷贝:拷贝的对象中的指针指向一块新建立的内存地址,这个内存地址的内容完全复制被拷贝对象中的指针指向的内容

  3. 当一个类的成员变量有指针时,多半要写拷贝构造和拷贝赋值

  4. 拷贝构造函数的参数应该是一个const修饰的同类的对象的引用

    1.const修饰:防止被拷贝的对象被修改
    2. 引用:传递的时候速度快,效率高

1
2
3
4
inline String::String(const String &str) {
this->data = new char[strlen(str.data) + 1];
strcpy(this->data, str.data);
}
  1. 拷贝赋值函数

    1. 检测是否自我赋值,是的话直接把 this 传回去
    2. delete 内存
    3. new 新的内存地址
    4. 拷贝
    5. 返回
1
2
3
4
5
6
7
8
9
inline String &String::operator=(const String &str) {
if (this == &str)
return *this;

delete[] this->data;
this->data = new char[strlen(str.data) + 1];
strcpy(this->data, str.data);
return *this;
}

为什么要自我检测?

如果没有自我检测,当确实发生了传入自己的时候(str=str),由于先delete了内存空间,接下来要检测长度和拷贝的时候就傻眼了,啥也没了。因为我把自己传进来并且先 delete 掉了

我杀我自己 👋

析构函数

  1. 当成员变量是指针且 new 了动态内存时,需要用析构函数 delete
  2. 指针指向数组时,delete[] this->ptr

内存管理

  1. 栈(stack):程序运行时自动分配释放 ,存放函数的参数值,局部变量的值等
  2. 堆(heap):一般由程序员分配释放, 若程序员不释放,程序结束时可能由 OS 回收
  3. 全局区(也叫静态区):存放全局变量和静态变量,以上变量作用域结束后仍然存在,程序结束后由系统销毁
  4. String str(“hello”):建立在栈中,在作用域结束时自动调用析构函数结束自己
  5. String str = new String:建立在堆中,由程序员自己控制建立和销毁,需要自己delete;如果忘记了delete就会内存泄漏
    6.new时干了啥(String
    str = new String(“hello”))
    1. 分配内存:调用 C 的 malloc(sizeof(String)),得到一个指向内存地址的指针(void)
    2. 转型:把 void 指针转型为需要的指针,并赋值为一开始建立的指针(str
    3. 调用构造函数:str—>String::String(“hello”),这时候成员变量的指针 new 了一个内存空间
    7.delete时干了啥(delete str)
    1. 调用析构函数:String::~String(str)
    2. 释放内存:调用 C 的 free(str)

    首先明确一下概念,str 这个指针指向一个内存地址,这个地址保存了一个对象;这个对象里有一个成员变量,是一个指针,这个指针指向了一个 new 出来的内存空间

    调用析构函数时,这个对象 delete 了自己的成员变量指向的内存空间

    释放内存时,释放了这个 str 指针持有的内存空间(这个空间保存了这个对象)

  6. 用了new []就要用delete[]

    在 new 的时候,分配的内存块的边界会标明整个内存块有多大。然后在这个大的内存块里分出了 n 个小内存块来用。

    如果用 delete[],编译器就会知道要把这 n 个小内存块先回收,再回收整个大内存块

    如果用 delete,编译器只会把第一个小内存块回收,然后回收整个大内存块

    好像也是一种内存泄露,但是跟我一开始学的内存泄露的概念不太一样。以上写的这块内容不确定对不对,找个时间再琢磨一下

参数传递与返回值

  1. 对于不会修改类成员变量的函数,在函数名后面加上const

    例子:int getA() const;

    假设实例化一个 const 对象,调用没有 const 的 getA()函数,会认为会修改成员变量,与这个 const 对象冲突。

  2. 参数传递少用传值,尽量传引用,不希望被传值被修改时加上const更好

    其实没必要这样。比如传一个对象进去,传值的话会传很大的数据进去,这时候传引用就相当于传指针,很小的一块数据就可以指向很大的东西;如果传个数字啥的,直接传值也没什么大不了的

  3. 返回值也可以在适当情况下返回引用

    要注意是适当情况下,有时候返回成员变量的值就不必返回引用了;返回一个对象时可以返回引用

  4. 函数内部创建的局部变量返回的必须是值,不能是引用

    函数结束后这个局部变量就销毁了,那返回引用就没用了

friend

  1. 利用friend声明函数或另一个类为友元,可以访问直接成员变量

    友元打破封装,有时比较方便

  2. 同一个 class 的对象互为 friend

    即声明两个 Test 的对象,可以互相访问对方的成员变量

操作符重载

  1. operator指明操作符重载——针对成员函数
1
2
3
4
5
Test &Test::operator+=(const Test &test) {
this->a += test.a;
this->b += test.b;
return *this;
}
  1. 参数中默认传入当前对象的this指针
  2. 返回值返回引用
  3. 针对非成员函数,创建临时对象用来返回,返回值
1
2
3
4
Test operator+(const Test &test1, const Test &test2) {
Test test(test1);
return test += test2;
}

甚至存在简化方法

1
2
3
Test operator+(const Test &test1, const Test &test2) {
return Test(test1.getA() + test2.getA(), test1.getB() + test2.getB());
}

然后 CLion 又给简化了

1
2
3
Test operator+(const Test &test1, const Test &test2) {
return {test1.getA() + test2.getA(), test1.getB() + test2.getB()};
}

我直呼 666,以前还觉得用编辑器就够了,现在发现 IDE 是真滴行 👍

因为创建了临时对象用来保存值,所以传入的两个对象需要用const修饰,防止被修改
5. 取反操作

1
2
3
Test operator-(const Test &test) {
return {-test.getA(), -test.getB()};
}
  1. 输出流
1
2
3
std::ostream &operator<<(std::ostream &os, const Test &test) {
return os << "a:" << test.getA() << std::endl << "b:" << test.getB() << std::endl;
}

cout 输出的是 ostream 流,所以需要定义时需要传入 ostream 引用,最后函数返回的也是一个 os 流

static

  1. 一般的成员函数有this指针,并作为默认参数传入,不要在声明和定义的时候在函数头写出来,但是在函数体里可以用;static 函数没有this指针
  2. 类的 static 函数只有一个,不属于任何对象
  3. 调用 static 函数:可以通过类名直接调用A::getInstance()
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class A {
    public:
    static A &getInstance() {
    static A a;
    return a;
    }

    void setup() {}

    private:
    A() = default;
    };
    通过这种方式可以调用函数:A::getInstance().steup()

模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template<typename T>
class A {
A() = default;

A(T value) : value(value) {}

void setValue(T value);

T getValue() const;

private:
T value;
};

template<typename T>
void A<T>::setValue(T value) {
this->value = value;
}

template<typename T>
T A<T>::getValue() const {
return this->value;
}

复合

  1. 一个类以另一个类的对象作为成员变量,调用对象的一些方法实现需要的功能
  2. 构造时由内至外
  3. 析构时由外至内

委托

  1. 创建指向一个类(A)的指针,另一个类(B)以这个指针作为成员变量
  2. 功能写入类 A,类 B 调用类 A 的功能,做到无论类 A 如何改动,都不会影响类 B,即内部是外部功能的实现,外部是内部对外的接口

复合和继承的差异

复合的外部和内部同时出现,拥有一致的生命周期

委托可以先创建外部,等需要用到内部时再创建,生命周期不一致

继承

  1. 创建一个类时,不需要重新编写新的数据成员和成员函数,只需指定新建的类继承了一个已有的类的成员即可。这个已有的类称为基类,新建的类称为派生类
  2. 继承关系有 public,protected 和 private
    1. 公有继承(public):当一个类派生自公有基类时,基类的公有成员也是派生类的公有成员,基类的保护成员也是派生类的保护成员,基类的私有成员不能直接被派生类访问,但是可以通过调用基类的公有和保护成员来访问
    2. 保护继承(protected): 当一个类派生自保护基类时,基类的公有和保护成员将成为派生类的保护成员
    3. 私有继承(private):当一个类派生自私有基类时,基类的公有和保护成员将成为派生类的私有成员
  3. 派生类可以继承多个基类,拥有多个基类的特性
  4. 构造时由内至外
  5. 析构时由外至内

虚函数

  1. 数据可以被继承,函数可以被继承调用权(派生类可以调用基类的函数
  2. virtual 函数:基类的 virtual 函数有默认定义,派生类继承调用权后可以进行覆写(override,重新定义)
  3. pure virtual 函数:基类的 pure virtual 函数没有默认定义,派生类继承调用权后必须覆写
  4. 框架思想:基类定义一套动作,其中关键的步骤设置为 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
    class A {
    public:
    virtual std::string getName() = 0; // 纯虚函数

    void info() {
    std::cout << "输出信息" << std::endl;
    std::cout << getName() << std::endl;
    }
    };

    class B : public A {
    public:
    std::string getName() {
    return this->name;
    }

    private:
    std::string name;
    };

    int main() {
    B b();
    b.info();
    return 0;
    }

    对象 b 调用基类的 info()函数,执行到 getName()函数时,发现是虚函数,回到派生类 B,发现了覆写后的函数,调用覆写后的函数,再回到 info()函数执行完。

    对象 b 调用基类的 info()函数时,b 作为 this 指针以隐藏参数的形式传入 info()函数