《设计模式:可复用面向对象软件的基础》——创建型模式(2)(笔记)

三、创建型模式

3.4 PROTOTYPE(原型)

1.意图

用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。

补充部分

可以当做将抽象类和工厂基类(工厂方法)和并在了一起(即声明和创建又重新放在了一起)

// 抽象类
class ISplitter{
public:
	virtual void split() = 0;
	virtual ISpliter* clone() = 0;
	virtual ~ISplitter(){}
}

// 具体类
class BinarySplitter:public ISplitter{
public:
	virtual ISplitter* clone(){
		// 使用复制构造函数
		return new BinarySplitter(*this);
	}
}

class MainForm:public Form{
private:
	ISplitter* prototype = prototype;
public:
	MainForm(ISplitter* prototype){
		this->prototype = prototype;
	}
	void Button1_Click(){
		// 克隆原型,不能直接使用原型
		ISplitter splitter = prototype->clone();
	
		splitter ->split();
		// ...
	}
}

当我们面对结构复杂的对象时,我们用克隆可以简单的解决问题。通过直接使用对象的当前方法时,就可以立即使用。

如果用工厂方法创建很简单就用工厂。如果对象很复杂且我们需要对象的某些中间状态,就可以用原型。

一些语言可以利用某些框架中的序列化来实现深拷贝。

2.动机

你可以通过定制–个通用的图形编辑器框架和增加一些表示音符、休止符和五线谱的新对象来构造一个乐谱编辑器。这个编辑器框架可能有一个工具选择板用于将这些音乐对象加到乐谱中。这个选择板可能还包括选择、移动和其他操纵音乐对象的工具。用户可以点击四分音符工具并使用它将四分音符加到乐谱中。或者他们可以使用移动工具在五线谱上上下移动一个音符,从而改变它的音调。

我们假定该框架为音符和五线谱这样的图形构件提供了一个抽象的Graphics类。此外,为定义选择板中的那些工具,还提供一个抽象类Tool。该框架还为—些创建图形对象实例并将它们加入到文档中的工具预定义了一个GraphicTool子类。

但GraphicTool给框架设计者带来一个问题。音符和五线谱的类特定于我们的应用,而GraphicTool类却属于框架。GraphicTool不知道如何创建我们的音乐类的实例,并将它们添加.到乐谱中。我们可以为每一种音乐对象创建一个GraphicTool的子类,但这样会产生大量的子类,这些子类仅仅在它们所初始化的音乐对象的类别上有所不同。我们知道对象复合是比创建子类更灵活的一种选择。问题是,该框架怎么样用它来参数化GraphicTool的实例,而这些实例是由Graphic类所支持创建的。

解决办法是让GraphicTool通过拷贝或者“克隆”一个Graphic子类的实例来创建新的Graphic,我们称这个实例为一-个原型。GraphicTool将它应该克隆和添加到文档中的原型作为参数。如果所有Graphic子类都支持一个Clone操作,那么GraphicTool可以克隆所有种类的Graphic,如图所示。

在这里插入图片描述

因此在我们的音乐编辑器中,用于创建个音乐对象的每一种工具都是一个用不同原型进行初始化的GraphicTool实例。通过克隆一个音乐对象的原型并将这个克隆添加到乐谱中,每个GraphicTool实例都会产生一个音乐对象。

我们甚至可以进一步使用Prototype模式来减少类的数目。我们使用不同的类来表示全音符和半音符,但可能不需要这么做。它们可以是使用不同位图和时延初始化的相同的类的实例。一个创建全音符的工具就是这样的GraphicTool,它的原型是一个被初始化成全音符的MusicalNote。这可以极大的减少系统中类的数目,同时也更易于在音乐编辑器中增加新的音符。

3.适用性

  • 当一个系统应该独立于它的产品创建、构成和表示时,要使用Prototype模式
  • 当要实例化的类是在运行时刻指定时,例如,通过动态装载
  • 为了避免创建一个与产品类层次平行的工厂类层次时
  • 当一个类的实例只能有几个不同状态组合中的一种时。建立相应数目的原型并克隆它们,可能比每次用合适的状态手工实例化该类更方便一些。

4.结构

在这里插入图片描述

5.参与者

  • Prototype (Graphic)
    • 声明一个克隆自身的接口。
  • ConcretePrototype (Staff、WholeNote、HalfNote)
    • 实现一个克隆自身的操作。
  • Client (GraphicTool )
    • 让一个原型克隆自身从而创建一个新的对象。

6.协作

  • 客户请求一个原型克隆自身。

7.效果

Prototype有许多和Abstract Factory ( 3.1)和Builder ( 3.2)一样的效果:它对客户隐藏了具体的产品类,因此减少了客户知道的名字的数目。此外,这些模式使客户无需改变即可使用与特定应用相关的类。
下面列出Prototype模式的另外一些优点。

  1. 运行时刻增加和删除产品

Prototype允许只通过客户注册原型实例就可以将一个新的具体产品类并入系统。它比其他创建型模式更为灵活,因为客户可以在运行时刻建立和删除原型。

  1. 改变值以指定新对象

高度动态的系统允许你通过对象复合定义新的行为——例如,通过为一个对象变量指定值——并且不定义新的类。你通过实例化已有类并且将这些实例注册为客户对象的原型,就可以有效定义新类别的对象。客户可以将职责代理给原型,从而表现出新的行为。

这种设计使得用户无需编程即可定义新“类”。实际上,克隆一个原型类似于实例化一个类。Prototype模式可以极大的减少系统所需要的类的数目。在我们的音乐编辑器中,一个GraphicTool类可以创建无数种音乐对象。

  1. 改变结构以指定新对象

许多应用由部件和子部件来创建对象。例如电路设计编辑器就是由子电路来构造电路的°。为方便起见,这样的应用通常允许你实例化复杂的、用户定义的结构,比方说,一次又一次的重复使用一个特定的子电路。

Prototype模式也支持这一点。我们仅需将这个子电路作为-个原型增加到可用的电路元素选择板中。只要复合电路对象将Clone实现为–个深拷贝( deep copy),具有不同结构的电路就可以是原型了。

  1. 减少子类的构造

Factory Method ( 3.3)经常产生一个与产品类层次平行的Creator类层次。Prototype模式使得你克隆一个原型而不是请求–个工厂方法去产生一个新的对象。因此你根本不需要Creator类层次。这一优点主要适用于像C+4这样不将类作为一级类对象的语言。像.Smalltalk和Objective C这样的语言从中获益较少,因为你总是可以用一个类对象作为生成者。在这些语言中,类对象已经起到原型一样的作用了。

  1. 用类动态配置应用

一些运行时刻环境允许你动态将类装载到应用中。在像C++这样的语言中,Prototype模式是利用这种功能的关键。

一个希望创建动态载人类的实例的应用不能静态引用类的构造器。而应该由运行环境在载人时自动创建每个类的实例,并用原型管理器来注册这个实例(参见实现一节)。这样应用就可以向原型管理器请求新装载的类的实例,这些类原本并没有和程序相连接。ET++应用框架[WGM88]有一个运行系统就是使用这一方案的。

Prototype的主要缺陷是每一个Prototype的子类都必须实现Clone操作,这可能很困难。例如,当所考虑的类已经存在时就难以新增Clone操作。当内部包括一些不支持拷贝或有循环引用的对象时,实现克隆可能也会很困难的。

8 实现

因为在像C++这样的静态语言中,类不是对象,并且运行时刻只能得到很少或者得不到任何类型信息,所以Prototype特别有用。而在Smalltalk或Objective C这样的语言中Prototype就不是那么重要了,因为这些语言提供了一个等价于原型的东西(即类对象)来创建每个类的实例。Prototype模式在像Self[US87]这样基于原型的语言中是固有的,所有对象的创建都是通过克隆一个原型实现的。

当实现原型时,要考虑下面一些问题:

  1. 使用一个原型管理器

当一个系统中原型数目不固定时(也就是说,它们可以动态创建和销毁),要保持一个可用原型的注册表。客户不会自己来管理原型,但会在注册表中存储和检索原型。客户在克隆一个原型前会向注册表请求该原型。我们称这个注册表为原型管理器( prototype manager )。

原型管理器是一个关联存储器( associative store ),它返回一个与给定关健字相匹配的原型。它有一些操作可以用来通过关键字注册原型和解除注册。客户可以在运行时更改甚或浏览这个注册表。这使得客户无需编写代码就可以扩展并得到系统清单。

  1. 实现克隆操作

Prototype模式最困难的部分在于正确实现Clone操作。当对象结构包含循环引用时,这尤为棘手。

大多数语言都对克隆对象提供了一些支持。例如,Smalltalk提供了一个copy的实现,它被所有Object的子类所继承。C++提供了一个拷贝构造器。但这些设施并不能解决“浅拷贝和深拷贝”问题。也就是说,克隆–个对象是依次克隆它的实例变量呢,或者还是由克隆对象和原对象共享这些变量?

浅拷贝简单并且通常也足够了,它是Smalltalk所缺省提供的。C+中的缺省拷贝构造器实现按成员拷贝,这意味着在拷贝的和原来的对象之间是共享指针的。但克隆一个结构复杂的原型通常需要深拷贝,因为复制对象和原对象必须相互独立。因此你必须保证克隆对象的构件也是对原型的构件的克隆。克隆迫使你决定如果所有东西都被共享了该怎么办。

如果系统中的对象提供了Save和Load操作,那么你只需通过保存对象和立刻载人对象,就可以为Clone操作提供一个缺省实现。Save操作将该对象保存在内存缓冲区中,而Load则通过从该缓冲区中重构这个对象来创建一个复本。

  1. 初始化克隆对象

当一些客户对克隆对象已经相当满意时,另一些客户将会希望使用他们所选择的一些值来初始化该对象的一些或是所有的内部状态。一般来说不可能在Clone操作中传递这些值,因为这些值的数目由于原型的类的不同而会有所不同。一些原型可能需要多个初始化参数,另-些可能什么也不要。在Clone操作中传递参数会破坏克隆接口的统一性。

可能会这样,原型的类已经为(重)设定–些关键的状态值定义好了操作。如果这样的话,客户在克隆后马上就可以使用这些操作。否则,你就可能不得不引入一个Initialize操作(参见代码示例一节),该操作使用初始化参数并据此设定克隆对象的内部状态。注意深拷贝Clone操作——一些复制在你重新初始化它们之前可能必须要被删除掉(删除可以显式地做也可以在Initialize内部做)。

9.代码示例

我们将定义MazeFactory (3.1)的子类MazePrototypeFactory。该子类将使用它要创建的对象的原型来初始化,这样我们就不需要仅仅为了改变它所创建的墙壁或房间的类而生成子类了。

MazePrototypeFactory用一个以原型为参数的构造器来扩充MazeFactory接口:

class MazePrototypeFactory : public Mazeractory{
publie:
	MazePrototypeFactory (Maze*,wal1*,Room*,Door*);  .
	virtual Maze* MakeMazeconst;
	virtual Room* MakeRoom(int) const:virtual wall*Makewal1iconst;
	virtual Door MakeDoor (Room*, Room*) const;
private:
	Maze* _prototypeMaze;Room _prototypeRoom;wall*prototypewall;Door* prototypeDoor;
};

新的构造器只初始化它的原型:

MazePrototypeFactory::MazePrototypeFactory(Maze* m,wall* w,Room* r, Door* a){
	prototypeMaze = m;
	prototypewall = w;
	prototypeRoom = r;
	_prototypeDoor = d;
}

用于创建墙壁、房间和门的成员函数是相似的:每个都要克隆一个原型,然后初始化。下面是MakeWall和MakeDoor的定义:

wall * MazePrototypeFactory::Makewall()const (
	return _prototypewal1->Clone();
}
Door* MazePrototypeFactory::MakeDoor(Room* r1,Room *r2)const {
	Door* door = _prototypeDoor->Clone();
	door->Initialize(r1. r2):
	return door;
)

我们只需使用基本迷宫构件的原型进行初始化,就可以由MazePrototypeFactory来创建一个原型的或缺省的迷宫:

	MazeGame game;
	MazePrototypeFactory simpleMazeFactory(new Maze, new wall, new Room, new Door);
	Maze* maze = game.createMaze(simpleMazeFactory) 

为了改变迷宫的类型,我们用一个不同的原型集合来初始化MazePrototypeFactory。下面的调用用一个BombedDoor和一个RoomWithABomb创建了一个迷宫:\

MazePrototypeFactory bombedMazeFactory(new Maze, new Bombeawall,new RoolmiEhABomb,new Door);

一个可以被用作原型的对象,例如Wall的实例,必须支持Clone操作。它还必须有一个拷贝构造器用于克隆。它可能还需要一个独立的操作来重新初始化内部状态。我们将给Door增加Initialize操作以允许客户初始化克隆对象的房间。

将下面Door的定义与第3章的进行比较:

class Door : public Mapsite{
public:
	Door();
	Door(const Door&);
	virtual void Initialize(Room*,Room*);
	virtual Door* Clone()const;
	virtual void Enter();
	Room->othersideFrom(Room*);
private:
	Room _room1;
	Room _room2;
};

Door::Door (const Door& other) {
	_room1 = other.room1;
	_room2 = other.room2;
}

void Door::Initialize(Room* r1,Room* r2){
	_room1 = r1;
	_room2 = r2;
}
Door* Door::Clone() const {
	return new Door(*this);
}

BombedWall子类必须重定义Clone并实现相应的拷贝构造器。

class Bombedwall : public Wall{
public:
	Bombedwall();
	BombedWall(const Bombedwall&);
	virtual Wall* clone() conat ;
	bool HasBonb();
private:
	bool _bomb;
};
Bombedwall::Bombedwall(const Bombedwall& other)::wall(other){
	_bomb = other ._bomb;
}
Wall* Bombedwall::clone()const {
	return new Bombeawall(*this);
}

虽然BombedWall::Clone返回一个WalI*,但它的实现返回了一个指向子类的新实例的指针,即BombedWall*。我们在基类中这样定义Clone是为了保证克隆原型的客户不需要知道具体的子类。

客户决不需要将Clone的返回值向下类型转换为所需类型。

10.相关模式

正如我们在这一章结尾所讨论的那样,Prototype和Abstract Factory (3.1)模式在某种方面是相互竞争的。但是它们也可以一起使用。

Abstract Factory可以存储一个被克隆的原型的集合,并且返回产品对象。

大量使用Composite (4.3)和Decorator (4.4)模式的设计通常也可从Prototype模式处获益。

3.5 SINGLETON(单件)

1.意图

保证一个类仅有一个实例,并提供一个访问它的全局访问点。

2.动机

对一些类来说,只有一个实例是很重要的。虽然系统中可以有许多打印机,但却只应该有一个打印假脱机( printer spooler ),只应该有一个文件系统和一个窗口管理器。一个数字滤波器只能有一个A/D转换器。一个会计系统只能专用于一个公司。

我们怎么样才能保证一个类只有一个实例并且这个实例易于被访问呢?一个全局变量使得一个对象可以被访问,但它不能防止你实例化多个对象。

一个更好的办法是,让类自身负责保存它的唯一实例。这个类可以保证没有其他实例可以被创建(通过截取创建新对象的请求),并且它可以提供一个访问该实例的方法。这就是Singleton模式。

3.适用性

在下面的情况下可以使用Singleton模式

  • 当类只能有一个实例而且客户可以从一个众所周知的访问点访问它时。
  • 当这个唯一实例应该是通过子类化可扩展的,并且客户应该无需更改代码就能使用一个扩展的实例时。

4.结构

在这里插入图片描述

5.参与者

  • Singleton
    • 定义一个Instance操作,允许客户访问它的唯一实例。Instance是一个类操作(即
      Smalltalk中的一个类方法和C++中的一个静态成员函数)。
    • 可能负责创建它自己的唯一实例。

6.协作

·客户只能通过Singleton的Instance操作访间一个Singleton的实例。

7.效果

Singleton模式有许多优点:

  1. 对唯一实例的受控访问

因为Singleton类封装它的唯一实例,所以它可以严格的控制客户怎样以及何时访问它。

  1. 缩小名空间

Singleton模式是对全局变量的一种改进。它避免了那些存储唯一实例的全局变量污染名空间。

  1. 允许对操作和表示的精化

Singleton类可以有子类,而且用这个扩展类的实例来配置一个应用是很容易的。你可以用你所需要的类的实例在运行时刻配置应用。

  1. 允许可变数目的实例

这个模式使得你易于改变你的想法,并允许Singleton类的多个实例。此外,你可以用相同的方法来控制应用所使用的实例的数目。只有允许访问Singleton实例的操作需要改变。

  1. 比类操作更灵活

另一种封装单件功能的方式是使用类操作(即C++中的静态成员函数或者是Smalltalk中的类方法)。但这两种语言技术都难以改变设计以允许–个类有多个实例。此外,C++中的静态成员函数不是虚函数,因此子类不能多态的重定义它们。

8.实现

下面是使用Singleton模式时所要考虑的实现问题:

  1. 保证一个唯一的实例

Singleton模式使得这个唯一实例是类的一般实例,但该类被写成只有一个实例能被创建。做到这一点的一个常用方法是将创建这个实例的操作隐藏在一个类操作(即一-个静态成员函数或者是一个类方法)后面,由它保证只有一个实例被创建。这个操作可以访问保存唯一实例的变量,而且它可以保证这个变量在返回值之前用这个唯一实例初始化。这种方法保证了单件在它的首次使用前被创建和使用。

在C++中你可以用Singleton类的静态成员函数Instance来定义这个类操作。Singleton还定义了一个静态成员变量_instance,它包含了一个指向它的唯一实例的指针。

singleton类定义如下

class singleton {
public:
	static singleton* Instance();
protected:
	singleton();
private:
	static singleton* _instance;
};


singleton singleton::_instance = 0 ;

singleton* singleton::Instance() {
	if ( _instance == 0){
		_instance = new singleton;
	}
	return .instance;
}

客户仅通过Instance成员函数访问这个单件。变量_instance初始化为0,而静态成员函数Instance返回该变量值,如果其值为O则用唯一实例初始化它。

Instance使用惰性(lazy )初始化;它的返回值直到被第一次访问时才创建和保存。

注意构造器是保护型的。试图直接实例化Singleton的客户将得到一个编译时的错误信息。这就保证了仅有一个实例可以被创建。

此外,因为_instance是一个指向Singleton对象的指针,Instance成员函数可以将一个指向Singleton的子类的指针赋给这个变量。我们将在代码示例一节给出一个这样的例子。

关于C++的实现还有一点需要注意。将单件定义为一个全局或静态的对象,然后依赖于自动的初始化,这是不够的。有如下三个原因:

  1. 我们不能保证静态对象只有一个实例会被声明。
  2. 我们可能没有足够的信息在静态初始化时实例化每一个单件。单件可能需要在程序执行中稍后被计算出来的值。
  3. C++没有定义转换单元(translation unit)上全局对象的构造器的调用顺序。这就意味着单件之间不存在依赖关系;如果有,那么错误将是不可避免的。

使用全局/静态对象的实现方法还有另一个(尽管很小)缺点,它使得所有单件无论用到与否都要被创建。使用静态成员函数避免了所有这些问题。

  1. 创建Singleton类的子类

主要问题与其说是定义子类不如说是建立它的唯一实例,这样客户就可以使用它。事实上,指向单件实例的变量必须用子类的实例进行初始化。最简单的技术是在Singleton的Instance操作中决定你想使用的是哪一个单件。代码示例一节中的一个例子说明了如何用环境变量实现这一技术。

另一个选择Singleton的子类的方法是将Instance的实现从父类(即MazeFactory)中分离出来并将它放入子类。这就允许C++程序员在链接时刻决定单件的类(即通过链入一个包含不同实现的对象文件),但对单件的客户则隐蔽这一点。

链接的方法在链接时刻确定了单件类的选择,这使得难以在运行时刻选择单件类。使用条件语句来决定子类更加灵活一些,但这硬性限定( hard-wire)了可能的Singleton类的集合。这两种方法不是在所有的情况都足够灵活的。

一个更灵活的方法是使用一个单件注册表( registry of singleton )。可能的Singleton类的集合不是由Instance定义的,Singleton类可以根据名字在一个众所周知的注册表中注册它们的单件实例。

这个注册表在字符串名字和单件之间建立映射。当Instance需要一个单件时,它参考注册表,根据名字请求单件。

注册表查询相应的单件(如果存在的话)并返回它。这个方法使得Instance不再需要知道所有可能的Singleton类或实例。它所需要的只是所有Singleton类的一个公共的接口,该接口包括了对注册表的操作:

class singleton{
public:
	static void Register (const char* name,singleton*) ;
	static singleton* Instance():
protected:
	static singleton*Lookup (const char*name) ;private:
	static singleton* _instance;
	static List<NamesingletonPair>* _registry;
};

Register以给定的名字注册Singleton实例。为保证注册表简单,我们将让它存储一列NameSingletonPair对象。每个NameSingletonPair将一个名字映射到一个单件。Lookup操作根据给定单件的名字进行查找。我们假定一个环境变量指定了所需要的单件的名字。

singleton* singleton::Instance(){
	if(_instance == 0){
	const char* singletonName = getenv ( "SINGLETON") ;
	// user or environment. supplies this at startup
	_instance = Lookup (singletonName) ;
	// Lookup returns 0 if there's no such singleton
	return _instance;
}

Singleton类在何处注册它们自己?一种可能是在它们的构造器中。例如,MySingleton子类可以像下面这样做:

Mysingleton::Mysingleton() {
 	// ...
	singleton::Register("Mysingleton", this);
}

当然,除非实例化类否则这个构造器不会被调用,这正反映了Singleton模式试图解决的问题!在C++中我们可以定义MySingleton的一个静态实例来避免这个问题。例如,我们可以在包含MySingleton实现的文件中定义:

static Mysingleton thesingleton ;

Singleton类不再负责创建单件。它的主要职责是使得供选择的单件对象在系统中可以被访问。

静态对象方法还是有一个潜在的缺点——也就是所有可能的Singleton子类的实例都必须被创建,否则它们不会被注册。

9.代码示例

假定我们定义一个MazeFactory类用于建造在第3章所描述的迷宫。

MazeFactory定义了一个建造迷宫的不同部件的接口。子类可以重定义这些操作以返回特定产品类的实例,如用BombedWall对象代替普通的Wall对象。

此处相关的问题是Maze应用仅需迷宫工厂的一个实例,且这个实例对建造迷宫任何部件的代码都是可用的。这样就引入了Singleton模式。将MazeFactory作为单件,我们无需借助全局变量就可使迷宫对象具有全局可访问性。

为简单起见,我们假定不会生成MazeFactory的子类。(我们随后将考虑另一个选择。)我们通过增加静态的Instance操作和静态的用以保存唯一实例的成员_instance,从而在C++中生成一个Singleton类。我们还必须保护构造器以防止意外的实例化,因为意外的实例化可能会导致多个实例。

class MazeFactory{
public:
	static MazeFactory* Instance();
	// existing interface goes here
protected:
	MazeFactory();
private:
	static MazeFactory*_instance;

相应的实现是:

Mazeractory* MazeFactory::_instance = 0 ;

MazeFactory* MazeFactory::Instance{
	if(_instance == 0){
		_instance = new MazeFactory;
	}
	return _instance;
}

现在让我们考虑当存在MazeFactory的多个子类,而且应用必须决定使用哪一个子类时的情况。

我们将通过环境变量选择迷宫的种类并根据该环境变量的值增加代码用于实例化适当的MazeFactory子类。

Instance操作是增加这些代码的好地方,因为它已经实例化了MazeFactory :

MazeFactory MazeFactory : : Instance ) {
	if(_instance == 0){
		const char* mazestyle = getenv ( "MAZESTYLE");
	}
	if(strcmp(mazesty1e,"bombed" y -= 0)){
		_instance = new BombedMazeFactory;
	}else if (strcmp ( mazestyle,"enchanted" ) == 0){
		_instance = new EnchantedMazeFactory ;
	}
		// ... other possible subclasses	
	} 
	else{	//default
		_instance = new MazeFactory;
	}
	return _instance;
}

注意,无论何时定义一个新的MazeFactory的子类,Instance都必须被修改。

在这个应用中这可能没什么关系,但对于定义在一个框架中的抽象工厂来说,这可能是一个问题。

一个可能的解决办法将是使用在实现一节中所描述过的注册表的方法。此处动态链接可能也很有用—一一它使得应用不需要装载那些用不着的子类。

10.相关模式

很多模式可以使用Singleton模式实现。参见Abstract Factory (3.1)、Builder (3.2),和Prototype (3.4)。

资料

[1]《设计模式:可复用面向对象软件的基础》(美) Erich Gamma Richard Helm、Ralph Johnson John Vlissides 著 ; 李英军、马晓星、蔡敏、刘建中 等译; 吕建 审校

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值