• C++多态 万字详解


    在经历两个多月的备赛后,最终5.21结果出来后自己也比较满意,以一个省三收尾(算法类的)。

    期间每天偶尔学学新知识,然后主要做题,博客也落下了不少,现在开始继续补(可能会些许生疏).

    在学c++多态之前,还是建议先了解一下c++的继承,最起码把基础的概念认知清楚,然后再来看多态这一章节.

    闲话就到这了,现在我们正式学习C++中的多态.

    目录

    1、什么是多态?

    2、多态的定义及实现

    2.1、多态的构成条件

    2.2、虚函数

    2.3、重写

    重写的定义及形式

    函数重写/覆盖 、隐藏和重载的关系

    虚函数重写的两个例外

    2.4、C++11 override 和 final

    3、抽象类

    4、多态的原理

    4.1、虚函数表

    4.2、多态的原理

    4.3、动态绑定与静态绑定

    5、继承和多态常见的面试问题


    1、什么是多态?

    多态也是c++种面向对象的重要特性,那么多态具体是什么呢?

    先来通俗说一下概念:

    多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。

    举个简单的例子:

    我们去买火车票,不同的对象买票会有不同的结果:

    1.普通人买票:正常全价买票

    2.学生买票:半价买票

    3.军人买票:优先买票

    还有就是一个红包码,可能新人扫,获得钱会多一些;而扫过了的再扫,可能钱就会少一些。

    类似于这些都是多态的具体例子。

    那么多态是如何定义及如何抽象成代码实现的呢?

    2、多态的定义及实现

    2.1、多态的构成条件

    多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。 Person对象买票全价,Student对象买票半价。

    那么在继承中要构成多态还有两个条件:
    1. 必须通过基类的指针或者引用调用虚函数
    2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写/覆盖.

    简洁一些就是:

    1.父类指针或者引用调用虚函数

    2.虚函数重写

    看到这看不懂不要担心,我们将会对上面的条件进行一一分析讲解.

    第一个条件我们暂且先不说,先来看第二个条件:

    上面提到了虚函数,那么什么是虚函数呢?

    2.2、虚函数

    我们在上一章菱形继承中提到了虚函数,以及关键字virtual.

    虽然多态中的虚函数的关键字也是virtual,但两者的作用不同.

    继承中的virtual是解决菱形继承的二义性和冗余性问题的,具体可以看我上一章节.

    而多态中的virtual使用来修饰父类中的成员函数,使子类可以重写父类函数(virtual关键字只是重写父类函数的一个条件!要重写还有一个条件后面说).

    这里注意一下:虚函数只能修饰类内部成员函数静态函数、构造函数不可使用虚函数。

    所以虚函数如下:

    1. class Person {
    2. public:
    3. virtual void BuyTicket()
    4. {
    5. cout << "全价 - 买票" << endl;
    6. }
    7. };

    2.3、重写

    重写的定义及形式

    上面第二个条件中也提到了重写,重写也叫做覆盖,那么什么是重写呢?

    定义如下:

    虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。

    简洁来说就是:虚函数(父类中被重写的函数一定要被virtual修饰,子函数可写可不写,但最好是写上)+三同(返回值,函数名,参数全部相同).

    看一下下面的例子:

    1. class Person {
    2. public:
    3. virtual void BuyTicket()
    4. {
    5. cout << "全价 - 买票" << endl;
    6. }
    7. };
    8. //Student重写了Person中的BuyTicket的成员函数
    9. class Student :public Person {
    10. public:
    11. //满足父函数是个虚函数,返回值,函数名,参数名相同,构成重写
    12. virtual void BuyTicket()
    13. {
    14. cout << "半价 - 买票" << endl;
    15. }
    16. };

    函数重写/覆盖 、隐藏和重载的关系

    先整体上分为两类:1.相同作用域中 2.不同作用域中

    1.在相同作用域中:

    这个只涉及到了函数重载,满足以下任意一个或两个条件构成函数重载:

    1.参数个数不同

    2.参数类型不同或者参数类型顺序不同

    返回值相同不相同都可以,主要必须最少满足以上两个条件之一.

    1. int Add(int x, int y)
    2. {
    3. return x + y;
    4. }
    5. int Add(double x, double y)
    6. {
    7. return x + y;
    8. }
    9. int Add(int x, int y, int z)
    10. {
    11. return x + y + z;
    12. }
    13. int main()
    14. {}

    如以上几个函数就构成函数重载

    2.在不同作用域中

    这个时候需要区分函数重写/覆盖和隐藏.

    函数重写我们上面说了:满足 虚函数 + 三同(返回值,函数名,参数列表)的函数就是函数重写/覆盖.

    只要不满足其中任意一个条件的,都构成函数隐藏.

    例如我在上面那个函数重写代码中随便做一下改动:

    我给子函数加了一个参数x

    1. class Person {
    2. public:
    3. virtual void BuyTicket()
    4. {
    5. cout << "全价 - 买票" << endl;
    6. }
    7. };
    8. class Student :public Person {
    9. public:
    10. //加了一个参数x
    11. virtual void BuyTicket(int x)
    12. {
    13. cout << "半价 - 买票" << endl;
    14. }
    15. };

    此时他们的参数列表便不再相同,不符合重写的条件,此时便构成函数隐藏.

     再例如我们去掉父类中的virtual,此时便不再满足虚函数这个条件,也构成函数隐藏.

    1. class Person {
    2. public:
    3. //去掉了virtual,此时这个函数便不再是虚函数,不满足条件
    4. void BuyTicket()
    5. {
    6. cout << "全价 - 买票" << endl;
    7. }
    8. };
    9. class Student :public Person {
    10. public:
    11. virtual void BuyTicket()
    12. {
    13. cout << "半价 - 买票" << endl;
    14. }
    15. };

    所以,要构成函数重写/覆盖同时满足虚函数 + 三同.

    只要不满足其中任意之一的都是函数隐藏.

    这里有一个区别图,清晰明了可以看一下:

     现在就可以把第二个条件说清楚了.

    接下来讲解第一个条件:必须通过基类的指针或者引用调用虚函数

    这是什么意思呢?

    这个用代码来说明会比较方便理解:

    (ps.如果此时你感觉看不下去代码,建议平复一下仔细分析一下,非常简单的代码)

    1. class Person {
    2. public:
    3. virtual void BuyTicket()
    4. {
    5. cout << "全价 - 买票" << endl;
    6. }
    7. };
    8. class Student :public Person {
    9. public:
    10. virtual void BuyTicket()
    11. {
    12. cout << "半价 - 买票" << endl;
    13. }
    14. };
    15. //基类是Person,所有调用必须通过基类指针或引用进行调用
    16. void func(Person& p)
    17. {
    18. p.BuyTicket();
    19. }
    20. int main()
    21. {
    22. Person ps;
    23. Student st;
    24. //虽然是调用同一个函数,但是由于多态,结果会不一样
    25. func(ps);
    26. func(st);
    27. return 0;
    28. }

    我们首先创建了一个Person类的对象和Student类的对象.

    然后分别调用了func这个函数,进而通过基类的引用进行调用其中的函数.

    所以结果应该是不同,一个是全价,一个是半价,我们运行一下观察:

     可以看到已经达到了我们预期的结果.

    这就是所说的满足多态的一个条件.

    但是有两个例外:不满足构成重写的条件依然构成重写:

    虚函数重写的两个例外

    分别是:1.协变(基类与派生类的返回值不同,但是返回值是有要求的)2.析构函数的重写(基类与派生类析构函数的名字不同)

    先来看第一个:1.协变

    派生类重写基类虚函数时,与基类虚函数返回值类型不同。但是返回值必须要求是父子关系的指针或者引用.

    看下面代码:

    1. class Person {
    2. public:
    3. //返回值类型为Perosn*
    4. virtual Person* BuyTicket()
    5. {
    6. cout << "全价 - 买票" << endl;
    7. return this;
    8. }
    9. };
    10. class Student :public Person {
    11. public:
    12. //返回值类型为Student*
    13. virtual Student* BuyTicket()
    14. {
    15. cout << "半价 - 买票" << endl;
    16. return this;
    17. }
    18. };
    19. void func(Person& p)
    20. {
    21. p.BuyTicket();
    22. }
    23. int main()
    24. {
    25. Person ps;
    26. Student st;
    27. func(ps);
    28. func(st);
    29. return 0;
    30. }

    子类重写父类函数时,返回值不同,但此时我们执行代码:

    答案依旧相同,也就是说依然构成了多态.

    当然父子关系指针不一定是当前的父类和子类,还可以是以下这种:

    1. class A {};
    2. class B : public A {};
    3. class Person {
    4. public:
    5. //返回值类型为Perosn*
    6. virtual A* BuyTicket()
    7. {
    8. cout << "全价 - 买票" << endl;
    9. return nullptr;
    10. }
    11. };
    12. class Student :public Person {
    13. public:
    14. //返回值类型为Student*
    15. virtual B* BuyTicket()
    16. {
    17. cout << "半价 - 买票" << endl;
    18. return nullptr;
    19. }
    20. };

      

     这样依旧构成多态.

    而且顺序不能颠倒,比如Person类(父)返回类型不是B类型(子).只能是A.

    2.析构函数的重写(基类与派生类析构函数的名字不同)

    如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor.所以也构成了重写.

    这一样我在上一章C++继承中有详细的解释(派生类中的析构函数)。

    2.4、C++11 override 和 final

    从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写

    1.final : 修饰虚函数,表示改虚函数不能再被重写

    1. class Car
    2. {
    3. public:
    4. virtual void Drive() final {}
    5. };
    6. class Benz :public Car
    7. {
    8. public:
    9. virtual void Drive() { cout << "Benz-舒适" << endl; }
    10. };

    如上,Benz重写父类的Car中的Drive().但是由于final修饰,此时Car中的Drive()便不可再被重写:

     2.override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报

    1. class Car {
    2. public:
    3. virtual void Drive() {}
    4. };
    5. class Benz :public Car {
    6. public:
    7. virtual void Drive() override { cout << "Benz-舒适" << endl; }
    8. };

    然后我们把Drive改为Direct.

     此时由于没有重写基类的虚函数,由于加了override所以编译器检测到并会报错.

    这就是两个关键字的用法.

    3、抽象类

    概念:

    在虚函数后面加上 =0,则这个函数为虚函数。包含纯虚函数的类叫做抽象类(接口类),抽象类不能实例化出对象.派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

    大致意思就是说:如果一个类是抽象类(包含纯虚函数),那么继承它的子类必须重写它才能实例化出对象.

    我们可以看下面的例子来理解它.

    1. #include
    2. using namespace std;
    3. class car{
    4. public:
    5. virtual void Drive() = 0;
    6. };
    7. class Benz : public car{
    8. virtual void Drive()
    9. {
    10. cout << "Benz - 舒适" << endl;
    11. }
    12. };
    13. void Test()
    14. {
    15. car* pBenz = new Benz;
    16. pBenz->Drive();
    17. }
    18. int main()
    19. {
    20. Test();
    21. system("pause");
    22. return 0;
    23. }

    以上Benz类继承并重写了父类car中的Drive(),此时Benz可以实例化出对象并成功调用函数.

    (在上课,用学校电脑上的编译器写的----)

    但是如果我们此时不再重写Drive(),即直接继承了父类的纯虚函数,那么此时子类也就相当于是一个纯虚函数,不能被实例化.

    此时便不可以再被实例化出对象了.

    这就是纯虚函数的用法及作用了.

    接口继承和实现继承

    普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。

    我对以上的理解就是:

    普通的继承(实现继承)是为了继承父类函数的方法,方便使用父类所实现的函数.

    而接口继承是为继承父类的接口(函数名或者说某一属性),然后重写它,作为自己独有的方法或属性.

    4、多态的原理

    4.1、虚函数表

    先看一道题目来引入虚函数表:

    下面这个类的大小是多少?

    1. //sizeof(Base)是多少?
    2. class Base
    3. {
    4. public:
    5. virtual void Func1()
    6. {
    7. cout << "Func1()" << endl;
    8. }
    9. private:
    10. int _b = 1;
    11. };

    通过运行结果,我们发现答案是8字节.

    我们调试一下,看一下有哪些变量:

    除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中(_vfptr[0]即为Func1()的地址),虚函数表也简称虚表,那么如果继承的话,派生类中这个表放了些什么呢?

    我们对以上代码进行以下改动:

    1.增加一个派生类Derive来继承Base类

    2.在派生类Derive中重写Func1.

    3.在Base中新增一个虚函数Func2和普通函数Func3.

    改动后代码如下:

    1. //1.增加一个派生类Derive来继承Base类
    2. //2.在派生类Derive中重写Func1.
    3. //3.在Base中新增一个虚函数Func2和普通函数Func3.
    4. class Base
    5. {
    6. public:
    7. virtual void Func1()
    8. {
    9. cout << "Base::Func1()" << endl;
    10. }
    11. //新增虚函数Func2
    12. virtual void Func2()
    13. {
    14. cout << "Base::Func2()" << endl;
    15. }
    16. //新增普通函数Func3()
    17. void Func3()
    18. {
    19. cout << "Base::Func3()" << endl;
    20. }
    21. private:
    22. int _b = 1;
    23. };
    24. //新增Derive类来继承Base类
    25. class Derive : public Base
    26. {
    27. public:
    28. //重写Func1
    29. virtual void Func1()
    30. {
    31. cout << "Derive::Func1()" << endl;
    32. }
    33. private:
    34. int _d = 2;
    35. };
    36. int main()
    37. {
    38. Base b;
    39. Derive d;
    40. return 0;
    41. }

     此时我们再来分析,父类和子类的虚函数表中分别有什么东西.

     通过观察和测试,我们发现了以下问题:

    1. 派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员
    表指针
    也就是存在这一部分的,另一部分是自己的成员。
    2. 基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表
    中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数
    的覆盖。重写是语法的叫法,覆盖是原理层的叫法。

    3. 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函
    数,所以不会放进虚表。
    4. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。
    5. 总结一下派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生
    类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己
    新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后
    (这里注意,vs编译器监视窗口是看不到的,需要自己手动输出打印,但一定是有的)。

     这里还有一个我们很容易混淆的问题:虚函数存在哪的?虚表存在哪的? 错误回答:虚函数存在
    虚表,虚表存在对象中。

    但是很多同学都是这样深以为然的。注意
    虚表存的是虚函数的指针,而不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是
    他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的
    呢?实际我们去验证一下会发现vs下是存在代码段的,编译器不同可能存储的位置也不一样.

    4.2、多态的原理

    上面分析了半天的虚函数表,所以这个和多态的原理又有什么关系?

    我们还是拿上面说过的例子来说明,买火车票这个例子:

    还记得这里Func函数传Person调用的
    Person::BuyTicket,传Student调用的是Student::BuyTicket.

    看下面这段代码:

    1. class Person {
    2. public:
    3. virtual void BuyTicket() { cout << "买票-全价" << endl; }
    4. };
    5. class Student : public Person {
    6. public:
    7. virtual void BuyTicket() { cout << "买票-半价" << endl; }
    8. };
    9. void Func(Person& p)
    10. {
    11. p.BuyTicket();
    12. }
    13. int main()
    14. {
    15. Person Mike;
    16. Func(Mike);
    17. Student Johnson;
    18. Func(Johnson);
    19. return 0;
    20. }

    1.当p是Mike即Person类对象时:

    p->BuyTicket在mike的虚表中找到虚函数是Person::BuyTicket。 

    2.当p是Johnson即Student类对象时

    p->BuyTicket在johson的虚表中找到虚函数是Student::BuyTicket。 

    3. 这样就实现出了不同对象去完成同一行为时,展现出不同的形态

    4.通过下面的汇编代码分析,看出满足多态以后的函数调用,不是在编译时确定的,是运行
    起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的

    先来看一下源代码:

    1. class Person {
    2. public:
    3. virtual void BuyTicket() { cout << "买票-全价" << endl; }
    4. };
    5. class Student : public Person {
    6. public:
    7. virtual void BuyTicket() { cout << "买票-半价" << endl; }
    8. };
    9. void Func(Person* p)
    10. {
    11. p->BuyTicket();
    12. }
    13. int main()
    14. {
    15. Person mike;
    16. Func(&mike);
    17. mike.BuyTicket();
    18. return 0;
    19. }

    我们分为两部分:    Func(&mike) 和  mike.BuyTicket(); 这两条语句.

    先来看Func(&mike)部分的反汇编:

     从call那个位置即可看出它是运行确定地址的,而不是编译时已经确定好的.

    再来看对象直接调用mike.BuyTicket();:        

     首先BuyTicket虽然是虚函数,但是mike是对象,不满足多态的条件,所以这里是普通函数的调
    用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call 地址

    这就是两者的差别:一个运行时确定地址,一个是编译时确定地址.

    4.3、动态绑定与静态绑定

     根据以上所讲的,我们可以分为两种不同的行为:

    1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,
    比如:函数重载,对象调用成员函数等.

    2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体
    行为,调用具体的函数
    ,也称为动态多态

    3. 4.2上的汇编代码已经很好的解释了什么是静态(编译时)绑定和动态(运行时)绑定。

    至于单继承和多继承关系中的虚函数表,单继承我以上已经说清楚了。

    多继承中有一个需要注意的就是:多继承中派生类的未重写的虚函数放在第一个继承基类的虚函数表中.

    5、继承和多态常见的面试问题

    1. 什么是多态?

    答:多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为

    2. 什么是重载、重写(覆盖)、重定义(隐藏)?

    答:

    重载:相同的作用域下,函数名相同,但是参数个数或者参数顺序及参数类型 不同.

    重写(覆盖):不同的作用域下(一般是继承关系),子类与父类的虚函数名字、返回值以及参数必须完全相同.

    重定义(隐藏):不同的作用域下(一般是继承关系),子类与父类的函数名字相同,但不满足返回值相同,参数相同(包括顺序,类型,个数) 这两个条件的任意一个条件.

    换句话说,在保证名字相同的前提下(不同的作用域),只要不满足重写的函数都是重定义函数.

    3. 多态的实现原理?

    可以参考本文中的4.多态的实现原理,大概整理如下:

    C++中的多态是通过虚函数和继承实现的。其中,在类中定义的虚函数会在运行时在对象内部生成虚函数表,通过该表来实现多态。每个包含虚函数的类都有一个虚函数表,虚函数表是一个函数指针数组,其中存储着该类的虚函数指针,这些指针指向此类所定义的虚函数的地址。

    当一个带有虚函数的类派生出新类时,新类也会有一个虚函数表,其中包含新类的虚函数和从基类继承来的虚函数。如果子类没有重写基类的虚函数,那么其虚函数指针将指向基类虚函数表中的虚函数地址;如果子类重写了基类虚函数,那么其虚函数指针将指向子类虚函数表中的虚函数地址。

    在使用基类指针或引用调用一个经过重写的虚函数时,编译器将会在对象的虚函数表中查找正确的虚函数地址。每个对象都会保留一个指向其虚函数表的指针,由于基类指针或引用可以指向派生类对象,所以也可以调用派生类中实现的同名虚函数,实现了多态的效果。

    4. inline函数可以是虚函数吗?

    答:可以,不过inline只是一个建议。当一个函数是虚函数以后,因为要放到虚函数表中,所以多态调用中,inline就会失效.

    总结就是:可以,但是写和没写一样.

    5. 静态成员(static)可以是虚函数吗?

    答:不能,因为静态成员没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员不可以是虚函数.

     6.构造函数可以是虚函数吗?

    答:不可以,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始
    化的。

    解释一下:使用virtual函数是为了实现多态,运行时去虚表中找虚函数调用.

    而虚表是在调用构造函数初始化列表时才初始化的。

    此时构造函数是虚函数,你去虚表里找构造函数,但是还没有初始化,因为它需要构造函数,所以这样就出现了错误,即构造函数不能初始化.

    7. 对象访问普通函数快还是虚函数更快?

    答:首先如果是普通对象,是一样快的。

    如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。

    8. 虚函数表是在什么阶段生成的,存在哪的?

    答:虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。

    虚函数表指针是存在对象中的.

    9.什么是抽象类?抽象类的作用?

    包含纯虚函数的类叫做抽象类,可以具体参考文中内容.

    抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。

    到这里C++多态就基本上差不多了,如果有不明白或者不理解的地方,欢迎私信或者评论区哦。不过最好评论区,私信有各种文章推送,太多消息可能看不到,评论区发表也可能解决大家的问题~

  • 相关阅读:
    潘多拉 IOT 开发板学习(RT-Thread)—— 实验19 MQTT 协议通信实验(学习笔记)
    iview项目中,radio选中值回显问题
    No1.详解【2023年全国大学生数学建模竞赛】C题——蔬菜类商品的自动定价与补货决策(代码 + 详细输出 + 数据集&代码 下载)
    做短视频内容需要注意的五大要素,你做的内容都有吗
    【Codeforces】 CF1870E Another MEX Problem
    Node.js数电票、全电票查验接口示例、发票查验、票据OCR API
    JavaScript扩展原型链浅析
    源码编译安装Apache
    vue学习-13路由的query参数,props传参,以及编程式路由导航栏
    PROSTATEx-2 上前列腺癌的 3D CNN 分类
  • 原文地址:https://blog.csdn.net/weixin_47257473/article/details/130872904