《C++Primer》第十五章-面向对象编程-学习笔记(2)

《C++Primer》第十五章-面向对象编程-学习笔记(2)

日志:
1,2020-03-09 笔者提交文章的初版V1.0

作者按:
最近在学习C++ primer,初步打算把所学的记录下来。

传送门/推广
《C++Primer》第二章-变量和基本类型-学习笔记(1)
《C++Primer》第三章-标准库类型-学习笔记(1)
《C++Primer》第八章-标准 IO 库-学习笔记(1)
《C++Primer》第十二章-类-学习笔记(1)

构造函数和复制控制

每个派生类对象由派生类中定义的(非 static)成员加上一个或多个基类子对象构成,当我们构造、复制、赋值和撤销一个派生类对象时,也会构造、复制、赋值和撤销这些基类子对象
构造函数复制控制成员不能继承,每个类定义自己的构造函数和复制控制成员。像任何类一样,如果类不定义自己的默认构造函数和复制控制成员,就将使用合成版本。

基类构造函数和复制控制

本身不是派生类的基类,其构造函数和复制控制基本上不受继承影响。构造函数看起来像已经见过的许多构造函数一样:

Item_base(const std::string &book = "",double sales_price = 0.0):
isbn(book), price(sales_price) { }

继承对基类构造函数的唯一影响是,在确定提供哪些构造函数时,必须考虑一类新用户。像任意其他成员一样,构造函数可以为 protected 或 private,某些类需要只希望派生类使用的特殊构造函数,这样的构造函数应定义为protected。

派生类构造函数

派生类的构造函数受继承关系的影响,每个派生类构造函数除了初始化自己的数据成员之外,还要初始化基类。

合成的派生类默认构造函数

派生类的合成默认构造函数非派生的构造函数只有一点不同:除了初始化派生类的数据成员之外,它还初始化派生类对象的基类部分。基类部分由基类的默认构造函数初始化
对于 Bulk_item 类,合成的默认构造函数会这样执行:

  1. 调用 Item_base 的默认构造函数,将 isbn 成员初始化空串,将 price成员初始化为 0。
  2. 用常规变量初始化规则初始化 Bulk_item 的成员,也就是说,qty 和discount 成员会是未初始化的。

定义默认构造函数

因为 Bulk_item 具有内置类型成员,所以应定义自己的默认构造函数:

class Bulk_item : public Item_base {
public:
Bulk_item(): min_qty(0), discount(0.0) { }
// as before
};

这个构造函数使用构造函数初始化列表初始化 min_qty 和discount 成员,该构造函数还隐式调用 Item_base 的默认构造函数初始化对象的基类部分。
运行这个派生类构造函数的效果是,首先使用 Item_base 的默认构造函数初始化Item_base 部分,那个构造函数将 isbn 置为空串并将 price 置为 0。
Item_base 的构造函数执行完毕后,再初始化 Bulk_item 部分的成员并执行构造函数的函数体(函数体为空)。

向基类构造函数传递实参

除了默认构造函数之外,Item_base 类还使用户能够初始化 isbn 和 price成员,我们希望支持同样 Bulk_item 对象的初始化,事实上,我们希望用户能够指定整个 Bulk_item 的值,包括折扣率和数量。
派生类构造函数的初始化列表只能初始化派生类的成员,不能直接初始化继承成员。相反派生类构造函数通过将基类包含在构造函数初始化列表中来间接初始化继承成员。

class Bulk_item : public Item_base {
public:
Bulk_item(const std::string& book, double sales_price,std::size_t qty = 0, double disc_rate = 0.0):Item_base(book, sales_price), min_qty(qty), discount(disc_rate) { }
// as before
};

这个构造函数使用有两个形参的 Item_base 构造函数初始化基类子对象,它将自己的 book 和 sales_price 实参传递给该构造函数。这个构造函数可以这样使用:

// arguments are the isbn, price, minimum quantity, and discount
Bulk_item bulk("0-201-82470-1", 50, 5, .19);

要建立 bulk,首先运行 Item_base 构造函数,该构造函数使用从Bulk_item 构造函数初始化列表传来的实参初始化 isbn 和 price。Item_base构造函数执行完毕之后,再初始化 Bulk_item 的成员。最后,运行 Bulk_item 构造函数的(空)函数体。
构造函数初始化列表为类的基类和成员提供初始值,它并不指定初始化的执行次序。首先初始化基类,然后根据声明次序初始化派生类的成员。

在派生类构造函数中使用默认实参

当然,也可以将这两个 Bulk_item 构造函数编写为一个接受默认实参的构造函数:

class Bulk_item : public Item_base {
public:
Bulk_item(const std::string& book, double sales_price,
std::size_t qty = 0, double disc_rate = 0.0):Item_base(book, sales_price),
min_qty(qty), discount(disc_rate) { }
// as before
};

这里为每个形参提供了默认值,因此,可以用 0 至 4 个实参使用该构造函数。

只能初始化直接基类

一个类只能初始化自己的直接基类。直接就是在派生列表中指定的类。如果类 C 从类 B 派生,类 B 从类 A 派生,则 B 是 C 的直接基类。虽然每个 C 类对象包含一个 A 类部分,但 C 的构造函数不能直接初始化 A 部分。相反,需要类 C 初始化类 B,而类 B 的构造函数再初始化类 A。这一限制的原因是,类B 的作者已经指定了怎样构造和初始化 B 类型的对象。像类 B 的任何用户一样,类 C 的作者无权改变这个规约。
作为更具体的例子,书店可以有几种折扣策略。除了批量折扣外,还可以为购买某个数量打折,此后按全价销售,或者,购买量超过一定限度的可以打折,在该限度之内不打折。
这些折扣策略都需要一个数量和一个折扣量,可以定义名为 Disc_item 的新类存储数量和折扣量,以支持这些不同的折扣策略。Disc_item 类可以不定义net_price 函数,但可以作为定义不同折扣策略的其他类(如 Bulk_item 类)的基类。要实现这个设计,首先需要定义 Disc_item 类:

// class to hold discount rate and quantity
// derived classes will implement pricing strategies using these data
class Disc_item : public Item_base {
public:
Disc_item(const std::string& book = "",
double sales_price = 0.0,
std::size_t qty = 0, double disc_rate = 0.0):
Item_base(book, sales_price),
quantity(qty), discount(disc_rate) { }
protected:
std::size_t quantity; // purchase size for discount to apply
double discount; // fractional discount to apply
};

这个类继承 Item_base 类并定义了自己的 discount 和 quantity 成员。它唯一的成员函数是构造函数,用以初始化基类和 Disc_item 定义的成员。其次,可以重新实现 Bulk_item 以继承 Disc_item,而不再直接继承Item_base:

// discount kicks in when a specified number of copies of same book are sold
// the discount is expressed as a fraction to use to reduce the normal price
class Bulk_item : public Disc_item {
public:
	Bulk_item(const std::string& book = "",
	double sales_price = 0.0,
	std::size_t qty = 0, double disc_rate = 0.0):
	Disc_item(book, sales_price, qty, disc_rate) { }
// redefines base version so as to implement bulk purchase discount policy
	double net_price(std::size_t) const;
};

Bulk_item 类现在有一个直接基类 Disc_item,还有一个间接基类Item_base。每个 Bulk_item 对象有三个子对象:一个(空的)Bulk_item 部分和一个 Disc_item 子对象,Disc_item 子对象又有一个 Item_base 基类子对象。
虽然 Bulk_item 没有自己的数据成员,但为获取值用来初始化其继承成员,它定义了一个构造函数。
派生类构造函数只能初始化自己的直接基类,在 Bulk_item 类的构造函数初始化列表中指定Item_base 是一个错误。

复制控制和继承

像任意其他类一样,派生类也可以使用第十三章所介绍的合成复制控制成员。合成操作对对象的基类部分连同派生部分的成员一起进行复制、赋值或撤销,使用基类的复制构造函数、赋值操作符或析构函数对基类部分进行复制、赋值或撤销。
类是否需要定义复制控制成员完全取决于类自身的直接成员。基类可以定义自己的复制控制而派生类使用合成版本,反之亦然。
只包含类类型或内置类型数据成员、不含指针的类一般可以使用合成操作,复制、赋值或撤销这样的成员不需要特殊控制。具有指针成员的类一般需要定义自己的复制控制来管理这些成员
Item_base 类及其派生类可以使用复制控制操作的合成版本。复制Bulk_item 对象时,调用(合成的)Item_base 复制构造函数复制 isbn 和 price成员。使用 string 复制构造函数复制 isbn,直接复制 price 成员。一旦复制了基类部分,就复制派生部分。Bulk_item 的两个成员都是 double 型,直接复
制这些成员。赋值操作符和析构函数类似处理。

定义派生类复制构造函数

如果派生类显式定义自己的复制构造函数或赋值操作符,则该定义将完全覆盖默认定义。被继承类的复制构造函数和赋值操作符负责对基类成分以及类自己的成员进行复制或赋值。
如果派生类定义了自己的复制构造函数,该复制构造函数一般应显式使用基类复制构造函数初始化对象的基类部分:

class Base { /* ... */ };
class Derived: public Base {
public:
// Base::Base(const Base&) not invoked automatically
	Derived(const Derived& d):
	Base(d) /* other member initialization */ { /*... */ }
};

初始化函数 Base(d) 将派生类对象 d 转换为它的基类部分的引用,并调用基类复制构造函数。如果省略基类初始化函数,如下代码:

// probably incorrect definition of the Derived copy constructor
Derived(const Derived& d) /* derived member initizations */
{/* ... */ }

效果是运行 Base 的默认构造函数初始化对象的基类部分。假定 Derived成员的初始化从 d 复制对应成员,则新构造的对象将具有奇怪的配置:它的Base 部分将保存默认值,而它的 Derived 成员是另一对象的副本。

派生类赋值操作符

赋值操作符通常与复制构造函数类似:如果派生类定义了自己的赋值操作符,则该操作符必须对基类部分进行显式赋值。

// Base::operator=(const Base&) not invoked automatically
Derived &Derived::operator=(const Derived &rhs)
{
	if (this != &rhs) { //赋值操作符必须防止自身赋值。假定左右操作数不同
	Base::operator=(rhs); // assigns the base part
// do whatever needed to clean up the old value in the derived part
// assign the members from the derived
	}
	return *this;
}

赋值操作符必须防止自身赋值。假定左右操作数不同,则调用 Base 类的赋值操作符给基类部分赋值。该操作符可以由类定义,也可以是合成赋值操作符,这没什么关系——我们可以直接调用它。基类操作符将释放左操作数中基类部分的值,并赋以来自 rhs 的新值。该操作符执行完毕后,接着要做的是为派生类中的成员赋值。

派生类析构函数

析构函数的工作与复制构造函数和赋值操作符不同:派生类析构函数不负责撤销基类对象的成员。编译器总是显式调用派生类对象基类部分的析构函数。每个析构函数只负责清除自己的成员:

class Derived: public Base {
public:
// Base::~Base invoked automatically
~Derived() { /* do what it takes to clean up derived members
*/ }
};

对象的撤销顺序与构造顺序相反:首先运行派生析构函数,然后按继承层次依次向上调用各基类析构函数。

虚析构函数

自动调用基类部分的析构函数对基类的设计有重要影响。
删除指向动态分配对象的指针时,需要运行析构函数在释放对象的内存之前清除对象。处理继承层次中的对象时,指针的静态类型可能与被删除对象的动态类型不同,可能会删除实际指向派生类对象的基类类型指针。
如果删除基类指针,则需要运行基类析构函数并清除基类的成员,如果对象实际是派生类型的,则没有定义该行为要保证运行适当的析构函数,基类中的析构函数必须为虚函数

class Item_base {
public:
// no work, but virtual destructor needed
// if base pointer that points to a derived object is ever deleted
virtual ~Item_base() { } //虚析构函数
};

如果析构函数为虚函数,那么通过指针调用时,运行哪个析构函数将因指针所指对象类型的不同而不同

Item_base *itemP = new Item_base; // same static and dynamic type
delete itemP; // ok: destructor for Item_base called
itemP = new Bulk_item; // ok: static and dynamic types differ
delete itemP; // ok: destructor for Bulk_item called

像其他虚函数一样,析构函数的虚函数性质都将继承。因此,如果层次中根类的析构函数为虚函数,则派生类析构函数也将是虚函数,无论派生类显式定义析构函数还是使用合成析构函数,派生类析构函数都是虚函数。
基类析构函数是三法则(第 13.3 节)的一个重要例外。三法则指出,如果类需要析构函数,则类几乎也确实需要其他复制控制成员。基类几乎总是需要构造函数,从而可以将析构函数设为虚函数。如果基类为了将析构函数设为虚函数则具有空析构函数,那么,类具有析构函数并不表示也需要赋值操作符或复制构造函数。
即使析构函数没有工作要做,继承层次的根类也应该定义一个虚析构函数。

构造函数和赋值操作符不是虚函数

复制控制成员中,只有析构函数应定义为虚函数,构造函数不能定义为虚函数。构造函数是在对象完全构造之前运行的,在构造函数运行的时候,对象的动态类型还不完整。
虽然可以在基类中将成员函数 operator= 定义为虚函数,但这样做并不影响派生类中使用的赋值操作符。每个类有自己的赋值操作符,派生类中的赋值操作符有一个与类本身类型相同的形参,该类型必须不同于继承层次中任意其他类的赋值操作符的形参类型。
将赋值操作符设为虚函数可能会令人混淆,因为虚函数必须在基类和派生类中具有同样的形参。基类赋值操作符有一个形参是自身类类型的引用,如果该操作符为虚函数,则每个类都将得到一个虚函数成员,该成员定义了参数为一个基类对象的 operator=。但是,对派生类而言,这个操作符与赋值操作符是不同的。将类的赋值操作符设为虚函数很可能会令人混淆,而且不会有什么用处。

构造函数和析构函数中的虚函数

构造派生类对象时首先运行基类构造函数初始化对象的基类部分。在执行基类构造函数时,对象的派生类部分是未初始化的。实际上,此时对象还不是一个派生类对象。
撤销派生类对象时,首先撤销它的派生类部分,然后按照与构造顺序的逆序撤销它的基类部分。
在这两种情况下,运行构造函数或析构函数的时候,对象都是不完整的。为了适应这种不完整,编译器将对象的类型视为在构造或析构期间发生了变化。在基类构造函数或析构函数中,将派生类对象当作基类类型对象对待。
构造或析构期间的对象类型对虚函数的绑定有影响。如果在构造函数或析构函数中调用虚函数,则运行的是为构造函数或析构函数自身类型定义的版本
无论由构造函数(或析构函数)直接调用虚函数,或者从构造函数(或析构函数)所调用的函数间接调用虚函数,都应用这种绑定。
要理解这种行为,考虑如果从基类构造函数(或析构函数)调用虚函数的派生类版本会怎么样。虚函数的派生类版本很可能会访问派生类对象的成员,毕竟,如果派生类版本不需要使用派生类对象的成员,派生类多半能够使用基类中的定义。但是,对象的派生部分的成员不会在基类构造函数运行期间初始化,实际上,如果允许这样的访问,程序很可能会崩溃。

继承情况下的类作用域

每个类都保持着自己的作用域,在该作用域中定义了成员的名字。在继承情况下,派生类的作用域嵌套在基类作用域中。如果不能在派生类作用域中确定名字,就在外围基类作用域中查找该名字的定义。
正是这种类作用域的层次嵌套使我们能够直接访问基类的成员,就好象这些成员是派生类成员一样。如果编写如下代码:

Bulk_item bulk;
cout << bulk.book();

名字 book 的使用将这样确定:

  1. bulk 是 Bulk_item 类对象,在 Bulk_item 类中查找,找不到名字 book。
  2. 因为从 Item_base 派生 Bulk_item,所以接着在 Item_base 类中查找,找到名字 book,引用成功地确定了。

名字查找在编译时发生

对象、引用或指针的静态类型决定了对象能够完成的行为。甚至当静态类型和动态类型可能不同的时候,就像使用基类类型的引用或指针时可能会发生的,静态类型仍然决定着可以使用什么成员。例如,可以给 Disc_item 类增加一个成员,该成员返回一个保存最小(或最大)数量和折扣价格的 pair 对象:

class Disc_item : public Item_base {
public:
	std::pair<size_t, double> discount_policy() const
	{ return std::make_pair(quantity, discount); }
// other members as before
};

只能通过 Disc_item 类型或 Disc_item 派生类型的对象、指针或引用访问discount_policy:

Bulk_item bulk;
Bulk_item *bulkP = &bulk; // ok: static and dynamic types are the same
Item_base *itemP = &bulk; // ok: static and dynamic types differ
bulkP->discount_policy(); // ok: bulkP has type Bulk_item*
itemP->discount_policy(); // error: itemP has type Item_base*
//基类类型的指针(引用或对象)只能访问对象的基类部分,而在基类中没有定义 discount_policy 成员

重新定义 itemP 的访问是错误的,因为基类类型的指针(引用或对象)只能访问对象的基类部分,而在基类中没有定义 discount_policy 成员。

名字冲突与继承

虽然可以直接访问基类成员,就像它是派生类成员一样,但是成员保留了它的基类成员资格。一般我们并不关心是哪个实际类包含成员,通常只在基类和派生类共享同一名字时才需要注意。
与基类成员同名的派生类成员将屏蔽对基类成员的直接访问

struct Base {
	Base(): mem(0) { }
protected:
	int mem;
};
struct Derived : Base {
	Derived(int i): mem(i) { } // initializes Derived::mem
	int get_mem() { return mem; } // returns Derived::mem
protected:
	int mem; // hides mem in the base
};

get_mem 中对 mem 的引用被确定为使用 Derived 中的名字。如果编写如下代码:

Derived d(42);
cout << d.get_mem() << endl; // prints 42

则输出将是 42。

使用作用域操作符访问被屏蔽成员

可以使用作用域操作符访问被屏蔽的基类成员:

struct Derived : Base {
int get_base_mem() { return Base::mem; }
};

作用域操作符指示编译器在 Base 中查找 mem。
设计派生类时,只要可能,最好避免与基类成员的名字冲突。

作用域与成员函数

在基类和派生类中使用同一名字的成员函数,其行为与数据成员一样:在派生类作用域中派生类成员将屏蔽基类成员。即使函数原型不同,基类成员也会被屏蔽

struct Base {
	int memfcn();
};
struct Derived : Base {
	int memfcn(int); // hides memfcn in the base
};
Derived d; Base b;
b.memfcn(); // calls Base::memfcn
d.memfcn(10); // calls Derived::memfcn
d.memfcn(); // error: memfcn with no arguments is hidden
d.Base::memfcn(); // ok: calls Base::memfcn

Derived 中的 memfcn 声明隐藏了 Base 中的声明。这并不奇怪,第一个调用通过 Base 对象 b 调用基类中的版本,同样,第二个调用通过 d 调用Derived 中的版本。可能比较奇怪的是第三个调用:

d.memfcn(); // error: Derived has no memfcn that takes no arguments

要确定这个调用,编译器需要查找名字 memfcn,并在 Derived 类中找到。一旦找到了名字,编译器就不再继续查找了。这个调用与 Derived 中的 memfcn定义不匹配,该定义希望接受 int 实参,而这个函数调用没有提供那样的实参,因此出错。
回忆一下,局部作用域中声明的函数不会重载全局作用域中定义的函数(第 7章),同样,派生类中定义的函数也不重载基类中定义的成员。通过派生类对象调用函数时,实参必须与派生类中定义的版本相匹配,只有在派生类根本没有定义该函数时,才考虑基类函数。

重载函数

像其他任意函数一样,成员函数(无论虚还是非虚)也可以重载派生类可以重定义所继承的 0 个或多个版本。

如果派生类重定义了重载成员,则通过派生类型只能访问派生类中重定义的那些成员。

如果派生类想通过自身类型使用的重载版本,则派生类必须要么重定义所有重载版本,要么一个也不重定义。
有时类需要仅仅重定义一个重载集中某些版本的行为,并且想要继承其他版本的含义,在这种情况下,为了重定义需要特化的某个版本而不得不重定义每一个基类版本,可能会令人厌烦。
派生类不用重定义所继承的每一个基类版本,它可以为重载成员提供using声明。一个 using 声明只能指定一个名字,不能指定形参表,因此,为基类成员函数名称而作的 using 声明将该函数的所有重载实例加到派生类的作用域。将所有名字加入作用域之后,派生类只需要重定义本类型确实必须定义的那些函数,对其他版本可以使用继承的定义。

虚函数与作用域

还记得吗,要获得动态绑定,必须通过基类的引用或指针调用虚成员。当我们这样做时,编译器器将在基类中查找函数。假定找到了名字,编译器就检查实参是否与形参匹配。
现在可以理解虚函数为什么必须在基类和派生类中拥有同一原型了。如果基类成员与派生类成员接受的实参不同,就没有办法通过基类类型的引用或指针调用派生类函数。考虑如下(人为的)为集合:

class Base {
public:
	virtual int fcn();
};
class D1 : public Base {
public:
// hides fcn in the base; this fcn is not virtual
	int fcn(int); // parameter list differs from fcn in Base
// D1 inherits definition of Base::fcn()
};
class D2 : public D1 {
public:
	int fcn(int); // nonvirtual function hides D1::fcn(int)
	int fcn(); // redefines virtual fcn from Base
};

D1 中的 fcn 版本没有重定义 Base 的虚函数 fcn,相反,它屏蔽了基类的fcn。结果 D1 有两个名为 fcn 的函数:类从 Base 继承了一个名为 fcn 的虚函数,类又定义了自己的名为 fcn 的非虚成员函数,该函数接受一个 int 形参。
但是,从 Base 继承的虚函数不能通过 D1 对象(或 D1 的引用或指针)调用,因为该函数被 fcn(int) 的定义屏蔽了。
类 D2 重定义了它继承的两个函数,它重定义了 Base 中定义的 fcn 的原始版本并重定义了 D1 中定义的非虚版本。

通过基类调用被屏蔽的虚函数

通过基类类型的引用或指针调用函数时,编译器将在基类中查找该函数而忽略派生类

Base bobj; D1 d1obj; D2 d2obj;
Base *bp1 = &bobj, *bp2 = &d1obj, *bp3 = &d2obj;
bp1->fcn(); // ok: virtual call, will call Base::fcnat run time
bp2->fcn(); // ok: virtual call, will call Base::fcnat run time
bp3->fcn(); // ok: virtual call, will call D2::fcnat run time

三个指针都是基类类型的指针,因此通过在 Base 中查找 fcn 来确定这三个调用,所以这些调用是合法的。另外,因为 fcn 是虚函数,所以编译器会生成代码,在运行时基于引用指针所绑定的对象的实际类型进行调用。在 bp2 的情况,基本对象是 D1 类的,D1 类没有重定义不接受实参的虚函数版本,通过bp2 的函数调用(在运行时)调用 Base 中定义的版本。

重构

将 Disc_item 加到 Item_base 层次是重构(refactoring)的一个例子。重构包括重新定义类层次,将操作和数据从一个类移到另一个类。
为了适应应用程序的需要而重新设计类以便增加新函数或处理其他改变时,最有可能需要进行重构。
重构常见在面向对象应用程序中非常常见。值得注意的是,虽然改变了继承层次,使用 Bulk_item 类或 Item_base 类的代码不需要改变。然而,对类进行重构,或以任意其他方式改变类,使用这些类的任意代码都必须重新编译。

尊重基类接口

构造函数只能初始化其直接基类的原因是每个类都定义了自己的接口。定义 Disc_item 时,通过定义它的构造函数指定了怎样初始化Disc_item 对象。一旦类定义了自己的接口,与该类对象的所有交互都应该通过该接口,即使对象是派生类对象的一部分也不例外。同样,派生类构造函数不能初始化基类的成员且不应该对基类成员赋值。
如果那些成员为 public 或 protected,派生构造函数可以在构造函数函数体中给基类成员赋值,但是,这样做会违反基类的接口。派生类应通过使用基类构造函数尊重基类的初始化意图,而不是在派生类构造函数函数体中对这些成员赋值。

名字查找与继承

理解 C++ 中继承层次的关键在于理解如何确定函数调用确定函数调用遵循以下四个步骤

  1. 首先确定进行函数调用的对象、引用或指针的静态类型。
  2. 在该类中查找函数,如果找不到,就在直接基类中查找,如此循着类的继承链往上找,直到找到该函数或者查找完最后一个类。如果不能在类或其相关基类中找到该名字,则调用是错误的。
  3. 一旦找到了该名字,就进行常规类型检查(第 7章),查看如果给定找到的定义,该函数调用是否合法。
  4. 假定函数调用合法,编译器就生成代码。如果函数是虚函数且通过引用或指针调用,则编译器生成代码以确定根据对象的动态类型运行哪个函数版本,否则,编译器生成代码直接调用函数。

参考资料

【1】C++ Primer 中文版(第四版·特别版)

注解

发布了52 篇原创文章 · 获赞 72 · 访问量 4万+

猜你喜欢

转载自blog.csdn.net/engineerxin/article/details/104742939