0%

Effective Modern C++ 笔记 Chapter4 智能指针 (Item 18-22)

我们不爱裸指针的原因:

  1. 裸指针的声明没办法告诉我们它指向的是单个对象还是数组。
  2. 没办法知道用完这个裸指针后要不要销毁它指向的对象。
  3. 没办法知道怎么销毁这个裸指针,是用operator delete还是什么其它自定义的途径。
  4. 参照原因1,没办法知道该用delete还是delete[],如果用错了,结果未定义。
  5. 很难保证调用路径上恰好销毁这个指针一次,可能内存泄露,也可能double free。
  6. 通常没办法知道裸指针是否是空悬指针,即是否指向已销毁的对象。

智能指针就是来解这些问题的,它们用起来像裸指针,但能避免以上的很多陷阱。C++11中有4种智能指针:std::auto_ptrstd::unique_ptrstd::shared_ptrstd::weak_ptr。其中std::auto_ptr已经过时了,C++11中可以被std::unique_ptr取代了。

Item18: 需要显式所有权的资源管理时,用std::unique_ptr

首先要知道:默认情况下,std::unique_ptr与裸指针一样大,且对于绝大多数操作来说(包括解引用),它们编译后的指令都是完全一样的(参见为什么unique_ptr的Deleter是模板类型参数,而shared_ptr的Deleter不是),所有裸指针的空间和性能开销能满足要求的场景,std::unique_ptr一样能满足。

std::unique_ptr体现了显式所有权的语义:非空的std::unique_ptr总是拥有它指向的对象;移动一个std::unique_ptr会将源指针持有的所有权移交给目标指针;不允许复制std::unique_ptr;非空的std::unique_ptr总是销毁它持有的资源,默认是通过delete

一个例子是工厂函数。假设有一个基类和三个派生类,通过一个工厂函数来返回某个派生类的std::unique_ptr,这样调用方就不需要费心什么时候销毁返回的对象了:std::unique_ptr会负责这件事。

1
2
3
4
5
6
7
8
9
class Investment {...};
class Stock: public Investment {...};
class Bond: public Investment {...};
class RealEstate: public Investment {...};

template <typename... Ts>
std::unique_ptr<Investment> makeInvestment(Ts&&... params);

auto pInvestment = makeInvestment(args);

注意这里实际上有个所有权的转移:工厂函数通过std::unique_ptrInvestment对象的所有权转移给了调用者。

在构造std::unique_ptr时,我们还可以传入一个自定义的销毁器,它会在std::unique_ptr析构时被调用,来销毁对应的资源。比如我们可能不想只是delete obj,还想输出一条日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
auto delInvmt = [](Investment* pInvestment) {
makeLogEntry(pInvestment);
delete pInvestment;
};
template <typename... Ts>
std::unique_ptr<Investment, decltype(delInvmt)> makeInvestment(Ts&&... params) {
std::unique_ptr<Investment, decltype(delInvmt)> pInv(nullptr, delInvmt);
if (...) {
pInv.reset(new Stock(std::forward<Ts>(params)...));
}
...
return pInv;
}

从调用者的角度,你可以放心的处理std::unique_ptr,你可以相信在调用过程中资源只会销毁一次,且按恰当的方式销毁。理解以下几点能帮助你理解这种实现有多漂亮:

  • delInvmt是自定义的销毁器,在std::unique_ptr析构时,自定义的销毁器会来完成释放资源必需的操作。这里用lambda表达式来实现delInvmt,不仅更方便,性能还更好。
  • 自定义的销毁器的类型必须与std::unique_ptr的第二个模板参数相同,因此我们要用decltype(delInvmt)来声明std::unique_ptr
  • makeInvestment的基本策略是创建一个空的std::unique_ptr,再令它指向合适的类型,再返回。其中我们把delInvmt作为第二个构造参数传给std::unique_ptr,从而将销毁器与pInv关联起来。
  • 无法将裸指针隐式转换为std::unique_ptr,需要用reset来修改std::unique_ptr持有的裸指针。
  • 我们在创建具体的对象时,使用了std::forwardmakeInvestment的所有参数完美转发给对应的构造函数。
  • 注意delInvmt的参数是Investment*,而它的实际类型可能是派生类,因此需要基类Investment有一个虚的析构函数:
1
2
3
4
5
6
class Investment {
public:
...
virtual ~Investment();
...
};

C++14中我们可以做两件事来让makeInvestment更简单,封装更好:

  1. 返回类型可以为auto(参见Item3)。
  2. delInvmt的定义可以放到makeInvestment函数体中。

前文我们说过在不提供自定义的销毁器时,std::unique_ptr的大小与裸指针相同。但在有了自定义的销毁器后,这个假设不成立了。销毁器的大小取决于它内部保存了多少状态。对于无状态的函数对象(例如捕获列表为空的lambda表达式),销毁器实际不占用任何空间,这就意味着当你需要一个无状态的销毁器时,在lambda表达式和函数间做选择,lambda表达式更好:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
auto delInvmt1 = [](Investment* pInvestment) {
...
};

template <typename... Ts>
std::unique_ptr<Investment, decltype(delInvmt1)> makeInvestment(Ts&&... args); // return type has size of Investment*

void delInvmt2(Investment* pInvestment) {
...
}

template <typename... Ts>
std::unique_ptr<Investment, void(*)(Investment*)> makeInvestment(Ts&&... args); // return type has size of Investment*
// plus at least size of function pointer

std::unique_ptr另一个广泛应用的场景是pImpl模式。

std::unique_ptr的两种形式分别是std::unique_ptr<T>std::unique_ptr<T[]>,其中前者没有定义operator[],后者在默认析构时会调用delete[],且没有定义operator*operator->。但在用到std::unique_ptr<T[]>的地方,你可能需要想一下是不是std::vectorstd::arraystd::string更合适。唯一一个用std::unique_ptr<T[]>更好的场合就是当你需要与C API交互时。

std::unique_ptr另一个吸引人的地方在于,它可以作为std::shared_ptr的构造参数,因此上面的工厂函数返回std::unique_ptr就再正确不过了:调用者可以根据自己对所有权的需求来决定用std::unique_ptr还是std::shared_ptr,反正都支持。

Item19: 需要共享所有权的资源管理,用std::shared_ptr

垃圾回收的好处:不用手动管理资源的生命期。坏处:资源回收的时间无法确定。

手动管理资源的好处:确定的资源回收时间,不只可以回收内存,还能回收任何其它资源。坏处:复杂,容易写出bug。

C++11中结合以上两者的方式是使用std::shared_ptr。使用std::shared_ptr管理的对象的所有权是共享的,没有哪个std::shared_ptr特别拥有这个对象,而是最后一个std::shared_ptr析构时,销毁这个对象。与垃圾回收类似,调用者不需要手动管理std::shared_ptr管理的对象;与析构函数类似,对象的析构时间是确定的。

std::shared_ptr内部有引用计数,被复制时,引用计数+1,有std::shared_ptr析构时,引用计数-1,当引用计数为0时,析构持有的对象。

引用计数的存在有以下性能影响:

  • std::shared_ptr的大小是裸指针的两倍:一个指针指向持有的对象,一个指针指向引用计数。
  • 引用计数使用的内存必须动态分配,原因是std::shared_ptr的引用计数是非侵入式的,必须要独立在对象外面。用std::make_shared能避免这次单独的内存分配。
  • 引用计数的加减必须是原子的,因此你必须假设读写引用计数是有成本的。

注意,不是所有std::shared_ptr的构造都会增加引用计数,移动构造就不会。因此移动构造一个std::shared_ptr要比复制一个更快。

std::unique_ptr类似,std::shared_ptr的默认销毁动作也是delete,且也可以接受自定义的销毁器。但与std::unique_ptr不同的是,std::shared_ptr的销毁器类型不必作为它的模板参数之一:

1
2
3
4
5
6
7
8
auto loggingDel = [](Widget* pw) {
makeLogEntry(pw);
delete pw;
};

std::unique_ptr<Widget, decltype(loggingDel)> upw(new Widget, loggingDel);

std::shared_ptr<Widget> spw(new Widget, loggingDel);

因此std::shared_ptr要比std::unique_ptr使用更灵活,比如不同销毁器的std::shared_ptr可以放到同一个容器中,而std::unique_ptr则不可以。

另外,不同的销毁器不会改变std::shared_ptr的大小。std::shared_ptr内部需要为引用计数单独开辟一块内存,那么这块内存中再放一个销毁器也没什么额外开销。实际上这块内存被称为”控制块”,它里面包含以下元素:

  • 引用计数
  • 弱引用计数
  • 其它数据,包括:
    • 自定义销毁器
    • 内存分配器
    • 等等

控制块的创建规则为:

  • std::make_shared总会创建一个控制块。
  • 通过一个独享所有权的指针(如std::unique_ptrstd::auto_ptr)创建出的std::shared_ptr总会创建一个控制块。
  • 通过裸指针创建的std::shared_ptr会创建控制块。

一个推论就是:通过一个裸指针创建两个std::shared_ptr,会创建两个控制块,进而导致这个裸指针会被析构两次!

从中我们可以得到两个教训:

  1. 不要直接用裸指针构造std::shared_ptr,尽量用std::make_shared。当然在需要自定义的销毁器时不能用std::make_shared
  2. 非要用裸指针构造std::shared_ptr的话,尽量直接new,不要传入已有的裸指针变量。

有一种场景下,我们可能无意间创建了对应同一指针的两个控制块。

1
std::vector<std::shared_ptr<Widget>> processedWidgets;

processedWidgets表示所有处理过的Widget。进一步假设Widget有一个成员函数process

1
2
3
4
5
6
7
8
class Widget {
public:
...
void process() {
...
processedWidgets.emplace_back(this); // this is wrong!
}
};

如果被调用processWidget对象本身就被std::shared_ptr所管理,上面那行代码会导致它又创建了一个新的控制块。这种情况下我们应该令Widget继承自std::enable_shared_from_this,它允许创建一个指向自身控制块的std::shared_ptr

1
2
3
4
5
6
7
8
class Widget: public std::enable_shared_from_this<Widget> {
public:
...
void process() {
...
processedWidgets.emplace_back(shared_from_this());
}
};

这种基类是用派生类特化的模板的模式,称为“奇异递归模板模式”(The Curiously Recurring Template Pattern, CRTP)。

在调用shared_from_this,它会寻找指向自身的控制块。如果此时这个对象没有被任何一个std::shared_ptr持有,也就没有控制块,那么shared_from_this的行为是未定义的。因此往往继承自std::enable_shared_from_this的类都会把构造函数设为private,再提供一个静态方法来得到该类型的std::shared_ptr

1
2
3
4
5
6
7
8
9
class Widget: public std::enable_shared_from_this<Widget> {
public:
template <typename... Ts>
static std::shared_ptr<Widget> create(Ts&&... params);
...
void process();
private:
... // ctors
};

控制块会带来哪些开销呢?一个控制块通常只有几个word大,但其中会用到继承,甚至还有虚函数。这也意味着使用std::shared_ptr也会有调用虚函数的开销。

但通常来说std::shared_ptr的额外开销是很小的。对于std::make_shared创建的std::shared_ptr,它的控制块只有3个word大,且内存分配上无额外成本。解引用一个std::shared_ptr也不会比解引用一个裸指针开销大。操作引用计数会带来一两次原子操作的开销,但通常也不大。

但你用这些开销换来的是自动的资源管理。std::shared_ptr能满足大多数的共享所有权的资源管理需求。如果你还犹豫的话就想想共享所有权和额外开销,哪个对你而言更重要。

std::shared_ptr的一个缺点是它不支持数组,但在C++11已经提供了std::arraystd::vectorstd::string这些容器类的前提下,还要用std::shared_ptr去管理一个数组,本身就是不好设计的信号。

Item20: 在需要共享语义且可能空悬的地方用std::weak_ptr

有时候我们需要一种类似std::shared_ptr,但又不参与这个共享对象的所有权的智能指针。这样它就需要能知道共享对象是否已经销毁了。这就是std::weak_ptrstd::weak_ptr不是单独存在的,它不能解引用,也不能检测是否为空,它就是配合std::shared_ptr使用的。

通常std::weak_ptr都是通过std::shared_ptr构造的,但它不会影响std::shared_ptr的引用计数:

1
2
3
4
5
auto spw = std::make_shared<Widget>(); // ref count is 1
...
std::weap_ptr<Widget> wpw(spw); // ref count remains 1
...
spw = nullptr; // ref count toes to 0, wps now dangles

可以用expired()来检测std::weak_ptr指向的对象是否有效:

1
if (wpw.expired()) ...

另一个常用的操作是lock(),它能原子地检测对象是否有效,以及返回这个对象的std::shared_ptr

1
std::shared_ptr<Widget> spw = wpw.lock(); // if wpw's expired, spw is null

与之类似的操作是用std::weak_ptr构造一个std::shared_ptr

1
std::shared_ptr<Widget> spw(wpw); // 

区别在于,如果wpw已经失效了,这次构造会抛std::bad_weak_ptr的异常。

下面我们用一个例子来说明std::weak_ptr的必要性。

想象我们要实现一个cache,希望其中的元素在无人使用后被销毁。这里我们用std::unique_ptr并不合适,因为cache天然需要共享的语义。这样每个调用者都可以获得一个cache中元素的std::shared_ptr,它的生命期由调用者控制。cache内还需要保存一份元素的指针,且有能力检测它是不是失效了。这里我们需要的就是std::weak_ptr

1
2
3
4
5
6
7
8
9
std::shared_ptr<const Widget> fastLoadWidget(WidgetID id) {
static std::unordered_map<WidgetID, std::weak_ptr<const Widget>> cache;
auto objPtr = cache[id].lock();
if (!objPtr) {
objPtr = loadWidget(id);
cache[id] = objPtr;
}
return objPtr;
}

请不用在意上面的static,这只是个示意。

第二个例子是设计模式中的“观察者模式”。它的一种典型实现是每个主题对象持有一组观察者的指针,每当主题对象有状态变化时依次通知每个观察者。这里主题对象不需要控制观察者的生命期,但需要知道观察者的指针是否还有效。用std::weak_ptr就可以非常自然的实现出这样的特性。

第三个例子是,当A和C都持有B的std::shared_ptr时,如果B也需要持有A的某种指针,该持有什么?

  • 裸指针:如果A析构了,但C还在,B也就还在,此时B持有的A的裸指针就成了空悬指针,不好。
  • std::shared_ptr:这样A与B就形成了循环依赖,永远不可能析构了。
  • std::weak_ptr:唯一的好选择。

但要注意的是,用std::weak_ptr来解std::shared_ptr可能造成的循环依赖,这种特性本身并没有价值。设计良好的数据结构,比如树,父节点控制子节点的生命期,但子节点也需要持有父节点的指针,这里最好的方案是父节点用std::unique_ptr来持有子节点,而子节点直接持有父节点的裸指针。即,严格层次结构,明确生命期的场景,不需要使用std::weak_ptrstd::weak_ptr的价值在于:在生命期不明确的场景,可以知道对象是否还有效。

在效率方面,std::weak_ptr的大小与std::shared_ptr是相同的,它们使用相同的控制块,区别在于std::weak_ptr不会影响控制块中的引用计数,只会影响其中的弱引用计数。

Item21: 优先用std::make_uniquestd::make_shared而不是直接new

先做一下介绍,std::make_shared是在C++11中增加的,但std::make_unique却是在C++14中增加的。如果你想在C++11中就用上std::make_unique,自己写一个简单版的也不难:

1
2
3
4
template <typename T, typename... Ts>
std::unique_ptr<T> make_unique(Ts&&... params) {
return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
}

这个版本不支持数组,不支持自定义的销毁器,但这些都不重要,它足够用了。但要记住的是,不要把它放到namespace std下面。

这两个make函数的功能就不解释了,和它们类似的还有一个std::allocate_shared

1
2
3
4
5
auto upw1(std::make_unique<Widget>());
std::unique_ptr<Widget> upw2(new Widget);

auto spw1(std::make_shared<Widget>());
std::shared_ptr<Widget> spw2(new Widget);

上面这个例子说明了用make函数的第一个好处:不需要重复写一遍类型。所有程序员都知道:不要重复代码。代码越少,bug越少。

第二个好处:异常安全性。想象我们有两个函数:

1
2
void processWidget(std::shared_ptr<Widget> spw, int priority);
int computePriority();

调用代码很可能长成这个样子:

1
processWidget(std::shared_ptr<Widget>(new Widget), computePriority()); // potential resource leak!

上面这行代码有内存泄漏的风险,为什么?根据C++标准,在processWidget的参数求值过程中,我们只能确定下面几点:

  • new Widget一定会执行,即一定会有一个Widget对象在堆上被创建。
  • std::shared_ptr<Widget>的构造函数一定会执行。
  • computePriority一定会执行。

new Widget的结果是std::shared_ptr<Widget>构造函数的参数,因此前者一定早于后者执行。除此之外,编译器不保证其它操作的顺序,即有可能执行顺序为:

  1. new Widget
  2. 执行computePriority
  3. 构造std::shared_ptr<Widget>

如果第2步抛异常,第1步创建的对象还没有被std::shared_ptr<Widget>管理,就会发生内存泄漏。

如果这里我们用std::make_shared,就能保证new Widgetstd::shared_ptr<Widget>是一起完成的,中间不会有其它操作插进来,即不会有不受智能指针保护的裸指针出现:

1
processWidget(std::make_shared<Widget>(), computePriority()); // no potential resource leak

第三个好处:更高效。

1
std:shared_ptr<Widget> spw(new Widget);

这行代码中,我们以为只有一次内存分配,实际发生了两次,第二次是在分配std::shared_ptr控制块。如果用std::make_shared,它会把Widget对象和控制块合并为一次内存分配。

但是make函数也有一些缺点。

第一个缺点:无法传入自定义的销毁器。

第二个缺点:make函数初始化时使用了括号初始化,而不是花括号初始化,比如std::make_unique<std::vector<int>>(10, 20)创建了一个有着20个值为10的元素的vector,而不是创建了{10, 20}这么两个元素的vector(参见Item7)。

第三个缺点:对象和控制块分配在一块内存上,减少了内存分配的次数,但也导致对象和控制块占用的内存也要一次回收掉。即,如果还有std::weak_ptr存在,控制块就要在,对象占用的内存也没办法回收。如果对象比较大,且std::weak_ptr在对象析构后还可能长期存在,那么这种开销是不可忽视的。

如果我们因为前面这三个缺点而不能使用std::make_shared,那么我们要保证,智能指针的构造一定要单独一个语句。回到之前processWidget的例子中,假设我们有个自定义的销毁器void cusDel(Widget* ptr);,因此不能使用std::make_shared,那么我们要这么写来保证异常安全性:

1
2
std::shared_ptr<Widget> spw(new Widget, cusDel);
processWidget(spw, computePriority());

但这么写还不够高效,这里我们明确知道spw就是给processWidget用的,那么可以使用std::move,将其转为右值,来避免对引用计数的修改:

1
2
std::shared_ptr<Widget> spw(new Widget, cusDel);
processWidget(std::move(spw), computePriority());

Item22: 在用到Pimpl惯用法时,在实现文件中定义特殊成员函数

我们经常用名为Pimpl的方法来实现接口与实现分离,进而大大降低程序构建的时间。Pimpl是指把类A中的所有数据成员都移到一个impl类中,A中只留下一个impl类型的指针。一个例子:

1
2
3
4
5
6
7
8
9
class Widget {
public:
Widget();
...
private:
std::string name;
std::vector<double> data;
Gadget g1, g2, g3; // Gadget is some user-defined type
};

Widget的数据成员的类型为std::stringstd::vector<double>Gadget,这样就至少要include三个头文件,这也意味着每个需要include了这个包含Widget定义的头文件的地方,都被动引入了三个头文件。如果有一天我们修改了Widget的实现,比如增加或删除了一个成员,即使它们都是private的,即使接口完全没有变化,所有include它的用户文件都要重新编译。我们不想污染用户文件,也不想用户文件因为我们的实现修改而重新编译,我们就可以用Pimpl:

1
2
3
4
5
6
7
8
9
class Widget {
public:
Widget();
~Widget();
...
private:
struct Impl;
Impl* pImpl;
};

注意这里出现的Impl类型只是声明,没有定义,称为“不完整类型”,这样的类型只支持很少的操作,其中包括了我们需要的:声明一个不完整类型的指针。

对应的实现文件内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Widget::Impl {
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};

Widget::Widget()
: pImpl(new Impl)
{}

Widget::~Widget()
{
delete pImpl;
}

有了智能指针后,我们觉得直接newdelete不好,需要用std::unique_ptr

1
2
3
4
5
6
7
8
class Widget {
public:
Widget();
...
private:
struct Impl
std::unique_ptr<Impl> pImpl;
};

因为不需要手动的delete,我们没有自己实现Widget的析构函数。

看起来都很美好,编译也没问题,但在用户要用时,出事了:

1
Widget w; // error!

编译时出错(参见delete不完整类型的指针):

究其原因,是因为我们没有给Widget实现自定义的析构函数,因此编译器为Widget准备了一个。这个析构函数会被放到Widget的定义体内,默认是内联的,因此会有一份实现在用户文件中。~Widget中只做一件事:析构pImpl,即析构一个std::unique_ptr<Impl>。注意,我们隐藏了Impl的实现,在析构std::unique_ptr<Impl>时编译器发现Impl还是个不完整类型,此时对它调用delete是危险的,因此编译器用static_cast禁止了这种行为。

解决方案很简单:自己实现Widget的析构函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// widget.h
class Widget {
public:
Widget();
~Widget();
...
private:
struct Impl
std::unique_ptr<Impl> pImpl;
};
// widget.cpp
...
Widget::Widget()
: pImpl(std::make_unique<Impl>())
{}

Widget::~Widget()
{}

参考Item17,更好的方法是将析构函数定义为= default

1
Widget::~Widget() = default;

根据Item17,自定义的析构函数会阻止编译器生成移动构造函数和移动赋值函数,因此如果你想要Widget有移动的能力,就要自己实现:

1
2
3
4
5
6
7
8
class Widget {
public:
Widget();
~Widget();
Widget(Widget&& rhs) = default; // right idea, wrong code!
Widget& operator=(Widget&& rhs) = default;
...
};

注意不要在这些特殊成员函数的声明后面加= default,这样会重复上面析构函数的问题:会被内联,因此在用户代码中有一份实现,遇到不完整类型,game over。我们要做的就是在.cpp中将它们的实现定义为= default

接下来就是复制构造函数和复制赋值函数了。我们用std::unique_ptr是为了更好的实现Pimpl方法,这也导致了Widget无法自动生成复制函数(std::unique_ptr不支持),但这并不意味着Widget就不能支持复制了,我们还可以自己定义两个复制函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// widget.h
class Widget {
public:
...
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs);
...
};
// widget.cpp
Widget::Widget(const Widget& rhs)
: pImpl(nullptr) {
if (rhs.pImpl) {
pImpl = std::make_unique<Impl>(*rhs.pImpl);
}
}
Widget& Widget::operator=(const Widget& rhs) {
if (!rhs.pImpl) {
pImpl.reset();
} else if (!pImpl) {
pImpl = std::make_unique<Impl>(*rhs.pImpl);
} else {
*pImpl = *rhs.pImpl;
}
}

有意思的是,如果你把pImpl的类型改为std::shared_ptr<Impl>,你会发现上面所有这些注意事项,都不见了。你不需要手动实现析构函数、移动函数、构造函数,程序编译仍然是好的。

这种差异来自于std::unique_ptrstd::shared_ptr对自定义销毁器的支持方式不同(参见为什么unique_ptr的Deleter是模板类型参数,而shared_ptr的Deleter不是)。std::unique_ptr的目标是从体积到性能上尽可能与裸指针相同,因此它将销毁器类型作为模板参数的一部分,这样实现起来更高效,代价是各种特殊函数在编译时就要知道元素的完整类型。而std::shared_ptr没有这种性能上的要求,因此它的销毁器不是模板参数的一部分,性能会有一点点影响,但好处是不需要在编译特殊函数时知道元素的完整类型。

std::shared_ptr在构造时就把销毁器保存在了控制块中,之后即使传递到了不知道元素完整类型的地方,它仍然能调用正确的销毁器来销毁元素指针。而std::unique_ptr是依靠模板参数提供的类型信息来进行销毁,因此必须要知道元素的完整类型。

目录