C++对象模型学习——Function语意学

       如果有一个Point3d的指针和对象:

Point3d obj;
Point3d *ptr = &obj;

       当这样做:

obj.normalize();
ptr->normalize();

        时,会发生什么事?其中的Point3d::normalize()定义如下:

Point3d Point3d::normalize() const
{
  register float mag = magnitude();
  Point3d normal;

  normal._x = _x / mag;
  normal._y = _y / mag;
  normal._z = _z / mag;

  return normal;
}

        而其中的Point3d::magnitude()又定义如下:

float Point3d::magnitude() const
{
  return sqrt( _x * _x + _y * _y + _z * _z );
}

       答案是不明确的。C++支持三种类型的member functions:static、nonstatic和virtual,每一

种类型被调用的方式都不同。不过我们虽不能确定normalize()和magnitude()两函数是否为

virtual或nonvirtual,但可以确定它一定不是static,原因有二:(1)它直接存取nonstatic数据,(2)

它被声明为const。而static member functions不可能做到这两点。

一、Member的各种调用方式

       1、非静态成员函数(Nonstatic Member Functions)

       C++的设计准则之一就是:nonstatic member function至少必须和一般的nonmember

function有相同的效率。也就是说,如果我们要在以下两个函数之间做选择:

float magnitude3d( const Point3d *_this ){ ... }
float Point3d::magnitude3d() const { ... }

        选择member function不应该带来什么额外负担。这是因为编译器内部已将”member 函数实

例“转换为对等的”nonmember函数实例“。

        比如下面是magnitude()的一个nonmember定义:

float magnitude3d( const Point3d *_this )
{
  return sqrt( _this->_x * _this->_x +
               _this->_y * _this->_y +
               _this->_z * _this->_z );
}

        咋看之下似乎nonmember function比较没有效率,它间接地经由参数取用坐标成员,而

member function却是直接取用坐标成员。然而实际上member function被内化为nonmember的

形式。下面是转化步骤:

        1)改写函数的signature(函数原型)以安插一个额外的参数到member function中,用以

提供一个存取管道,使class object得以将此函数调用。该额外参数被称为this指针:

// non-const nonstatic member的扩张过程
Point3d Point3d::magnitude( Point3d *const this )

             如果member function 是const,则变为:

// const nonstatic member的扩张过程
Point3d Point3d::magnitude( const Point3d *const this )

        2)将每一个”对nonstatic data member的存取操作“改为经由this指针来存取:

{
  return sqrt( this->_x * this->_x +
               this->_y * this->_y +
               this->_z * this->_z );
}

        3)将member function重新写成一个外部函数。将函数名称经过”mangling“处理,使它在程

序中成为独一无二的语汇:

extern magnitude_7Point3dFv(
   register Point3d *const this );

        现在这个函数已经被转换好了,而其每一个调用操作也都必须转换。于是:

obj.magnitude();

// 转换为:
magnitude_7Point3dFv( &obj );

         而

ptr->magnitude();

// 转换为:
magnitude_7Point3dFv( ptr );

        而normalize()函数会被转化为下面的形式,其中假设已经声明有一个Point3d copy

constructor,而named returned value(NRV)的优化也已经实施:

// 以下描述”named return value函数“的内部转化
// 使用C++代码
void normalize_7Point3dFv( register const Point3d *const this, 
                           Point3d &_result )
{
  register float mag = this->magnitude();
  
  // default constructor
  _result.Point3d::Point3d();

  _result._x = this->_x / mag;
  _result._y = this->_y / mag;
  _result._z = this->_z / mag;

  return;
}

           一个有效率的做法是直接构建”normal“值,像这样:

Point3d Point3d::normalize() const
{
  register float mag = magnitude();
  return Point3d( _x / mag, _y / mag, _z / mag ); 
}

            它会转化为以下的代码(再一次假设Point3d的copy constructor已经声明好了,而NRV

的优化也已实施):

// 以下描述内部转化
// 使用C++伪码
void normalize_7Point3dFv( register const Point3d *const this, Point3d & _result )
{
  register float mag = this->magnitude();

  // _result用以取代返回值(return value)
  _result.Point3d::Point3d( this->_x / mag, this->_y / mag, this->_z / mag );

  return;
}

          这可以节省default constructor初始化所引起的额外负担。

       2、名称的特殊处理(Name Mangling)

       一般而言,member的名称前面会被加上class名称,形成独一无二的命令。例如下面的声

明:    

class Bar { public: int ival; ... };

       其中的ival有可能变成这样:

// member经过name-mangling之后的可能结果之一
ival_3Bar

       为什么编译器要这么做?清考虑这样的派生操作(derivation):

class Foo : public Bar { public: int val;... };

       Foo对象内部结合了base class和derived class两者:

// C++伪码
// Foo的内部描述
class Foo
{
  public:
    int ival_3Bar;
    int ival_3Foo;
};

        不管处理哪个ival,通过”name mangling“,都可以绝对清楚地指出来。由于member

functions可以被重载化(overload),所以需要更广泛的mangling手法,以提供对独一无二的

名称。如果把:

class Point
{
  public:
    void x( float newX );
    float x();
    ...
};

         转换为:

class Point
{
  public:
    void x_5Point( float newX );
    float x_5Point();
  ...
};

       会导致两个被重载化(overloaded)的函数实例拥有相同的名称。为了让它们独一无二,唯

有再加上它们的参数链表(可以从函数原型中参考得到)。如果把参数类型也编码进取,就一

定可以制造出独一无二的结果,使我们的两个x() 函数有良好的转换(如果声明extern ”C“,就会

压抑nonmember functions的”mangling“效果):

class Point
{
  public:
    void x_5PointFf( float newX );
    float x_5PointFv();
    ...
};

        把参数和函数名称编码在一起,编译器于是在不同的编译模块之间达成了一种优先形式的

类型检验。如下print函数被这样定义:

void print( const Point3d& ) { ... }

         但意外地被这样声明和调用:

// 以为是const Point3d&
void print( const Point3d );

         两个实例如果拥有独一无二的name mangling,那么任何不正确的调用操作在链接时期就

因无法决议(resolved)而失败。但如果是”返回类型“声明错误就没办法检查出来。

       3、虚拟成员函数(Virtual Member Functions)

       如果normalize()是一个virtual member function,那么以下的调用:

ptr->normalize();

        将会被内部转化为:

( *ptr->vptr[ 1 ] )( ptr );

        其中:

        1)vptr表示由编译器产生的指针,指向virtual table。它被安插在每一个“声明有(或继承

自),一个或多个virtual functions”的class object中。其名称也会被“mangled”,因为在一个复

杂的class派生体系中,可能存在多个vptrs。

         2)1是virtual table slot的索引值,关联到normalize()函数。

         3)第二个ptr表示this指针。

          同样,如果magnitude()也是一个virtual function,它在normalize()之中的调用操作将被转

换如下:

// register float mag = magnitude();
register float mag = ( *this->vptr[ 2 ] )( this );

          由于Point3d::magnitude()是在Point3d::normalize()中被调用的,而后者已经由虚拟机制而

决议妥当,所以显示地调用“Point3d实例”会比较有效率,并因此压制由于虚拟机制而产生的不

必要重复调用操作:

// 显示的调用操作(explicitly invocation)会压制虚拟机制
register float mag = Point3d::magnitude();

            如果magnitude()声明为inline函数,会更有效率。使用class scope operator显示调用一

个virtual function,其决议方式会和nonstatic member function一样:

register float mag = magnitude_7Point3dFv( this );

            对于以下调用:

// Point3d obj;
obj.normalize();

            如果编译器把它转换为:

( *obj.vptr[ 1 ] )( &obj );

            虽然语意正确,却没有必要。”经由一个class object 调用一个virtual function“,这种操作

应该总是被编译器像对待一般nonstatic member function一样地加以决议:

normalize_7Point3dFv( &obj );

             这项优化的一利益是,virtual function的一个inline函数实例可以被扩展(expanded)开

来,因而提供极大的效率利益。 

       4、静态成员函数(Static Member Functions)

           如果Point3d::normalize()是一个static member function,以下两个调用操作:

obj.normalize();
ptr->normalize();

           将被转换为一般的nonmember函数调用,如下:

// obj.normalize();
normalize_7Point3dSFv();
// ptr->normalize();
normalize_7Point3dSFv();

            在C++引入static member functions之前,很少会看到如下怪异写法:

( ( Point3d* )0 )->object_count();

            其中的object_count只是简单传回_object_count这个static data member。

            在引入static member functions之前,C++语言要求所有的member functions都必须经由

该class的object来调用。而实际上,只有当一个或多个nonstatic data members在member

function中被直接存取时,才需要class object。Class object提供了this指针给这种形式的函数

调用使用。这个this指针把”在member function中存取的nonstatic class members“绑定于”object

内对应的members“之上。如果没有任何一个members被直接存取,事实上就不需要this指针,

因此也就没必要通过一个class object来调用一个member function。

            这么一来就存取static data members时产生了一些不规则性。如果class的设计者把static

data member声明为nonpublic(这一直被视为一种好的习惯),那么他就必须提供一个或多个

member functions来存取该member。因此,虽然你可以不靠class object来存取一个static

member,但其存取函数却得绑定于一个class object之上。

            独立于class object之外的存取操作,在某个时候特别重要:当class设计者希望支持”没

有class object存在“的情况时。程序方法上的解决之道是很奇特地把0强制转换为一个class指

针,因而提供出一个this指针实例:

// 函数调用的内部转换
object_count( ( Point3d* )0 );

            Static member functions的主要特性就是它没有this指针。以下次要特性统统根源于其主

要特性:

            1)它不能直接存取其class中的nonstatic members。

            2)它不能够被声明为const、volatile或virtual。

            3)它不需要经由class object才被调用——虽然大部分时候它是这样被调用的!

            “member selection”语法的使用是一种符号上的便利,它会被转化为一个直接调用操作:

if( Point3d::object_count() > 1 ) ...

             如果class object是因为某个表达式而获得的,会如何?例如:

if( foo().object_count() > 1 ) ...

             这个表达式仍然需要被评估求值:

// 转化,以保存副作用
( void ) foo();
if( Point3d::object_count() > 1 ) ...

             一个static member function,当然会被提出于class声明之外,并给予一个经过

“mangled”的适当名字。例如:

unsigned int Point3d::object_count()
{
  return _object_count;
}

            会被cfront转化为:

// 在cfront之下的内部转化结果
unsigned int object_count_5Point3dSFv()
{
  return _object_count_5Point3d;
}

            其中SFv表示它是一个static member function,拥有一个空白(void)的参数链表

(argument list)。

             由于static member function没有this指针,所以其地址的类型并不是一个“指向class

member function的指针”,而是一个“nonmember函数指针”。也就是说:

&Point3d::object_count();

             会得到一个数值,类型是:

unsigned int (*)();

             而不是:

unsigned int ( Point3d::* )(  );

             Static member function由于缺乏this指针,因此差不多等同于nonmember function。它

提供了一个意想不到的好处:成为一个callback函数,使我们得以将C++和C-base X Window系

统结合。它们也可以成功地应用在线程(threads)函数身上。

二、Virtual Member Function(虚拟成员函数)

         virtual function的一般实现模型:,每一个class 有一个virtual table,内含该

class之中有作用的virtual function的地址,然后每个object有一个vptr,指向virtual

table的所在。

           为了支持virtual function机制,必须首先能够对于多态对象有某种形式的“执行期类型判断

(runtime type resolution)”。也就是说以下的调用操作将需要ptr在执行期的某些相关信息:

ptr->z();

           如此一来才能够找到并调用z()的适当实例。

           或许直截了当但是成本最高的解决方法就是把必要信息加载ptr身上。在这样的策略之

下,一个指针(或是一个reference)持有两项信息:

          1)它所参考到的对象的地址(也就是目前它所持有的东西);

          2)对象类型的某种编码,或是某个结构(内含某些信息,用以正确决议出z()函数实例)

的地址。

           这个方法带来两个问题:第一,它明显增加了空间负担,即使程序并不使用多态

(polymorphism);第二,它打断了与C程序间的链接兼容性。

           如果这份额外信息不能够和指针放在一起,下一个可以考虑的地方就是把它放在对象本

身。但是哪一个对象真正需要这些信息呢?我们应该把这些信息放进可能被继承的每一个集合

体身上呢?也许。但请考虑一下这样的C struct声明:

struct date { int m, d, y; };

            这符合上述规则。然而事实上它并不需要那些信息。加上那些信息将使C struct膨胀并且

打破链接兼容性,却没有带来任何明显的补偿利益。

             而面对那些显示使用了class关键词的声明,才应该加上额外的执行期信息。这样做可以

保持语言的兼容性,不过仍然不是一个够聪明的政策。例如,下面这个class符合新规则:

class data { public: int m, d, y; };

            但实际上它并不需要那份信息。下面的class声明虽然不符合新规范,却需要那份信息:

struct geom { public: virtual ~geom(); ... };

            我们需要一个以class的使用为基础,而不在乎关键词是class或struct的规范。如果class

真正需要那份信息,它就会存在;如果不需要,它就不存在。很明显在必须支持某种形式之“执行

期多态(runtime polymorphism)”的时候需要这份信息。

             在C++中,多态(ploymorphism)表示“一个public base class的指针(或reference),

寻找出一个derived class object”的意思。例如下面的声明:

Point *ptr;

             我们可以指定ptr以寻址出一个Point2d对象:

ptr = new Point2d;

             或是一个Point3d对象:

ptr = new Point3d;

              ptr的多态技能主要扮演一个输送机制(transport mechanism)的角色,经由它,我们

可以在程序的任何地方采用一组public derived类型。这种多态形式被称为是消极的(passive)

,可以在编译时期完成——virtual base class的情况除外。        

              当被指出的对象真正被使用时,多态也就变成积极的(active)了。下面对于virtual

function的调用,就是一例:

// "积极多态(active ploymorphism)"的常见例子
ptr->z();

              在funtime type identification(RTTI)性质于1993年被引入C++语言之前,C++对“积极

多态(active polymorphism)”的唯一支持,就是对于virtual function call 的决议操作。有了

RTTI,就能够在执行期查询一个多态的pointer或多态的reference了:

// "积极多态(active polymorphism)"的第二个例子
if( Point3d *p3d = dynamic_cast<Point3d*>( ptr ) )
  return p3d->_Z;

              所以欲鉴定哪些classes展现多态特性,我们需要额外的执行期信息。关键词class和

struct并不能帮助我们。由于没有导入像是polymorphic之类的新关键词,因此识别一个class是

否支持多态,唯一适当的方法就是看看它是否有任何virtual function。只要class拥有一个virtual

function,它就需要这份额外的执行期信息。 

            下一个明显的问题是,什么样的额外信息是我们需要存储起来的?也就是说,如果有这

样的调用:

ptr->z();

            其中z()是一个virtual function,那么什么信息才能让我们在执行期调用正确的z()实例?

需要知道:

            1)ptr所指对象的真实类型。这可使我们选择正确的z()实例。

            2)z()实例的位置,以便能够调用它。

            在实际上,首先可以在每一个多态的class object身上增加两个members:

            1)一个字符串或数字,表示class的类型。

            2)一个指针,指向某表格,表格中持有程序的virtual functions的执行期地址。

            关于表格中的virtual functions地址如何被构建起来。在C++中,virtual functions(可经由

其class object被调用)可以在编译时期获知。此外,这一组地址是固定不变的,执行期不可能

新增或替换之。由于程序执行时,表格大小和内容不会改变,所以其建构和存取皆可以由编译

器完全掌控,不需要执行期的任何介入。         

             然而,执行期备妥那些函数地址,只是解答的一半而已。另一半解答是找到那些地址。

两个步骤可以完成这项任务:

            1)为了找到表格,每一个class object被安插了一个由编译器内部产生的指针,指向该

表格。

            2)为了找到函数地址,每一个virtual function被指派一个表格索引值。

           这些工作都是由编译器完成。执行期要做的,只是在特定的virtual table slot中激活virtual

function。

            一个class只会有一个virtual table。每一个table内含其对应之class object中所有active

virtual function函数实例的地址。包括:

            1)这一class所定义的函数实例。它会改写(overriding)一个可能存在的base class

virtual function函数实例。

            2)继承自base class的函数实例。这是在derived class决定不改写virtual function时才会

出现的情况。

            3)一个pure_virtual_called()函数实例,它既可以扮演pure virtual function的空间保卫者

角色,也可以当做执行期异常处理函数(有时候会用到)。

            每一个virtual function都被指派一个固定的索引值,这个索引在整个继承体系中保持与特

定的virtual function的关系。例如我们的Point class体系中:

#include <iostream>

class Point
{
  public:
    Point( float x = 0.0 ) : _x( x ) { }
    virtual ~Point() { }
    
    virtual int mult( float ) = 0;
    // ...其他操作
    
    float x() const { return _x; }
    virtual float y() const { return 0; }
    virtual float z() const { return 0; }
    // ...

  protected:
    float _x;
};

class Point2d : public Point
{
  public:
    Point2d( float x = 0.0, float y = 0.0 )
            : Point( x ), _y( y ) { }
    ~Point2d() { }
  
  // 改写base class virtual functions
  int mult( float y ) { return 1; }
  float y() const { return _y; }
  // ...其他操作

  protected:
    float _y;
};

class Point3d : public Point2d
{
  public:
    Point3d( float x = 0.0, float y = 0.0, float z = 0.0 )
            : Point2d( x, y ), _z( z ) { }
    ~Point3d() { }
  
  // 改写base class virtual functions
  int mult( float z ) { return 2; }
  float z() const { return _z; }
  // ...其他操作

  protected:
    float _z;
};

int main()
{
  Point2d point2d;
  Point3d point3d;

  std::cout << "sizeof( Point ) = " << sizeof( Point ) << std::endl;
  std::cout << "sizeof( point2d ) = " << sizeof( point2d ) << std::endl;
  std::cout << "sizeof( point3d ) = " << sizeof( point3d ) << std::endl; 

  return 0;
}

         下面是class Point的虚表:

	.section	.rodata._ZTV5Point,"aG",@progbits,_ZTV5Point,comdat
	.align 8
	.type	_ZTV5Point, @object
	.size	_ZTV5Point, 28
_ZTV5Point:                         # vtable for Point
	.long	0
	.long	_ZTI5Point          # typeinfo for Point
	.long	_ZN5PointD1Ev       # Point::~Point()
	.long	_ZN5PointD0Ev       # Point::~Point()
	.long	__cxa_pure_virtual
	.long	_ZNK5Point1yEv      # Point::y() const
	.long	_ZNK5Point1zEv      # Point::z() const

        virtual destructor被指派slot 2,3,而mult()被指派slot 4。此例并没有mult()的函数定义(因

为它是一个pure virtual function),所以pure_virtual_called()的函数地址会被放在slot 4。如果

该函数意外地被调用,通常操作是结束这个程序。y()被指派slot 5,z被指派slot 6。x()不是

virtual function所以不存在虚表中。

        下面是class Point2d的虚表:

	.section	.rodata._ZTV7Point2d,"aG",@progbits,_ZTV7Point2d,comdat
	.align 8
	.type	_ZTV7Point2d, @object
	.size	_ZTV7Point2d, 28
_ZTV7Point2d:                      # vtable for Point2d
	.long	0
	.long	_ZTI7Point2d       # typeinfo for Point2d
	.long	_ZN7Point2dD1Ev    # Point2d::~Point2d()
	.long	_ZN7Point2dD0Ev    # Point2d::~Point2d()
	.long	_ZN7Point2d4multEf # Point2d::mult(float)
	.long	_ZNK7Point2d1yEv   # Point2d::y() const
	.long	_ZNK5Point1zEv     # Point::z() const

          当一个class派生自Point时,会发生什么事?

          一共有三种可能性:

         1)它可以继承base class所声明的virtual function的函数实例。正确地说是,该函数实例

的地址会被拷贝到derived class的virtual table的相对应slot之中。 

          2)它可以使用自己的函数实例。这表示它自己的函数实例地址必须放在对应的slot之中。

          3)它可以加入一个新的virtual function。这时候virtual table的尺寸会增大一个slot,而新

的函数实例地址会被放进该slot之中。

           Point2d的virtual table在slot 2,3中指出destructor,而slot 4中指出mult()(取代pure

virtual function)。它自己的y()函数实例地址放在slot 5中,继承自Point的z()函数实例地址则放

在slot 6中。

          下面是class Point3d的虚表:

        .section	.rodata._ZTV7Point3d,"aG",@progbits,_ZTV7Point3d,comdat
	.align 8
	.type	_ZTV7Point3d, @object
	.size	_ZTV7Point3d, 28
_ZTV7Point3d:                      # vtable for Point3d
	.long	0
	.long	_ZTI7Point3d       # typeinfo for Point3d
	.long	_ZN7Point3dD1Ev    # Point3d::~Point3d()
	.long	_ZN7Point3dD0Ev    # Point3d::~Point3d()
	.long	_ZN7Point3d4multEf # Point2d::mult(float)
	.long	_ZNK7Point2d1yEv   # Point2d::y() const
	.long	_ZNK7Point3d1zEv   # Point3d::z() const

           同样对于派生自Point2d的Point3d,其virtual table中的slot 2,3放置Point3d的

destructor,slot 4放置Point3d::mult()函数地址,slot 5放置继承自Point2d的y()函数地址,slot 6

放置自己的z()函数地址。

           现在,如果有这样的式子:

ptr->z();

            如何有足够的知识在编译时期设定virtual function的调用呢?

          1)一般而言,在每次调用z()时,并不知道ptr所指对象的真正类型。然而知道经由ptr可以

存取到该对象的virtual table。

          2)虽然不知道哪一个z()函数实例会被调用,但知道每一个z()函数地址都被放在slot 6中。

          这些信息使得编译器可以将该调用转化为:

( *ptr->vptr[ 6 ] )( ptr );

          这一转化中,vptr表示编译器所安插的指针,指向virtual table;6表示z()被指派的slot编号

(关系到Point体系的virtual table)。唯一一个在执行期才能够知道的东西是:slot 6所指的到

底是哪一个z()函数实例。

          在一个单一继承体系中,virtual function 机制的行为十分良好,不但有效率而且很容易塑

造出模型来。但是在多重继承和虚拟继承之中,对virtual functions的支持就没那么美好了。

          1、多重继承下的virtual Functions

          在多重继承中支持virtual functions,其复杂度围绕在第二个及后继的base classes身上,

以及”必须在执行期调整this指针“这一点。以下面的class体系为例:

#include <iostream>

class Base1
{
  public:
    Base1( float base1 ) : data_Base1( base1 ) {  }
    virtual ~Base1() { }
    virtual void speakClearly() { data_Base1 += 1000.0; }
    virtual Base1 *clone() const { return new Base1( this->data_Base1 ); }

  protected:
    float data_Base1;
};

class Base2
{
  public:
    Base2( float base2 ) : data_Base2( base2 ) {  }
    virtual ~Base2() { }
    virtual void mumble() { data_Base2 -= 1.0; }
    virtual Base2 *clone() const { return new Base2( this->data_Base2 ); }

  protected:
    float data_Base2;
};

class Derived : public Base1, public Base2
{
  public:
    Derived( float data1, float data2,  float derived )
             :  Base1( data1 ), Base2( data2 ), data_Derived( derived ) { }
    virtual ~Derived() { }
    virtual Derived *clone() const { return new Derived( this->data_Base1, this->data_Base2, this->data_Derived ); }

  protected:
    float data_Derived;
}; 

int main()
{
  Base1 base1( 1.0 );
  Base2 base2( 2.0 );
  Derived derived( 1.0, 2.0, 3.0 );

  std::cout << "sizeof( base1 ) = " << sizeof( base1 ) << std::endl;
  std::cout << "sizeof( base2 ) = " << sizeof( base2 ) << std::endl;
  std::cout << "sizeof( derived ) = " << sizeof( derived ) << std::endl;

  return 0;
}

          "Derived支持virtual functions"的困难度,统统落在Base2 subobject身上。有三个问题需

要解决,以此而言分别是(1)virtual destructor,(2)被继承下来的Base2::mumble(), (3)一组

clone()函数实例。

           首先,把一个从heap中配置而得的Derived对象的地址,指定给一个Base2指针:

Base2 *pbase2 = new Drived;

            新的derived对象的地址必须调整以指向其Base2 subobject。编译时期会产生以下的代

码:

// 转移以支持第二个base class
Derived *temp = new Drived;
Base2 *pbase2 = temp ? temp + sizeof( Base1 ) : 0;

             如果没有这样的调整,指针的任何”非多态运用“都将失败:

// 即使pbase2被指定一个Derived对象,这也应该没有问题
pbase2->data_Base2;

             当程序员要删除pbase2所指的对象时:

// 必须首先调用正确的virtual destructor函数实例
// 然后施行delete运算符
// pbase2 可能需要调整,以指出完整对象的起始点
delete pbase2;

              指针必须被再一次调整,以求再一次指向Drived对象的起始处。然而上述的offset加法

却不能够在编译时期直接设定,因为pbase2所指的真正对象只有在执行期才能确定。

             一般规则是,经由指向”第二或后继之base class“的指针(或reference)来调用derived

class virtual function。

Base2 *pbase2 = new Derived;
...
delete pbase2; // invoke derived class's destructor( virtual )

             其所连带的必要的”this指针调整”操作,必须在执行期完成。也就是说,offset的大小,

以及把offset加到this指针上头的那一小段程序代码,必须由编译器在某个地方插入。问题是,

在哪插入?

            Bjarne原先实施于cfront编译器中的方法是将virtual table 加大,使它容纳此处所需要的

this指针,调整相关事物。每一个virtual table slot,不再只是一个指针,而是一个集合体,内含

可能的offset以及地址。于是virtual function的调用操作由:

( *pbase2->vptr[ 1 ] )( pbase2 );

            改变为:

( *pbase2->vptr[ 1 ].faddr )
  ( pbase2 + pbase2->vptr[ 1 ].offset );

            其中faddr内含virtual function地址,offset内含this指针调整值。

            这个做法的缺点是,它相当于连坐“处罚”了所有的virtual function调用操作,不管它们是

否需要offset的调整。

             比较有效率的解决方法是利用所谓的thunk。所谓thunk是一小段assembly代码,用来(1)

以适当的offset值调整this指针,(2)跳到virtual function去。例如,经由一个Base2指针调用

Derived destructor,其相关的thunk可能看起来是这个样子:

// 虚拟C++代码
pbase2_dtor_thunk:
  this += sizeof( base1 );
  Derived::~Derived( this );

            Thunk技术允许virtual table slot继续内含一个简单的指针,因此多重继承不要任何空间上

的额外负担。Slots中的地址可以直接指向virtual function,也可以指向一个相关的thunk(如需

调整this指针的话)。

             调整this指针的第二个额外负担就是,由于两种不同的可能:(1)经由derived class(或

第一个base class)调用,(2)经由第二个(或后继)base class调用,同一函数在virtual table

中可能需要多笔对应的slots。例如:

Base1 *pbase1 = new Derived;
Base2 *pbase2 = new Derived;

delete pbase1;
delete pbase2;

            虽然两个delete操作导致相同的Derived destructor,但是它们需要两个不同的virtual

table slots:

           1)pbase1不需要调整this指针(因为Base1是最左端base class之故,它已经指向

Derived对象的起始处)。其virtual table slot需放置真正的destructor地址。

            2)pbase2需要调整this指针。其virtual table slot需要相关的thunk地址。

            在多重继承之下,一个derived class内含n - 1个额外的virtual tables,n表示其上一层

base classes的个数(因此,单一继承不会有额外的virtual tables)。对于本例的Derived而

言,会有两个virtual tables被编译器产生出来:

             1)一个主要实例,与Base1(最左端base class)共享。

             2)一个次要实例,与Base2(第二个base class)有关。

             针对每一个virtual tables,Derived对象中有对应的vptr。

            class Base1的虚表:

	.weak	_ZTV5Base1
	.section	.rodata._ZTV5Base1,"aG",@progbits,_ZTV5Base1,comdat
	.align 8
	.type	_ZTV5Base1, @object
	.size	_ZTV5Base1, 24
_ZTV5Base1:                               # vtable for Base1
	.long	0  
	.long	_ZTI5Base1                # typeinfo for Base1
	.long	_ZN5Base1D1Ev             # Base1::~Base1()
	.long	_ZN5Base1D0Ev             # Base1::~Base1()
	.long	_ZN5Base112speakClearlyEv # Base1::speakClearly()
	.long	_ZNK5Base15cloneEv        # Base1::clone() const

             class Base2的虚表:

	.weak	_ZTV5Base2
	.section	.rodata._ZTV5Base2,"aG",@progbits,_ZTV5Base2,comdat
	.align 8
	.type	_ZTV5Base2, @object
	.size	_ZTV5Base2, 24
_ZTV5Base2:                         # vtable for Base2
	.long	0
	.long	_ZTI5Base2          # typeinfo for Base2
	.long	_ZN5Base2D1Ev       # Base2::~Base2()
	.long	_ZN5Base2D0Ev       # Base2::~Base2()
	.long	_ZN5Base26mumbleEv  # Base2::mumble()
	.long	_ZNK5Base25cloneEv  # Base2::clone() const

              class Derived的虚表:

	.weak	_ZTV7Derived
	.section	.rodata._ZTV7Derived,"aG",@progbits,_ZTV7Derived,comdat
	.align 32
	.type	_ZTV7Derived, @object
	.size	_ZTV7Derived, 48
_ZTV7Derived:                                  # vtable for Derived
	.long	0
	.long	_ZTI7Derived                   # typeinfo for Derived
	.long	_ZN7DerivedD1Ev                # Derived::~Derived()
	.long	_ZN7DerivedD0Ev                # Derived::~Derived()
	.long	_ZN5Base112speakClearlyEv      # Base1::speakClearly()
	.long	_ZNK7Derived5cloneEv           # Derived::clone() const
	.long	-8
	.long	_ZTI7Derived                   # typeinfo for Derived
	.long	_ZThn8_N7DerivedD1Ev           # non-virtual thunk to Derived::~Derived()
	.long	_ZThn8_N7DerivedD0Ev           # non-virtual thunk to Derived::~Derived()
	.long	_ZN5Base26mumbleEv             # Base2::mumble()
	.long	_ZTchn8_h8_NK7Derived5cloneEv  # covariant return thunk to Derived::clone() const

            于是当你将一个Derived对象地址指定给一个Base1指针或Derived指针时,被处理的

virtual table是主要表格。而当你将一个Derived对象地址指定给一个Base2指针时,被处理的

virtual table是次要表格。

             由于执行期链接器(runtime linkers)的降临(可以支持动态共享函数库),符号名称的

链接可能变得非常缓慢。为了调节执行期连接器的效率,Sun编译器将多个virtual tables连锁为

一个:指向次要表格的指针,可由主要表格名称加上一个offset获得。这样的策略下,每一个

class只有一个具名的virtual table。

              有三种情况,第二或后继的base class会影响对virtual functions的支持。第一种情况

是,通过一个“指向第二个base class”的指针,调用derived class virtual function。例如:

Base2 *ptr = new Derived;

// 调整Derived::~Derived
// ptr必须被向后调整sizeof( Base1 )个bytes
delete ptr;

             这个操作的重点:ptr指向Derived对象中的Base2 subobject;为了能够正确执行,ptr必须

调整指向Derived对象的起始处。

             第二种情况是第一种情况的变化,通过一个“指向derived class”的指针,调用第二个

base class中一个继承而来的virtual function。在此情况下,derived class指针必须再次调整,

以指向第二个base subobject。例如:

Derived *pder = new Derived;

// 调用Base2::mumble()
// pder必须被向前调整sizeof( Base1 )个bytes
pder->mumble();

            第三种情况发生于一个语言扩充性质之下:允许一个virtual function的返回值类型有所变

化,可能是base type,也可能是publicly derived type。这一点可以经由Derived::clone()函数实

例来说明。clone函数的Derived版本回传一个Derived class指针,默默地改写了它的两个base

class函数实例。当我们通过“指向第二个base class”的指针来调用clone()时,this指针的offset问

题于是诞生了:

Base2 *pb1 = new Derived;

// 调用Derived* Derived::clone()
// 返回值必须被调整,以指向Base2 subobject
Base2 *pb2 = pb1->clone();

          当进行pb1->clone()时,pb1会被调整指向Derived对象的起始地址,于是clone()的

Derived版会被调用,它会传回一个指针,指向一个新的Derived对象;该对象的地址在被指定给

pb2之前,必须先经过调整,以指向Base2 subobject。

          Microsoft以所谓的“address points“来代替thunk策略。即将用来改写别人的那个函数

(overriding function)期待获得的是”引入该virtual function之class“(而非derived class)的地

址。这就是该函数的“address point”。

          2、虚拟继承下的Virtual Functions

          考虑下面的virtual base class派生体系,从Point2d派生出Point3d:

#include <iostream>

class Point2d
{
  public:
    Point2d( float x = 0.0, float y = 0.0 )
            : _x( x ), _y( y ) { }
    virtual ~Point2d() { }
  
  virtual void mumble( ) { _y += _x; }
  virtual float z() { return _x + _y; }

  protected:
    float _x, _y;
};

class Point3d : public virtual Point2d
{
  public:
    Point3d( float x = 0.0, float y = 0.0, float z = 0.0 )
            : Point2d( x, y ), _z( z ) { }
    ~Point3d() { }
  
  float z() { return _z; }

  protected:
    float _z;
};

int main()
{
  Point2d point2d;
  Point3d point3d;

  std::cout << "sizeof( point2d ) = " << sizeof( point2d ) << std::endl;
  std::cout << "sizeof( point3d ) = " << sizeof( point3d ) << std::endl; 

  return 0;
}

         class Point2d的虚表:

	.weak	_ZTV7Point2d
	.section	.rodata._ZTV7Point2d,"aG",@progbits,_ZTV7Point2d,comdat
	.align 8
	.type	_ZTV7Point2d, @object
	.size	_ZTV7Point2d, 24
_ZTV7Point2d:                         # vtable for Point2d
	.long	0
	.long	_ZTI7Point2d          # typeinfo for Point2d
	.long	_ZN7Point2dD1Ev       # Point2d::~Point2d()
	.long	_ZN7Point2dD0Ev       # Point2d::~Point2d()
	.long	_ZN7Point2d6mumbleEv  # Point2d::mumble()
	.long	_ZN7Point2d1zEv       # Point2d::z()

          class Point3d的虚表:

	.weak	_ZTV7Point3d
	.section	.rodata._ZTV7Point3d,"aG",@progbits,_ZTV7Point3d,comdat
	.align 32
	.type	_ZTV7Point3d, @object
	.size	_ZTV7Point3d, 60
_ZTV7Point3d:                            # vtable for Point3d
	.long	8
	.long	0
	.long	_ZTI7Point3d             # typeinfo for Point3d
	.long	_ZN7Point3dD1Ev          # Point3d::~Point3d()
	.long	_ZN7Point3dD0Ev          # Point3d::~Point3d()
	.long	_ZN7Point3d1zEv          # Point3d::z()
	.long	-8
	.long	0
	.long	-8
	.long	-8
	.long	_ZTI7Point3d             # typeinfo for Point3d
	.long	_ZTv0_n12_N7Point3dD1Ev  # virtual thunk to Point3d::~Point3d()
	.long	_ZTv0_n12_N7Point3dD0Ev  # virtual thunk to Point3d::~Point3d()
	.long	_ZN7Point2d6mumbleEv     # Point2d::mumble()
	.long	_ZTv0_n20_N7Point3d1zEv  # virtual thunk to Point3d::z() 

         虽然Point3d有唯一一个(同时也是最左边的)base class,也就是Point2d,单Point3d和

Point2d的起始部分并不像”非虚拟的单一继承“情况那样一致。由于Point2d和Point3d的对象不

再相符,两者之间的转换也就需要调整this指针。至于在虚拟继承的情况下要消除thunks,一般

而言已经被证明是一项该难度技术。

         建议是不要在一个virtual base class中声明nonstatic data members。要不然会越来越复

杂。

三、函数的效能

      在下面这组测试中,计算两个3D点,其中用到一个nonmember friend function,

一个member function,以及一个virtual member function,并且Virtual member

function分别在单一、虚拟、多重继承三种情况下执行。

        对于nonmember function:

        未优化:

          优化:

        对于inline member:

          未优化:

 

             优化:

          对于static Member:

             未优化:

               优化:

          对于nonstatic Member:

          未优化:

 

               优化:

            对于Virtual Member:

             未优化:

                优化:

            对于Virtual Member(多重继承):

             未优化:

         

                优化:

            对于Virtual Member(虚拟继承):

              未优化:

              优化:

        nonmember 、static member或nonstatic member函数都被转化为完全相同的形式。所以三

者效率完全相同。

         virtual member的效率相比前三项降低了4%到11%不等。

         多重继承中的virtual function的调用利用thunk技术用掉了较多成本。

         而虚拟继承花掉了最多的成本。

         下面使用两种方法优化:

         1)在函数参数中加上一个对象,用以存放加法的结果:

void Point3d::cross_product( Point3d &pC, const Point3d &pA, const Point3d &pB )
{

  pC.x = pA.y * pB.z - pA.z * pB.y;
  pC.y = pA.z * pB.x - pA.x * pB.z;
  pC.z = pA.x * pB.y - pA.y * pB.x;
}

                  

         可以看到在未优化情况下,效率优化了50%。

        2)直接在this对象中计算结果:

void Point3d::cross_product( const Point3d &pB )
{

  x = y * pB.z - z * pB.y;
  y = z * pB.x - x * pB.z;
  z = x * pB.y - y * pB.x;
}

四、指向Member Function的指针(Pointer-to-Member Functions)

     取一个nonstatic data member的地址,得到的结果是该member在class布局中的

bytes位置(再加1)。可以想象它是一个不完整的值,它需要被绑定于某个class

object的地址上,才能够被存取。

       取一个nonstatic member function的地址,如果该函数是nonvirtual,得到的结果是它在内存

中真正的地址。然而这个值也是不完全的。它也需要被绑定于某个class object的地址上,才能

够通过它调用该函数。所有的nonstatic member functions都需要对象的地址(以this指出)。

      一个指向member function的指针,其声明语法如下:

double     // return type
{ Point::* // class the function is member
  pmf }    // name of pointer to member
();        // argument list

       然后我们可以这样定义并初始化该指针:

double( Point::*coord )() = &Point::x;

       也可以这样指定其值:

coord = &Point::y;

       欲调用它,可以这么做:

( origin.*coord )();

       或

( ptr->*coord )();

       这些操作会被编译器转化为:

// 虚拟C++码
( coord )( &origin );

       和

// 虚拟C++码
( coord )( ptr );

        指向member function的指针的声明语法,以及指向”member selection运算符“的指针,其

作用是作为this指针的空间保存者。这也就是为什么static member functions(没有this指针)的

类型是”函数指针”,而不是“指向member function的指针”之故。

         使用一个“member function指针”,如果并不用于virtual function、多重继承、virtual base

class等情况的话,并不会比使用一个“nonmember function指针”的成本高。上述三种情况对于

“member function指针”的类型以及调用都太过复杂。事实上,对于那些没有virtual functions、

virtual base class或multiple base classes的classes而言,编译器可以为它们提供相同的效率。

        1、支持“指向Virtual Member Functions”的指针

         考虑下面的程序片段:

float ( Point::*pmf )() = &Point::z;
Point *ptr = new Point3d;

          pmf,一个指向member function的指针,被设置为Point::z()(一个virtual function)的地

址。ptr则被指定以一个Point3d对象。如果我们直接经由ptr调用z():

ptr->z();

          被调用的是Point3d::z()。但如果我们从pmf间接调用z()呢?

( ptr->*pmf )();

           仍然是Point3d::z()被调用吗,也就是说,虚拟机制仍然能够在使用“指向member

function之指针”的情况运行。

           对一个nonstatic member function取其地址,将获得该函数在内存中的地址。然而面对一

个virtual function,起地址在编译时期是未知的,所能知道的仅是virtual function在其相关之

virtual table中的索引值。也就是是说,对一个virtual member function取其地址,所能获得的只

是一个索引值。

            例如,假设我们有以下的Point声明:

class Point
{
  public:
    virtual ~Point();
    float x();
    float y();
    virtual float z();
    // ...
};

           然后取destructor的地址:

&Point::~Point;

            取x()或y()的地址:

&Point::x();
&Point::y();

             得到的则是函数在内存中的地址,因为它们不是virtual。取z()的地址:

&Point::z();

             得到的结果是2。通过pmf来调用z(),会被内部转化为一个编译时期的式子,一般形式如

下:

( *ptr->vptr[ ( int )pmf ] )( ptr );

              对一个“指向member function的指针”评估求值,会因为该值有两种意义而复杂化:其调

用操作也将有别于常规调用操作。pmf的内部定义,也就是:

float ( Point::*pmf )();

               必须允许此函数能够寻址出nonvirtual x()和virtual z()两个member functions,而那两个

函数有着相同的原型:

// 两者都可以被指定给pmf
float Point::x() { return _x; }
float Point::z() { return 0; }

               只不过其中一个代表内存地址,另一个代表virtual table中的索引值。因此,编译器必

须定义pmf。使它能够(1)持有两种数值,(2)更重要的是其数值可以被区别代表内存地址还

是Virtual table中的索引值。

                在cfront2.0非正式版中,这两个值被内含在一个普通的指针内。cfront如何识别该值是

内存地址还是virtual table索引呢?它使用了以下技巧:

( ( ( int )pmf ) & ~127 )
  ?                          // non-virtual invocation
  ( *pmf )( ptr )
  :                          // virtual invocation
  ( *ptr->vptr[ ( int )pmf ]( ptr ) );

        2、在多重继承之下,指向 Member Functions的指针

        为了让指向member functions的指针也能支持多重继承和虚拟继承,Stroustrup设计了下面

一个结构体:

// 一般结构,用以支持
// 在多重继承之下指向member functions的指针
struct _mptr
{
  int delta;
  int index;
  union
  {
    ptrtofunc faddr;
    int v_offset;
  };
};

         index和faddr分别(不同时)持有virtual table索引和nonvirtual member function地址(为

了方便,当index不指向virtual table时,会被设为-1)。在此模型之下,像这样的调用操作:
 

( ptr->*pmf )();

          会变成:

( pmf.index < 0 )
  ?                  // non-virtual invocation
  ( *pmf.faddr )( ptr )
  :                  // virtual invocation
  ( *ptr->vptr[ pmf.index ]( ptr ) );

           此法所受到的批评是,每一个调用操作都得付出上述成本,检查其是否为virtual或

nonvirtual。Microsoft把这项检查拿掉,导入一个它所谓的vcall thunk。在此策略执之下,faddr

被指定的要不就是真正的member function地址(如果函数是nonvirtual的话),要不就是vcall

thunk的地址。于是virtual或nonvirtual函数的调用操作透明化,vcall thunk会选出并调用相关

virtual table中的适当slot。

            这个结构体的另一个副作用就是,当传递一个不变值的指针给member function时,它需

要产生一个临时性对象。如下:

extern Point3d foo( const Point3d&, Point3d ( Point3d::* )() );
void bar( const Point3d& p )
{
  Point3d pt = foo( p, &Point3d::normal );
  // ...
}

           其中&Point3d::normal的值类似这样:

{ 0, -1, 10727417 }

           将需要产生一个临时性对象,有明确的初值:

// 虚拟C++码
_mpter temp = { 0, -1, 10727417 }

foo( p, temp );

             delta字段表示this指针的offset值,而v_offset字段放的是一个virtual(或多重继承中的第

二或后继的)base class的vptr位置。如果ptr被编译器放在class对象的起头处,这个字段就没

有必要了,代价则是C对象兼容性降低。这些字段只在多重继承或虚拟继承的情况下才有其必要

性,有许多编译器在自身内部根据不同的classes特性提供多种指向member functions的指针形

式,例如Microsoft就提供了三种风味:

            1)一个单一继承实例(其中持有vcall thunk地址或是函数地址)

             2)一个多重继承实例(其中持有faddr和delta两个members)

             3)一个虚拟继承实例(其中持有4个members)

        3、“指向 Member Functions之指针”的效率

         下面一组测试中,cross_product()函数经由以下方式调用:

         1)一个指向nonmember function的指针;

         2)一个指向class member function的指针;

         3)一个指向virtual member function的指针;

         4)多重继承下的nonvirtual及virtual member function call;

         5)虚拟继承下的nonvirtual及virtual member function call;

五、Inline Functions

       下面是一个加法运算符的可能实现内容:

class Point
{
  friend Point operator+( const Point&, const Point& );
}

Point operator+( const Point &lhs, const Point &rhs )
{
  Point new_pt;
  
  new_pt._x = lhs._x + rhs._x;
  new_pt._y = lhs._y + rhs._y;

  return new_pt;
}

       理论上,一个比较“干净”的做法是使用inline函数来完成set和get函数:

// void Point::x( float new_ ) { _x = new_x; }
// float Point::x() { return _x; }

new_pt.x( lhs.x() + rhs.x() );

        由于我们受限只能在上述两个函数中对_x直接存取,因此也就将稍后可能发生的data

members的改变所带来的冲击最小化了。如果把这些存取函数声明为inline,我们就可以继续保

持直接存取members的那种高效率——虽然我们亦兼顾了函数的封装性。此外,加法运算符不

再需要被声明为Point的一个friend。

        然而,实际上我们并不能够强迫将任何函数都变为inline。关键词inline只是一项请求。如

果这项请求被接受,编译器就必须认为它可以用一个表达式(expression)合理地将这个函数

扩展开来。

        一般而言,处理一个inline函数,有两个阶段:

       1)分析函数定义,以决定函数的“intrinsic inline ability”(本质的inline能力)。“instrinsic”

一词在这里指“与编译器相关”。

             如果函数因其复杂度,或因其建构问题,被判断不可成为inline,它会被转为一个static

函数,并在编译模块内产生对应的函数定义。

       2)真正的inline函数扩展操作是在调用的那一点上。这会带来参数的求值操作以及临时对象

的管理。

       1、形式参数(Formal Arguments)

       在inline扩展期间,每一个形式参数都会被对应的实际参数取代。一般而言,面对“会带来副

作用的实际参数”,通常都需要引入临时性对象。换句话说,如果实际参数是一个常量表达式

(constant expression),我们可以在替换之前先完成其求值操作;后继的inline替换,就可以把

常量直接“绑”上去。如果既不是常量表达式,也不是个带有副作用的表达式,那么就直接带换

之。

       假设有以下的简单inline函数:

inline int 
min( int i, int j )
{
  return i < j ? i : j;
}

      下面是三个调用操作:

inline int
bar()
{
  int minval;
  int val1 = 1024;
  int val2 = 2048;

/* (1) */ minval = min( val1, val2 );
/* (2) */ minval = min( 1024, 2048 );
/* (3) */ minval = min( foo(), bar()+1 );

  return minval;
}

        标示为(1)的那一行会被扩展为:

// (1)参数直接替换
minval = val1 < val2 ? val1 : val2;

       标示为(2)的那一行直接拥抱常量:

// (2) 代换之后,直接拥抱常量
minval = 1024;

       表示为(3)的那一行则引发参数的副作用。它需要导入一个临时性对象,以避免重复求

值:

// (3) 有副作用,所以导入临时性对象
int t1;
int t2;

minval = ( t1 = foo() ), ( t2 = bar() + 1 ),
           t1 < t2 ? t1 : t2;

       2、局部变量(Local Variables)

       如果我们轻微地改变定义,在inline定义中加入一个局部变量,会怎样?

inline int 
min( int i, int j )
{
  int minval = i < j ? i : j;
  return minval;
}

        这个局部变量需要什么额外的支持或处理吗?如果我们有以下的调用操作:

{
  int local_var;
  int minval;

  // ...
  minval = min( val1, val2 );
}

        inline被扩展来后,为了维护其局部变量,可能会成为这个样子

{
  int local_var;
  int minval;

  // 将inline函数的局部变量处以“mangling”操作
  int _min_lv_minval;
  minval = 
    ( _min_lv_minval = 
      val1 < val2 ? val1 : val2 ),
      _min_lv_minval;
}

         一般而言,inline函数中的每一个局部变量都必须被放在函数调用的一个封闭区段中,拥有

一个独一无二的名称。如果inline函数以单一表达式扩展多次,则每次扩展都需要自己的一组局

部变量。如果inline函数以分离的多个式子(duscrete statements)被扩展多次,那么只需要一

组局部变量,就可以重复使用。

         inline函数中的局部变量,再加上有副作用的参数,可能会导致大量临时性对象的产生。特

别是如果它以单一表达式被扩展多次的话。例如:

minval = min( val1, val2 ) + min( foo(), foo() + 1 );

         可能被扩展为:

// 为局部变量产生临时变量
int _min_lv_minval_00;
int _min_lv_minval_01;

// 为放置副作用值而产生临时变量
int t1;
int t2;

minval =
  ( ( _min_lv_minval_00 = 
      val1 < val2 ? val1 : val2 ),
      _min_lv_minval_00 )
  +
  ( ( _min_lv_minval_01 = ( t1 = foo() ),
      ( t2 = foo() + 1 ),
      t1 < t2 ? t1 : t2 ),
      _min_lv_minval_01 );

        Inline函数对于封装提供了一种必要的支持,可以有效存取封装于class中的nonpublic数

据。它同时也是C程序中大量使用的#define(前置处理宏)的一个安全代替品——特别是如果

宏中的参数有副作用的话。然而一个inline函数如果被调用太多次的话,会产生大量的扩展码,

使程序大小暴涨。

       对于既要安全又要效率的程序码,inline函数提供了一个强而有力的工具。然而,与non-

inline函数比起来,它们需要更加小心地处理。
           

转载于:https://my.oschina.net/u/2537915/blog/708957

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值