• 【转载】C++之继承与多态


    转自:http://www.cnblogs.com/kunhu/p/3631285.html

    在程序设计领域,一个广泛认可的定义是“一种将不同的特殊行为和单个泛化记号相关联的能力”。和纯粹的面向对象程序设计语言不同,C++中的多态有着更广泛的含义。除了常见的通过类继承和虚函数机制生效于运行期的动态多态(dynamic polymorphism)外,带变量的宏,模板,函数重载,运算符重载,拷贝构造等也允许将不同的特殊行为和单个泛化记号相关联,由于这种关联处理于编译期而非运行期,因此被称为静态多态(static polymorphism)。

    静态多态性

    1、 函数重载与缺省参数

    (1)函数重载的实现原理

    假设,我们现在想要写一个函数(如Exp01),它即可以计算整型数据又可以计算浮点数,那样我们就得写两个求和函数,对于更复杂的情况,我们可能需要写更多的函数,但是这个函数名该怎么起呢?它们本身实现的功能都差不多,只是针对不同的参数:

    int sum_int(int nNum1, int nNum2)

    {

        return nNum1 + nNum2;

    }

    double sum_float(float nNum1, float nNum2)

    {

        return nNum1 + nNum2;

    }

    C++中为了简化,就引入了函数重载的概念,大致要求如下:

    1、    重载的函数必须有相同的函数名

    2、    重载的函数不可以拥有相同的参数

    2、 运算符重载

    运算符重载也是C++多态性的基本体现,在我们日常的编码过程中,我们经常进行+、—、*、/等操作。在C++中,要想让我们定义的类对象也支持这些操作,以简化我们的代码。这就用到了运算符重载。

    比如,我们要让一个日期对象减去另一个日期对象以便得到他们之间的时间差。再如:我们要让一个字符串通过“+”来连接另一个字符串……

    要想实现运算符重载,我们一般用到operator关键字,具体用法如下:

    返回值  operator 运算符(参数列表)

    {

             // code

    }

    例如:

    CMyString Operator +(CMyString & csStr)

    {

    int nTmpLen = strlen(msString.GetData());

    if (m_nSpace <= m_nLen+nTmpLen)

    {

    char *tmpp = new char[m_nLen+nTmpLen+sizeof(char)*2];

    strcpy(tmpp, m_szBuffer);

    strcat(tmpp, msString.GetData());

    delete[] m_szBuffer;

    m_szBuffer = tmpp;

    }

    }

    这样,我们的函数就可以写成:

    int sum (int nNum1, int nNum2)

    {

        return nNum1 + nNum2;

    }

    double sum (float nNum1, float nNum2)

    {

        return nNum1 + nNum2;

    }

    到现在,我们可以考虑一下,它们既然拥有相同的函数名,那他们怎么区分各个函数的呢?

    那就是通过C++名字改编(C++名字粉碎),,对于重载的多个函数来说,其函数名都是一样的,为了加以区分,在编译连接时,C++会按照自己的规则篡改函数名字,这一过程为"名字改编".有的书中也称为"名字粉碎".不同的C++编译器会采用不同的规则进行名字改编,例如以上的重载函数在VC6.0下可能会被重命sum_int@@YAHHH@Z和sum_float@@YAMMM@Z这样方便连接器在链接时正常的识别和找到正确的函数。

    (2)缺省参数

    无论是Win系统下的API,还是Linux下的很多系统库,它们的好多的函数存在许多参数,而且大部分都是NULL,倘若我们有个函数大部分的时候,某个参数都是固定值,仅有的时候需要改变一下,而我们每次调用它时都要很费劲的输入参数岂不是很痛苦?C++提供了一个给参数加默认参数的功能,例如:

    double sum (float nNum1, float nNum2 = 10);

    我们调用时,默认情况下,我们只需要给它第一个参数传递参数即可,但是使用这个功能时需要注意一些事项,以免出现莫名其妙的错误,下面我简单的列举一下大家了解就好。

    A、 默认参数只要写在函数声明中即可。

    B、 默认参数应尽量靠近函数参数列表的最右边,以防止二义性。比如

    double sum (float nNum2 = 10,float nNum1);

    这样的函数声明,我们调用时:sum(15);程序就有可能无法匹配正确的函数而出现编译错误。

    3.宏多态

    带变量的宏可以实现一种初级形式的静态多态: 
    // macro_poly.cpp

    #include <iostream>
    #include <string>

    // 定义泛化记号:宏ADD
    #define ADD(A, B) (A) + (B);

    int main()
    {
        int i1(1), i2(2);
        std::string s1("Hello, "), s2("world!");
        int i = ADD(i1, i2);                        // 两个整数相加
        std::string s = ADD(s1, s2);                // 两个字符串“相加”
        std::cout << "i = " << i << " ";
        std::cout << "s = " << s << " ";
    }
    当程序被编译时,表达式ADD(i1, i2)和ADD(s1, s2)分别被替换为两个整数相加和两个字符串相加的具体表达式。整数相加体现为求和,而字符串相加则体现为连接(注:string.h库已经重载了“+”)。程序的输出结果符合直觉:
    1 + 2 = 3
    Hello, + world! = Hello, world! 

    4.类中的早期绑定

    先看以下的代码:

    #include<iostream>
    using namespace std;
    class animal
    {
        public:
            void sleep(){
                cout<<"animal sleep"<<endl;
                
            }
        void breathe(){
         cout<<"animal breathe"<<endl;
        }
    };
    class fish:public animal
    {
        public:
            void breathe(){
                cout<<"fish bubble"<<endl;
            }
    };
    
    int main()
    {
        fish fh;
        animal *pAnimal=&fh;
        pAnimal->breathe();
    }

    答案是输出:animal breathe

    从编译的角度
    C++编译器在编译的时候,要确定每个对象调用的函数的地址,这称为早期绑定(early binding),当我们将fish类的对象fh的地址赋给pAn时,C++编译器进行了类型转换,此时C++编译器认为变量pAn保存的就是animal对象的地址。当在main()函数中执行pAn->breathe()时,调用的当然就是animal对象的breathe函数。

    内存模型的角度

    对于简单的继承关系,其子类内存布局,是先有基类数据成员,然后再是子类的数据成员,当然后面讲的复杂情况,本规律不一定成立。


    我们构造fish类的对象时,首先要调用animal类的构造函数去构造animal类的对象,然后才调用fish类的构造函数完成自身部分的构造,从而拼接出一个完整的fish对象。当我们将fish类的对象转换为animal类型时,该对象就被认为是原对象整个内存模型的上半部分,也就是图中的“animal的对象所占内存”。那么当我们利用类型转换后的对象指针去调用它的方法时,当然也就是调用它所在的内存中的方法。因此,输出animal breathe,也就顺理成章了。

    前面输出的结果是因为编译器在编译的时候,就已经确定了对象调用的函数的地址,要解决这个问题就要使用迟绑定(late binding)技术。当编译器使用迟绑定时,就会在运行时再去确定对象的类型以及正确的调用函数。而要让编译器采用迟绑定,就要在基类中声明函数时使用virtual关键字(注意,这是必须的,很多学员就是因为没有使用虚函数而写出很多错误的例子),这样的函数我们称为虚函数。一旦某个函数在基类中声明为virtual,那么在所有的派生类中该函数都是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
    <span style="font-family: 'comic sans ms', sans-serif; font-size: 15px;">#include<iostream>
    using namespace std;
    class animal
    {
        public:
            void sleep(){
                cout<<"animal sleep"<<endl;
                 
            }
        virtual void breathe(){
         cout<<"animal breathe"<<endl;
        }
    };
    class fish:public animal
    {
        public:
            void breathe(){
                cout<<"fish bubble"<<endl;
            }
    };
     
    int main()
    {
        fish fh;
        animal *pAnimal=&fh;
        pAnimal->breathe();
    }
    </span>

      运行结果:fish bubble

    编译器为每个类的对象提供一个虚表指针,这个指针指向对象所属类的虚表。在程序运行时,根据对象的类型去初始化vptr,从而让vptr正确的指向所属类的虚表,从而在调用虚函数时,就能够找到正确的函数。由于pAn实际指向的对象类型是fish,因此vptr指向的fish类的vtable,当调用pAn->breathe()时,根据虚表中的函数地址找到的就是fish类的breathe()函数。正是由于每个对象调用的虚函数都是通过虚表指针来索引的,也就决定了虚表指针的正确初始化是非常重要的。换句话说,在虚表指针没有正确初始化之前,我们不能够去调用虚函数。那么虚表指针在什么时候,或者说在什么地方初始化呢?

    答案是在构造函数中进行虚表的创建和虚表指针的初始化。还记得构造函数的调用顺序吗,在构造子类对象时,要先调用父类的构造函数,此时编译器只“看到了”父类,并不知道后面是否后还有继承者,它初始化父类对象的虚表指针,该虚表指针指向父类的虚表。当执行子类的构造函数时,子类对象的虚表指针被初始化,指向自身的虚表。

         当fish类的fh对象构造完毕后,其内部的虚表指针也就被初始化为指向fish类的虚表。在类型转换后,调用pAn->breathe(),由于pAn实际指向的是fish类的对象,该对象内部的虚表指针指向的是fish类的虚表,因此最终调用的是fish类的breathe()函数。

    下面详细的介绍内存的分布

    基类的内存分布情况

    对于无虚函数的类A:

    class A
    {
    void g(){.....}
    };
    则sizeof(A)=1;
    如果改为如下:
    class A
    {
    public:
        virtual void f()
        {
           ......
        }
        void g(){.....}
    }
    则sizeof(A)=4! 这是因为在类A中存在virtual function,为了实现多态,每个含有virtual function的类中都隐式包含着一个静态虚指针vfptr指向该类的静态虚表vtable, vtable中的表项指向类中的每个virtual function的入口地址
    例如 我们declare 一个A类型的object :
        A c;
        A d;
    则编译后其内存分布如下:

    从 vfptr所指向的vtable可以看出,每个virtual function都占有一个entry,例如本例中的f函数。而g函数因为不是virtual类型,故不在vtable的表项之内。说明:vtab属于类成员静态pointer,而vfptr属于对象pointer

    继承类的内存分布状况
    假设代码如下:
    public B:public A
    {
    public :
        int f() //override virtual function
        {
            return 3;
        }
    };

    A c;
    A d;
    B e;
    编译后,其内存分布如下:

    从中我们可以看出,B类型的对象e有一个vfptr指向vtable address:0x00400030 ,而A类型的对象c和d共同指向类的vtable address:0x00400050a

    动态绑定过程的实现
        我们说多态是在程序进行动态绑定得以实现的,而不是编译时就确定对象的调用方法的静态绑定。
        其过程如下:
        程序运行到动态绑定时,通过基类的指针所指向的对象类型,通过vfptr找到其所指向的vtable,然后调用其相应的方法,即可实现多态。
    例如:
    A c;
    B e;
    A *pc=&e; //设置breakpoint,运行到此处
    pc=&c;
    此时内存中各指针状况如下:

    可以看出,此时pc指向类B的虚表地址,从而调用对象e的方法。继续运行,当运行至pc=&c时候,此时pc的vptr值为0x00420050,即指向类A的vtable地址,从而调用c的方法。

    对于虚函数调用来说,每一个对象内部都有一个虚表指针,该虚表指针被初始化为本类的虚表。所以在程序中,不管你的对象类型如何转换,但该对象内部的虚表指针是固定的,所以呢,才能实现动态的对象函数调用,这就是C++多态性实现的原理。

       需要注意的几点
       总结(基类有虚函数):
         1、每一个类都有虚表。
         2、虚表可以继承,如果子类没有重写虚函数,那么子类虚表中仍然会有该函数的地址,只不过这个地址指向的是基类的虚函数实现。如果基类3个虚函数,那么基类的虚表中就有三项(虚函数地址),派生类也会有虚表,至少有三项,如果重写了相应的虚函数,那么虚表中的地址就会改变,指向自身的虚函数实现。如果派生类有自己的虚函数,那么虚表中就会添加该项。
         3、派生类的虚表中虚函数地址的排列顺序和基类的虚表中虚函数地址排列顺序相同。

    下面想将虚函数和纯虚函数做个比较

    虚函数

     引入原因:为了方便使用多态特性,我们常常需要在基类中定义虚函数。

      纯虚函数
     引入原因:为了实现多态性,纯虚函数有点像java中的接口,自己不去实现过程,让继承他的子类去实现。

        在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。 这时我们就将动物类定义成抽象类,也就是包含纯虚函数的类
        纯虚函数就是基类只定义了函数体,没有实现过程定义方法如下

      virtual void Eat() = 0; 直接=0 不要 在cpp中定义就可以了 
    虚函数和纯虚函数的区别
    1虚函数中的函数是实现的哪怕是空实现,它的作用是这个函数在子类里面可以被重载,运行时动态绑定实现动态
    纯虚函数是个接口,是个函数声明,在基类中不实现,要等到子类中去实现
    2 虚函数在子类里可以不重载,但是虚函数必须在子类里去实现。

    类的多继承

    一个类可以从多个基类中派生,也就是说:一个类可以同时拥有多个类的特性,是的,他有多个基类。这样的继承结构叫作“多继承”,最典型的例子就是沙发-床了:

    SleepSofa类继承自Bed和Sofa两个类,因此,SleepSofa类拥有这两个类的特性,但在实际编码中会存在如下几个问题。

    a)         SleepSofa类该如何定义?

    Class SleepSofa : public Bed, public Sofa

    {

           ….

    }

     构造顺序为:Bed  sofa  sleepsofa (也就是书写的顺序)

                        

    b)        Bed和Sofa类中都有Weight属性页都有GetWeight和SetWeight方法,在SleepSofa类中使用这些属性和方法时,如何确定调用的是哪个类的成员?

    可以使用完全限定名的方式,比如:

    Sleepsofa objsofa;

    Objsofa.Bed::SetWeight(); // 给方法加上一个作用域,问题就解决了。

    虚继承

    倘若,我们定义一个SleepSofa对象,让我们分析一下它的构造过程:它会构造Bed类和Sofa类,但Bed类和Sofa类都有一个父类,因此Furniture类被构造了两次,这是不合理的,因此,我们引入了虚继承的概念。

    class Furniture{……};

    class Bed : virtual public Furniture{……}; // 这里我们使用虚继承

    class Sofa : virtual public Furniture{……};// 这里我们使用虚继承

    class sleepSofa : public Bed, public Sofa {……};

                         这样,Furniture类就之构造一次了……

    总结下继承情况中子类对象的内存结构:

    单继承情况下子类实例的内存结构

    假设我们有这样的一个类:
     
    class Base {
         public:
                virtual void f() { cout << "Base::f" << endl; }
                virtual void g() { cout << "Base::g" << endl; }
                virtual void h() { cout << "Base::h" << endl; }
     
    };
     
    按照上面的说法,我们可以通过Base的实例来得到虚函数表。 下面是实际例程:
     
              typedef void(*Fun)(void);
     
                Base b;
     
                Fun pFun = NULL;
     
                cout << "虚函数表地址:" << (int*)(&b) << endl;
                cout << "虚函数表 — 第一个函数地址:" << (int*)*(int*)(&b) << endl;
     
                // Invoke the first virtual function 
                pFun = (Fun)*((int*)*(int*)(&b));
                pFun();
     
    实际运行经果如下:(Windows XP+VS2003,  Linux 2.6.22 + GCC 4.1.3)
     
    虚函数表地址:0012FED4
    虚函数表 — 第一个函数地址:0044F148
    Base::f
     
     
    通过这个示例,我们可以看到,我们可以通过强行把&b转成int *,取得虚函数表的地址,然后,再次取址就可以得到第一个虚函数的地址了,也就是Base::f(),这在上面的程序中得到了验证(把int* 强制转成了函数指针)。通过这个示例,我们就可以知道如果要调用Base::g()和Base::h(),其代码如下:
     
                (Fun)*((int*)*(int*)(&b)+0);  // Base::f()
                (Fun)*((int*)*(int*)(&b)+1);  // Base::g()
                (Fun)*((int*)*(int*)(&b)+2);  // Base::h()
     
    这个时候你应该懂了吧。什么?还是有点晕。也是,这样的代码看着太乱了,用下图解释一下。如下所示:
     

    (1)一般继承(无虚函数覆盖)

    假设有如下所示的一个继承关系:

    在这个继承关系中,子类没有重载任何父类的函数。那么,在派生类的实例中,其虚函数表如下所示:
     对于实例:Derive d; 的虚函数表如下:
     
    我们可以看到下面几点:
    1)虚函数按照其声明顺序放于表中。
    2)父类的虚函数在子类的虚函数前面。

    (2)一般继承(有虚函数覆盖)

    在这个类的设计中,假设只覆盖了父类的一个函数:f()。那么,对于派生类的实例,其虚函数表会是下面的一个样子

    我们从表中可以看到下面几点,
    1)覆盖的f()函数被放到了虚表中原来父类虚函数的位置。
    2)没有被覆盖的函数依旧。
    这样,我们就可以看到对于下面这样的程序,
     
                Base *b = new Derive();
     
                b->f();
     
    由b所指的内存中的虚函数表的f()的位置已经被Derive::f()函数地址所取代,于是在实际调用发生时是Derive::f()被调用了。这就实现了多态。
     
    在单继承下,对应于例程:

    class A

    {

    public:

        A(){m_A = 0;}

        virtual fun1(){};

        int m_A;

    };

    class B:public A

    {

    public:

        B(){m_B = 1;}

        virtual fun1(){};

        virtual fun2(){};

        int m_B;

    };

    int main(int argc, char* argv[])

    {

        B* pB = new B;

           return 0;

    }

    则在VC6.0下的内存分配图:

    在该图中,子类只有一个虚函数表,与以上的两种情况向符合。

    多继承情况下子类实例的内存结构(非虚继承)

    (1)多重继承(无虚函数覆盖)

    假设有下面这样一个类的继承关系。注意:子类并没有覆盖父类的函数:

    对于子类实例中的虚函数表,是下面这个样子:

    我们可以看到:
    1)  每个父类都有自己的虚表。
    2)  子类的成员函数被放到了第一个父类的表中。(所谓的第一个父类是按照声明顺序来判断的)
     

    (2)多重继承(有虚函数覆盖)

    下图中,我们在子类中覆盖了父类的f()函数。

    下面是对于子类实例中的虚函数表的图:

    我们可以看见,三个父类虚函数表中的f()的位置被替换成了子类的函数指针。这样,我们就可以任一静态类型的父类来指向子类,并调用子类的f()了。如:
     
                Derive d;
                Base1 *b1 = &d;
                Base2 *b2 = &d;
                Base3 *b3 = &d;
                b1->f(); //Derive::f()
                b2->f(); //Derive::f()
                b3->f(); //Derive::f()
     
                b1->g(); //Base1::g()
                b2->g(); //Base2::g()
                b3->g(); //Base3::g()

    在多继承(非虚继承)情况下,对应于以下例程序:

    #include <stdio.h>

    class A

    {

    public:

        A(){m_A = 1;};

        ~A(){};

        virtual int funA(){printf("in funA "); return 0;};

        int m_A;

    };

    class B

    {

    public:

        B(){m_B = 2;};

        ~B(){};

        virtual int funB(){printf("in funB "); return 0;};

        int m_B;

    };

    class C

    {

    public:

        C(){m_C = 3;};

        ~C(){};

        virtual int funC(){printf("in funC "); return 0;};

        int m_C;

    };

    class D:public A,public B,public C

    {

    public:

        D(){m_D = 4;};

        ~D(){};

        virtual int funD(){printf("in funD "); return 0;};

        int m_D;

    };

    则在VC6.0下的内存分配图:

    从该图中可以看出,此时子类中确实有三个来自于父类的虚表。

    多继承情况下子类实例的内存结构(存在虚继承)

    在虚继承下,Der通过共享虚基类SuperBase来避免二义性,在Base1,Base2中分别保存虚基类指针,Der继承Base1,Base2,包含Base1, Base2的虚基类指针,并指向同一块内存区,这样Der便可以间接存取虚基类的成员,如下图所示:

    class SuperBase

    {

    public:

        int m_nValue;

        void Fun(){cout<<"SuperBase1"<<endl;}

        virtual ~SuperBase(){}

    };

    class Base1:  virtual public SuperBase

    {

    public:

    virtual ~ Base1(){}

    };

    class Base2:  virtual public SuperBase

    {

    public:

    virtual ~ Base2(){}

    };

    class Der:public Base1, public Base2

    {

    public:

    virtual ~ Der(){}

    };

    void main()

    {

    cout<<sizeof(SuperBase)<<sizeof(Base1)<<sizeof(Base2)<<sizeof(Der)<<endl;

    }

    1) GCC中结果为8, 12, 12, 16

    解析:sizeof(SuperBase) = sizeof(int) + 虚函数表指针

    sizeof(Base1) = sizeof(Base2) = sizeof(int) + 虚函数指针 + 虚基类指针

    sizeof(Der) = sizeof(int) + Base1中虚基类指针 + Base2虚基类指针 + 虚函数指针 

    GCC共享虚函数表指针,也就是说父类如果已经有虚函数表指针,那么子类中共享父类的虚函数表指针空间,不在占用额外的空间,这一点与VC不同,VC在虚继承情况下,不共享父类虚函数表指针,详见如下。

    2)VC中结果为:8, 16, 16, 24

     解析:sizeof(SuperBase) = sizeof(int) + 虚函数表指针

    sizeof(Base1) = sizeof(Base2) = sizeof(int) + SuperBase虚函数指针 + 虚基类指针 + 自身虚函数指针

    sizeof(Der) = sizeof(int) + Base1中虚基类指针 + Base2中虚基类指针 + Base1虚函数指针 + Base2虚函数指针 + 自身虚函数指针

     如果去掉虚继承,结果将和GCC结果一样,A,B,C都是8,D为16,原因就是VC的编译器对于非虚继承,父类和子类是共享虚函数表指针的。

     (1)  部分虚继承的情况下子类实例的内存结构:

    #include "stdafx.h"

    class A

    {

    public:

      A(){m_A = 0;};

      virtual funA(){};

      int m_A;

    };

    class B

    {

    public:

      B(){m_B = 1;};

      virtual funB(){};

      int m_B;

    };

    class C

    {

    public:

      C(){m_C = 2;};

      virtual funC(){};

      int m_C;

    };

    class D:virtual public A,public B,public C

    {

    public:

        D(){m_D = 3;};

        virtual funD(){};

        int m_D;

    };

    int main(int argc, char* argv[])

    {

        D* pD = new D;

           return 0;

    }

    (2)全部虚继承的情况下,子类实例的内存结构

    class A

    {

    public:

        A(){m_A = 0;}

        virtual funA(){};

        int m_A;

    };

    class B

    {

    public:

        B(){m_B = 1;}

        virtual funB(){};

        int m_B;

    };

    class C:virtual public A,virtual public B

    {

    public:

        C(){m_C = 2;}

        virtual funC(){};

        int m_C;

    };

    int main(int argc, char* argv[])

    {

        C* pC = new C;

           return 0;

    }

     

    (3) 菱形结构继承关系下子类实例的内存结构

    class A

    {

    public:

        A(){m_A = 0;}

        virtual funA(){};

        int m_A;

    };

    class B :virtual public A

    {

    public:

        B(){m_B = 1;}

        virtual funB(){};

        int m_B;

    };

    class C :virtual public A

    {

    public:

        C(){m_C = 2;}

        virtual funC(){};

        int m_C;

    }; 

       

    class D: public B, public C

    {

    public:

          D(){m_D = 3;}

          virtual funD(){};

          int m_D;

    };

    int main(int argc, char* argv[])

    {

            D* pD = new D;

            return 0;

    }

    对于子类虚表的个数和设置,貌似虚继承与非虚继承的差别不是很大。

    参考:

    http://blog.csdn.net/chen_yi_long/article/details/8662822

    http://blog.csdn.net/zyq0335/article/details/7657465

    http://haoel.blog.51cto.com/313033/124595/

    http://blog.csdn.net/xsh_123321/article/details/5956289

  • 相关阅读:
    拦截器实现对用户是否登录及登陆超时的验证
    Spring+Websocket实现消息的推送
    经典 socket通讯 -- 已验证
    Unity编辑器扩展之RequireComponent等详解
    如何理解着色器,渲染管线,光栅化等概念?
    Unity3D研究院编辑器之脚本设置ToolBar及脚本设置顶视图
    Unity3D研究院编辑器之重写Hierarchy的右键菜单
    Unity3D研究院编辑器之自定义默认资源的Inspector面板
    Unity3D研究院之拓展系统自带组件的Inspector视图
    Unity3D研究院之Inspector视图中的get/set使用
  • 原文地址:https://www.cnblogs.com/raichen/p/5576736.html
Copyright © 2020-2023  润新知