现代C++中的访问者模式:从传统到通用的演进
1. 访问者模式基础与无环访问者模式分析
在使用访问者模式时,如果尝试访问特定访问者不支持的对象,错误能够被检测到,这解决了部分访问的问题。同时,依赖循环问题也得到了处理,通用的
PetVisitor
基类无需列出所有可访问对象的完整层次结构,具体的可访问类仅依赖于各自类的访问者,而非其他类型的访问者。因此,当向层次结构中添加新的可访问对象时,现有对象无需重新编译。
无环访问者模式看似十分出色,但为何不总是使用它,而要使用常规的访问者模式呢?主要有以下几个原因:
-
性能问题
:无环访问者模式使用
dynamic_cast
从一个基类转换到另一个基类(有时称为交叉转换),该操作通常比虚函数调用成本更高,所以无环访问者模式的速度比替代方案慢。
-
类数量与继承问题
:无环访问者模式要求为每个可访问类都创建一个访问者类,这使得类的数量翻倍,并且使用了带有许多基类的多重继承。虽然对于大多数现代编译器来说,多重继承不是太大问题,但许多程序员认为处理多重继承较为困难。
-
代码重复问题
:无环访问者模式存在大量样板代码,每个可访问类都需要复制几行代码。实际上,常规访问者模式也存在同样的问题,实现任何一种访问者都涉及大量重复的输入。不过,C++ 提供了通用编程工具来实现代码复用,以解决代码重复问题。
2. 现代C++中的访问者模式:通用访问者
为了减少访问者模式实现中的样板代码,我们从
accept()
成员函数入手。该函数必须复制到每个可访问类中,其代码通常如下:
class Cat : public Pet {
void accept(PetVisitor& v) override { v.visit(this); }
};
由于需要以实际类型而非基类型调用访问者,所以该函数不能移到基类中。我们可以引入一个中间模板基类来生成这个函数:
// Example 11
class Pet {
public:
virtual ~Pet() {}
Pet(std::string_view color) : color_(color) {}
const std::string& color() const { return color_; }
virtual void accept(PetVisitor& v) = 0;
private:
std::string color_;
};
template <typename Derived>
class Visitable : public Pet {
public:
using Pet::Pet;
void accept(PetVisitor& v) override {
v.visit(static_cast<Derived*>(this));
}
};
这个模板由派生类参数化,类似于奇异递归模板模式(CRTP),但这里我们不继承模板参数,而是用它将
this
指针转换为正确的派生类指针。现在,只需让每个宠物类从模板的正确实例派生,就可以自动获得
accept()
函数:
// Example 11
class Cat : public Visitable<Cat> {
using Visitable<Cat>::Visitable;
};
class Dog : public Visitable<Dog> {
using Visitable<Dog>::Visitable;
};
这样就解决了一半的样板代码问题,即派生可访问对象内部的代码。接下来,我们还需要处理访问者类内部的样板代码,即需要为每个可访问类反复输入相同的声明。对于具体的访问者,我们难以做太多改进,因为这里是实际工作的地方,不同的可访问类可能需要执行不同的操作。
不过,我们可以通过引入通用的
Visitor
模板来简化基访问者类的声明:
// Example 12
template <typename ... Types> class Visitor;
template <typename T> class Visitor<T> {
public:
virtual void visit(T* t) = 0;
};
template <typename T, typename ... Types>
class Visitor<T, Types ...> : public Visitor<Types ...> {
public:
using Visitor<Types ...>::visit;
virtual void visit(T* t) = 0;
};
这个模板只需实现一次,是一个很好的通用库类。有了它,为特定类层次结构声明访问者基类变得非常简单:
// Example 12
class Cat;
class Dog;
using PetVisitor = Visitor<Cat, Dog>;
通用访问者基类的工作原理是使用可变参数模板捕获任意数量的类型参数,主模板仅声明而未定义,其余为特化版本。首先处理一个类型参数的特殊情况,声明该类型的纯虚
visit()
成员函数。然后处理多个类型参数的情况,第一个参数显式指定,其余参数在参数包中。为显式指定的类型生成
visit()
函数,并从相同可变参数模板的实例继承其余函数,实例化过程递归进行,直到只剩下一个类型参数,然后使用第一个特化版本。
然而,这种通用且可复用的代码存在一个限制,即它无法处理深层层次结构。如果从
Cat
派生另一个类,如
SiameseCat
,它也必须从
Visitable
派生:
class SiameseCat : public Cat,
public Visitable<SiameseCat> {...};
但这样会导致
SiameseCat
类从
Pet
继承两次,一次通过
Cat
基类,一次通过
Visitable
基类。如果仍想使用模板生成
accept()
方法,唯一的解决方案是分离层次结构,使每个可访问类(如
Cat
)从
Visitable
和相应的基类(如
CatBase
)继承,这会使层次结构中的类数量翻倍,是一个主要缺点。
3. 现代C++中的访问者模式:Lambda访问者
定义具体访问者的大部分工作是为每个可访问对象编写实际工作的代码。特定访问者类中的样板代码并不多,但有时我们可能不想显式声明类本身。就像 lambda 表达式一样,能用 lambda 表达式完成的任何事情,也可以用显式声明的可调用类完成,因为 lambda 是(匿名)可调用类。同样,我们可能希望编写一个无需显式命名的访问者,即 Lambda 访问者,其代码示例如下:
auto v(lambda_visitor<PetVisitor>(
[](Cat* c) { std::cout << "Let the " << c->color()
<< " cat out" << std::endl;
},
[](Dog* d) { std::cout << "Take the " << d->color()
<< " dog for a walk" << std::endl;
}
));
pet->accept(v);
要实现 Lambda 访问者,需要解决两个问题:
-
创建处理类型列表和对应对象的类
:需要递归实例化模板,每次剥离一个参数。
-
使用 lambda 表达式生成一组重载函数
:解决方案类似于类模板章节中描述的 lambda 表达式重载集,我们可以使用递归模板实例化直接构建重载函数集。
实现过程中还面临一个新挑战,即需要处理两个类型列表。第一个列表包含所有可访问类型(如
Cat
和
Dog
),第二个列表包含每个可访问类型对应的 lambda 表达式类型。由于不能简单地声明
template<typename... A, typename... B>
,因为编译器无法确定第一个列表的结束位置和第二个列表的开始位置,所以需要将一个或两个类型列表隐藏在其他模板中。
我们首先声明
LambdaVisitor
类的通用模板:
// Example 13
template <typename Base, typename...>
class LambdaVisitor;
该模板必须声明,但不会直接使用,我们将为需要处理的每种情况提供特化版本。第一个特化版本用于只有一个可访问类型和一个对应 lambda 表达式的情况:
// Example 13
template <typename Base, typename T1, typename F1>
class LambdaVisitor<Base, Visitor<T1>, F1> :
private F1, public Base
{
public:
LambdaVisitor(F1&& f1) : F1(std::move(f1)) {}
LambdaVisitor(const F1& f1) : F1(f1) {}
using Base::visit;
void visit(T1* t) override { return F1::operator()(t); }
};
这个特化版本不仅处理只有一个可访问类型的情况,还作为每个递归模板实例化链中的最后一个实例。它是
LambdaVisitor
实例递归层次结构中的第一个基类,也是唯一直接从基访问者类(如
PetVisitor
)继承的类。即使只有一个可访问类型
T1
,我们也使用
Visitor
模板作为包装器,为处理未知长度的类型列表做准备。两个构造函数将 lambda 表达式
f1
存储在
LambdaVisitor
类中,尽可能使用移动而非复制。最后,
visit(T1*)
虚函数重写将调用转发给 lambda 表达式。
对于任意数量的可访问类型和 lambda 表达式的一般情况,使用以下部分特化版本:
// Example 13
template <typename Base,
typename T1, typename... T,
typename F1, typename... F>
class LambdaVisitor<Base, Visitor<T1, T...>, F1, F...> :
private F1,
public LambdaVisitor<Base, Visitor<T ...>, F ...>
{
public:
LambdaVisitor(F1&& f1, F&& ... f) :
F1(std::move(f1)),
LambdaVisitor<Base, Visitor<T...>, F...>(
std::forward<F>(f)...)
{}
LambdaVisitor(const F1& f1, F&& ... f) :
F1(f1),
LambdaVisitor<Base, Visitor<T...>, F...>(
std::forward<F>(f) ...)
{}
using LambdaVisitor<Base, Visitor<T ...>, F ...>::visit;
void visit(T1* t) override { return F1::operator()(t); }
};
同样有两个构造函数,将第一个 lambda 表达式存储在类中,并将其余表达式转发到下一个实例。在递归的每一步生成一个虚函数重写,始终针对可访问类剩余列表中的第一个类型。然后从列表中移除该类型,继续以相同方式处理,直到达到最后一个实例,即处理单个可访问类型的实例。
由于无法显式命名 lambda 表达式的类型,我们也不能显式声明 Lambda 访问者的类型。因此,需要一个
lambda_visitor()
模板函数,接受多个 lambda 表达式参数并从它们构造
LambdaVisitor
对象:
// Example 13
template <typename Base, typename ... F>
auto lambda_visitor(F&& ... f) {
return LambdaVisitor<Base, Base, F...>(
std::forward<F>(f) ...);
}
在 C++17 中,也可以使用推导指南实现相同的功能。现在,我们有了一个可以存储任意数量 lambda 表达式并将每个表达式绑定到相应
visit()
重写的类,就可以像编写 lambda 表达式一样轻松编写 Lambda 访问者:
// Example 13
void walk(Pet& p) {
auto v(lambda_visitor<PetVisitor>(
[](Cat* c){std::cout << "Let the " << c->color()
<< " cat out" << std::endl;},
[](Dog* d){std::cout << "Take the " << d->color()
<< " dog for a walk" << std::endl;}
));
p.accept(v);
}
需要注意的是,由于我们在从相应 lambda 表达式继承的同一类中声明
visit()
函数,
lambda_visitor()
函数参数列表中 lambda 表达式的顺序必须与
PetVisitor
定义中类型列表的类顺序匹配。如果需要,可以通过增加实现的复杂度来移除这个限制。
另一种在 C++ 中处理类型列表的常见方法是将它们存储在
std::tuple
中,例如可以使用
std::tuple<Cat, Dog>
表示由这两个类型组成的列表。同样,整个参数包也可以存储在元组中:
// Example 14
template <typename Base, typename F1, typename... F>
class LambdaVisitor<Base, std::tuple<F1, F...>> :
public F1, public LambdaVisitor<Base, std::tuple<F...>>;
通过比较示例 13 和 14,可以了解如何使用
std::tuple
存储类型列表。
4. 现代C++中的访问者模式:通用无环访问者
无环访问者模式不需要一个列出所有可访问类型的基类,但它也有自己的样板代码。每个可访问类型都需要
accept()
成员函数,且该函数的代码比原始访问者模式中的类似函数更多:
// Example 10
class Cat : public Pet {
public:
void accept(PetVisitor& v) override {
if (CatVisitor* cv = dynamic_cast<CatVisitor*>(&v)) {
cv->visit(this);
} else { // Handle error
assert(false);
}
}
};
假设错误处理是统一的,这个函数会针对不同类型的访问者反复出现,每个访问者对应一个可访问类型(如这里的
CatVisitor
)。此外,还有每个类型的访问者类本身,例如:
class CatVisitor {
public:
virtual void visit(Cat* c) = 0;
};
这些代码在程序中大量复制,只是有细微的修改。我们可以将这种容易出错的代码重复转换为易于维护的可复用代码。
首先,我们需要创建一些基础设施。无环访问者模式的层次结构基于所有访问者的通用基类,例如:
class PetVisitor {
public:
virtual ~PetVisitor() {}
};
这个类没有特定于
Pet
层次结构的内容,使用更好的名称后,它可以作为任何访问者层次结构的基类:
// Example 15
class VisitorBase {
public:
virtual ~VisitorBase() {}
};
我们还需要一个模板来生成所有特定于可访问类型的访问者基类,以替代几乎相同的
CatVisitor
、
DogVisitor
等。由于这些类只需要声明纯虚
visit()
方法,我们可以通过可访问类型对模板进行参数化:
// Example 15
template <typename Visitable> class Visitor {
public:
virtual void visit(Visitable* p) = 0;
};
任何类层次结构的基可访问类现在使用通用的
VisitorBase
基类接受访问者:
// Example 15
class Pet {
...
virtual void accept(VisitorBase& v) = 0;
};
为了避免每个可访问类直接从
Pet
派生并复制
accept()
方法,我们引入一个中间模板基类,它可以生成具有正确类型的该方法:
// Example 15
template <typename Visitable>
class PetVisitable : public Pet {
public:
using Pet::Pet;
void accept(VisitorBase& v) override {
if (Visitor<Visitable>* pv =
dynamic_cast<Visitor<Visitable>*>(&v)) {
pv->visit(static_cast<Visitable*>(this));
} else { // Handle error
assert(false);
}
}
};
这是我们需要编写的唯一一份
accept()
函数代码,它包含了我们应用程序中处理访问者不被基类接受情况的首选错误处理实现(回想一下,无环访问者允许部分访问,即某些访问者和可访问对象的组合不被支持)。与常规访问者一样,中间的 CRTP 基类使得这种方法难以处理深层层次结构。
具体的可访问类通过中间的
PetVisitable
基类间接继承自通用的
Pet
基类,该基类也为它们提供了可访问接口。
PetVisitable
模板的参数是派生类本身(再次体现了 CRTP 的应用):
// Example 15
class Cat : public PetVisitable<Cat> {
using PetVisitable<Cat>::PetVisitable;
};
class Dog : public PetVisitable<Dog> {
using PetVisitable<Dog>::PetVisitable;
};
当然,并非所有派生类都必须使用相同的基类构造函数,每个类可以根据需要定义自定义构造函数。
最后,我们需要实现访问者类。回想一下,无环访问者模式中的特定访问者继承自通用访问者基类和每个代表支持的可访问类型的访问者类。现在我们有了一种按需生成这些访问者类的方法:
// Example 15
class FeedingVisitor : public VisitorBase,
public Visitor<Cat>,
public Visitor<Dog>
{
public:
void visit(Cat* c) override {
std::cout << "Feed tuna to the " << c->color()
<< " cat" << std::endl;
}
void visit(Dog* d) override {
std::cout << "Feed steak to the " << d->color()
<< " dog" << std::endl;
}
};
回顾我们所做的工作,访问者类的并行层次结构不再需要显式输入,而是按需生成。重复的
accept()
函数被简化为单个
PetVisitable
类模板。不过,我们仍然需要为每个新的可访问类层次结构编写这个模板。我们可以进一步泛化,创建一个适用于所有层次结构的单一可复用模板,通过基可访问类进行参数化:
// Example 16
template <typename Base, typename Visitable>
class VisitableBase : public Base {
public:
using Base::Base;
void accept(VisitorBase& vb) override {
if (Visitor<Visitable>* v =
dynamic_cast<Visitor<Visitable>*>(&vb)) {
v->visit(static_cast<Visitable*>(this));
} else { // Handle error
assert(false);
}
}
};
现在,对于每个可访问类层次结构,我们只需要创建一个模板别名:
// Example 16
template <typename Visitable>
using PetVisitable = VisitableBase<Pet, Visitable>;
我们还可以进行进一步简化,允许程序员将可访问类列表指定为类型列表,而不是像之前那样从
Visitor<Cat>
、
Visitor<Dog>
等继承。这需要一个可变参数模板来存储类型列表,实现类似于前面看到的
LambdaVisitor
实例:
// Example 17
template <typename ... V> struct Visitors;
template <typename V1>
struct Visitors<V1> : public Visitor<V1> {};
template <typename V1, typename ... V>
struct Visitors<V1, V ...> : public Visitor<V1>,
public Visitors<V ...> {};
通过以上的改进,我们可以看到现代 C++ 利用通用编程工具,有效地减少了访问者模式实现中的样板代码,提高了代码的可维护性和复用性。但同时,我们也需要注意这些方法在处理深层层次结构等方面的局限性,根据具体的应用场景选择合适的实现方式。
总结
本文详细介绍了现代 C++ 中访问者模式的多种实现方式,包括通用访问者、Lambda 访问者和通用无环访问者。通过引入模板和通用编程技术,我们成功减少了样板代码,提高了代码的可维护性和复用性。然而,每种实现方式都有其优缺点,在实际应用中需要根据具体需求进行选择。例如,通用访问者和 Lambda 访问者适用于减少常规访问者模式中的样板代码,但在处理深层层次结构时存在限制;通用无环访问者解决了无环访问者模式中的代码重复问题,但也面临性能和多重继承等挑战。希望本文能帮助读者更好地理解和应用现代 C++ 中的访问者模式。
流程图
graph TD;
A[开始] --> B[选择访问者模式类型];
B --> C{是否使用通用访问者};
C -- 是 --> D[引入中间模板基类生成accept()函数];
C -- 否 --> E{是否使用Lambda访问者};
E -- 是 --> F[解决类型列表和重载函数问题];
E -- 否 --> G{是否使用通用无环访问者};
G -- 是 --> H[创建基础设施和模板生成代码];
G -- 否 --> I[使用常规访问者模式];
D --> J[处理深层层次结构问题];
F --> K[处理两个类型列表挑战];
H --> L[简化访问者类声明];
J --> M[结束];
K --> M;
L --> M;
I --> M;
表格
| 访问者模式类型 | 优点 | 缺点 |
|---|---|---|
| 通用访问者 | 减少可访问类中accept()函数的样板代码,简化基访问者类声明 | 难以处理深层层次结构 |
| Lambda访问者 | 无需显式声明类,方便编写一次性访问者 | 需处理类型列表和重载函数问题,lambda表达式顺序有要求 |
| 通用无环访问者 | 解决无环访问者模式的代码重复问题,按需生成访问者类 | 性能较慢,涉及多重继承 |
| 常规访问者模式 | 简单直接 | 存在大量样板代码 |
现代C++中的访问者模式:从传统到通用的演进
5. 不同访问者模式的对比与选择
在实际应用中,选择合适的访问者模式至关重要,它直接影响到代码的性能、可维护性和可扩展性。下面我们从多个维度对上述几种访问者模式进行对比分析:
-
性能方面
- 常规访问者模式:由于使用虚函数调用,其性能相对稳定,但当可访问对象层次结构复杂时,虚函数表的查找可能会带来一定的开销。
- 通用访问者:性能与常规访问者模式相近,因为它也是基于虚函数调用实现的。不过,在处理深层层次结构时,由于需要额外的模板实例化,可能会增加编译时间和内存开销。
- Lambda访问者:其性能主要取决于lambda表达式的实现。由于lambda表达式通常是内联的,因此在某些情况下可能会有较好的性能表现。但如果lambda表达式过于复杂,可能会影响性能。
-
通用无环访问者:使用
dynamic_cast进行类型转换,这是一个相对昂贵的操作,因此其性能通常比其他几种模式要差。尤其是在频繁进行类型转换的场景下,性能问题会更加明显。
-
可维护性方面
- 常规访问者模式:代码结构清晰,但存在大量的样板代码,当可访问对象或访问者类型增加时,代码的维护成本会显著增加。
- 通用访问者:通过模板技术减少了样板代码,提高了代码的可维护性。但模板的使用可能会增加代码的复杂度,对于初学者来说可能较难理解。
- Lambda访问者:无需显式声明类,代码简洁,易于编写和维护。但lambda表达式的顺序要求可能会给代码的维护带来一定的困扰。
- 通用无环访问者:解决了无环访问者模式中的代码重复问题,提高了代码的可维护性。但多重继承的使用可能会增加代码的复杂度,需要开发者具备较高的编程技能。
-
可扩展性方面
- 常规访问者模式:当需要添加新的可访问对象或访问者类型时,需要修改多个类的代码,扩展性较差。
- 通用访问者:通过模板技术,添加新的可访问对象或访问者类型相对容易,只需对模板进行相应的实例化即可。
- Lambda访问者:可以方便地添加新的lambda表达式来处理不同的可访问对象,扩展性较好。
- 通用无环访问者:可以按需生成访问者类,添加新的可访问对象或访问者类型时,只需对模板进行相应的修改,扩展性较强。
根据以上对比,我们可以总结出以下选择建议:
- 如果可访问对象层次结构简单,且对性能要求较高,可选择常规访问者模式。
- 如果希望减少样板代码,提高代码的可维护性和可扩展性,可选择通用访问者或Lambda访问者。
- 如果需要支持部分访问,且对代码的可维护性有较高要求,可选择通用无环访问者。
6. 访问者模式的实际应用案例
为了更好地理解访问者模式在实际中的应用,我们来看一个简单的文件系统遍历的例子。假设我们有一个文件系统,包含文件和文件夹两种类型的对象,我们希望实现一个功能,能够统计文件系统中所有文件的大小。
#include <iostream>
#include <vector>
#include <string>
// 前向声明
class File;
class Folder;
// 访问者基类
template <typename ... Types> class Visitor;
template <typename T> class Visitor<T> {
public:
virtual void visit(T* t) = 0;
};
template <typename T, typename ... Types>
class Visitor<T, Types ...> : public Visitor<Types ...> {
public:
using Visitor<Types ...>::visit;
virtual void visit(T* t) = 0;
};
// 文件系统对象基类
class FileSystemObject {
public:
virtual void accept(Visitor<File, Folder>& v) = 0;
virtual ~FileSystemObject() {}
};
// 文件类
class File : public FileSystemObject {
private:
std::string name;
size_t size;
public:
File(const std::string& n, size_t s) : name(n), size(s) {}
size_t getSize() const { return size; }
void accept(Visitor<File, Folder>& v) override { v.visit(this); }
};
// 文件夹类
class Folder : public FileSystemObject {
private:
std::string name;
std::vector<FileSystemObject*> children;
public:
Folder(const std::string& n) : name(n) {}
void addChild(FileSystemObject* child) { children.push_back(child); }
void accept(Visitor<File, Folder>& v) override {
v.visit(this);
for (auto child : children) {
child->accept(v);
}
}
};
// 统计文件大小的访问者类
class SizeVisitor : public Visitor<File, Folder> {
private:
size_t totalSize = 0;
public:
void visit(File* f) override { totalSize += f->getSize(); }
void visit(Folder* f) override {}
size_t getTotalSize() const { return totalSize; }
};
int main() {
// 创建文件系统
Folder root("root");
File file1("file1.txt", 100);
File file2("file2.txt", 200);
Folder subFolder("subFolder");
File file3("file3.txt", 300);
subFolder.addChild(&file3);
root.addChild(&file1);
root.addChild(&file2);
root.addChild(&subFolder);
// 创建访问者并遍历文件系统
SizeVisitor visitor;
root.accept(visitor);
// 输出总大小
std::cout << "Total file size: " << visitor.getTotalSize() << " bytes" << std::endl;
return 0;
}
在这个例子中,我们定义了
File
和
Folder
两个可访问对象类,以及一个
SizeVisitor
访问者类。通过访问者模式,我们将文件大小统计的逻辑与文件系统对象的结构分离,使得代码更加清晰和可维护。
7. 访问者模式的未来发展趋势
随着C++语言的不断发展,访问者模式也可能会有一些新的发展趋势。以下是一些可能的方向:
-
与现代C++特性的深度融合 :C++17、C++20等标准引入了许多新的特性,如结构化绑定、概念(Concepts)等。未来,访问者模式可能会与这些新特性深度融合,进一步简化代码,提高代码的可读性和可维护性。
-
自动化代码生成工具的应用 :为了减少样板代码的编写,可能会出现一些自动化代码生成工具,根据用户定义的可访问对象和访问者类型,自动生成访问者模式的代码。
-
跨语言支持 :在一些大型项目中,可能会使用多种编程语言进行开发。未来,访问者模式可能会支持跨语言调用,使得不同语言编写的组件之间能够更好地协作。
总结
本文全面介绍了现代C++中访问者模式的多种实现方式,包括通用访问者、Lambda访问者和通用无环访问者,并对它们的优缺点进行了详细分析。通过实际应用案例,我们展示了访问者模式在实际中的应用场景。同时,我们也对访问者模式的未来发展趋势进行了展望。在实际开发中,我们应根据具体需求选择合适的访问者模式,充分发挥其优势,提高代码的质量和可维护性。
流程图
graph TD;
A[开始] --> B[创建文件系统对象];
B --> C[创建访问者对象];
C --> D[调用accept方法遍历文件系统];
D --> E{是否还有未访问的对象};
E -- 是 --> F[访问下一个对象];
E -- 否 --> G[输出统计结果];
F --> D;
G --> H[结束];
表格
| 步骤 | 操作 | 代码示例 |
|---|---|---|
| 1 | 创建文件系统对象 |
Folder root("root"); File file1("file1.txt", 100);
|
| 2 | 创建访问者对象 |
SizeVisitor visitor;
|
| 3 | 调用accept方法遍历文件系统 |
root.accept(visitor);
|
| 4 | 输出统计结果 |
std::cout << "Total file size: " << visitor.getTotalSize() << " bytes" << std::endl;
|
超级会员免费看
1425

被折叠的 条评论
为什么被折叠?



