C++/CLI

C++/CLI
2010年08月12日
  从2001年.net平台开始发布到现在,C++/CLI已经存在了好几年。以前叫mc++,现在改名为C++/CLI,有些人认为C++/CLI是一种新语言,有的只是认为是C++的一个扩展。C++/CLI是完全基于.NET平台之上。C++/CLI的出现给C++项目开发带来了不少的变化。可惜由于.NET平台目前还没有完全覆盖windows的全部操作系统,C++/CLI也只能用于服务器程序的开发。用来编写客户端程序,分发成本实在不低。
  光从用途来看C++/CLI是.NET平台和iso c++之间的一个过渡桥梁,一个 ISO c++项目走向.net项目的一个单向桥梁。抛开各种政治上的,信仰上的考虑,.NET平台的确带来了不少吸人眼球的技术。在尝试着开发了几个c#1.1项目之后,由于c#1.1项目开发过于死板。于是一直等待到.NET 2.0发布,.NET平台开始进入成熟时期,我开始尝试着在近期的C++项目中引入.NET技术。.NET2.0以前的版本和.NET 2.0之间差异很大,这样的开发方式最好在vc2005上尝试。vc 2003,vc 2002和vc.net 上都是存在一定缺陷的,而且相互之间的代码也不能完全兼容。
  try-catch-exception,ado.net是羡慕已久的2项.NET技术了。终于可以在项目中尝试一把了。
  ISO C++自身也带有try,catch关键字。无论从使用角度还是实际效果方面都没有.NET的try-catch爽。.NET 的try-catch关键字有.NET强力支持,在查找bug和预防程序崩溃方面异常有用。基本上能避免应用程序完全崩溃。
  ado.net和其扩展Microsoft.Practices.EnterpriseLibrary.Data在操作database方面异常的方便。比起C++需要使用各种api或者ado来操作数据库,完全是2个世界的。
  最终完成的项目,99%的代码是ISO C++编写,数据库操作代码和部分try-catch用C++/CLI。C++/CLI的语法以及常用操作符号和ISO C++差异很大,很容易就能分辨出哪些是ISO C++代码,那些C++/CLI代码。在维护上也没有太多问题。不过实际开发过程中,如果不做控制,C++/CLI的代码最终会“蔓延”开来,越来越多。
  最后编译下来,部分ISO C++、C++/CLI代码编译成了.net的中间代码。部分ISO C++代码则还是二进制代码。完全是一个"怪物"应用程序。vc 2005的cl的确有些夸张,这样都能够编译。ISO C++和C++/CLI在某些角度来看是完全两种语言。cl居然能够分辨出来做出不同的编译.
  实际运行下来,这样的开发方式完全是可以接受的。数据库操作部分的代码不在有以前使用ado时候那种拖沓、难看的代码了。.NET版本try-catch的引入,使得程序稳定性增强不少。
  附一段C++/CLI的操作数据库代码
  C++/cli代码
  
  
  try { Database^ db = nullptr; String^ sqlCommand = nullptr; DbCommand^ dbCommand = nullptr; IDataReader^ dataReader = nullptr; db = DatabaseFactory::CreateDatabase(); String ^ temp = nullptr; sqlCommand = String::Format("select * from table where ID = {0} and type=1 ",ID); dbCommand = db->GetSqlStringCommand(sqlCommand); dataReader = db->ExecuteReader(dbCommand); if (dataReader->Read()) { temp= (String^)dataReader[0]; } } catch(Exception^ ex) { Console::Write(ex->ToString()); } try{Database^ db = nullptr;String^ sqlCommand = nullptr;DbCommand^ dbCommand = nullptr;IDataReader^ dataReader = nullptr;db = DatabaseFactory::CreateDatabase();String ^ temp = nullptr;sqlCommand = String::Format("select * from table where ID = {0} and type=1 ",ID);dbCommand = db->GetSqlStringCommand(sqlCommand);dataReader = db->ExecuteReader(dbCommand);if (dataReader->Read()){temp= (String^)dataReader[0];}}catch(Exception^ ex){Console::Write(ex->ToString());}
  命令行界面(Command Line Interface,简写:CLI)是在图形用户界面得到普及之前使用最为广泛的用户界面,它通常不支持鼠标,用户通过键盘输入指令,计算机接收到指令后,予以执行。也有人称之为字符用户界面(CUI)。C /CLI支持对本地ISO C 编程和.NET托管编程的无缝集成,而且可以实现类型class级和ISO C 和.NET类库的相互使用,更强的是能够实现类型的混合。
  [b]CLI CLI(Common Language Infrastructure,通用语言框架)
  [/b] 提供了一套可执行代码和它所运行需要的虚拟执行环境的规范。更通俗的我们可以说它是一个虚拟平台,是操作系统和应用程序间的一层抽象。
  微软的.NET的基础CLR(Common Language Runtime,通用语言运行时)就是CLI的一个实例。CLI主要的组成部分有:CTS,Metadata,CLS和VES。
  “CLI”即公共语言基础结构(Common Language Infrastructure),这是个支持动态组件编程模型的多层架构。在许多方面,他所表示的对象模型和C 的完全相反。他是个运行时软件层 ,一个虚拟执行系统,运行在应用程式和底层操作系统之间。对底层机器的访问受到相当严格的限制。支持对运行中程式的活动类型连同关联程式的基础构造 进行存取――发现和建构。斜线“/”表示 C 和 CLI 之间的一种绑定(binding),有关这种绑定的细节构成本专栏的常规主题。
  他是静态C 对象模型到动态CLI组件对象模型的一种绑定, 简言之,他就是您如何用C 进行.NET编程,而不是用C#或Visual Basic.NET。象C#和CLI自己相同,C /CLI正在经历 ECMA(欧洲电脑制造商协会) 标准化并最终要经历ISO标准认证。
  公共语言运行时(CLR)是微软版的CLI,专门用于 Windows 操作系统,同样,Visual C 2005是C /CLI 的实现。
   第二个近似答案是:我觉得C /CLI在C 内集成.NET编程模型和以前贝尔实验室在当时的C 中用模板集成泛型编程相同有异曲同工之处。两种情况中,您在现有C 代码库上的投资连同您现有的C 专业技术都得到保护。这是C /CLI设计的一个基本 需要。
  一种C /CLI语言的设计有三个层面,这三个层面也适用于任何语言:语言层语法到公共类型系统(CTS) 的映射;选择为程式员直接操作而公开的底层CLI基本组织结构 的周详程度;连同选择要提供的超越CLI直接支持的附加功能。
  第一个层面是任何CLI语言在很大程度上都共有的,第二个层面和第三层面是某一CLI语言区别于其他语言的地方。根据所要解决的问题,您能够选择某一 种语言,也能够将多种CLI语言结合起来。学习C /CLI语言需要掌控这三个设计层面。
   了解底层CTS 对学习C /CLI很有帮助,他主要包括三个常规类类型:
  多态引用类型,其用于任何的类继承;
    非多态值类型,其用于实现需要运行时效率的具体类型,如数字类型; 抽象接口类型,其用于定义一个实现该接口的一组引用类型或值类型一起使用的公共操作集;
  在设计方面,虽然CTS到一组内置的语言类型的映射对于任何CLI语言来说都是一起的,当然,每一种CLI语言的语法各不相同。例如,在C#中,我们能够 这样来定义一个抽象基类 Shape,从这个类派生特定的几何模型对象。
    而在C /CLI中,能够象下面这样写,以表示完全相同的底层引用类型:
  ref class Shape abstract { ... }; // C /CLI
  在底层 IL(中间语言)中,以上两种声明以完全相同的方式表示。同样,在C#中,我们能够用下面的代码来定义一个具体的 Point2D 类 :在C /CLI中写成:
    借助 C /CLI 支持的类类型家族表现了一种本机方式的 CTS 集成。他确定了您的语法选择,例如:
    CTS 也支持枚举类类型,其行为方式和本机枚举稍微有些区别,C /CLI对二者都提供支持:
    同样,CTS支持其自己的数组类型,其行为也和本机数组类型有一定差别,微软同样对二者提供支持:
  int native[] = { 1,1,2,3,5,8 };
  array<int>^ managed = { 1,1,2,3,5,8 };
  那种认为任何一种 CLI 语言比另一种语言更接近或几乎就是到底层CLI的映射是不精确的。相反,每一种CLI语言都只是表达了自己对底层CLI对象模型的一种 见解。在下一节您将更清楚地看到这一点。
   [b] CLI 的细节标准[/b]
    在设计CLI语言时必须考虑的第二个设计层面是要将什么程度的底层CLI实现模型结合到该语言中。这个语言解决什么样的问题?要解决这些问题必须要什么样的工具? 此外,该语言很可能吸引哪一类程式员?
  下面,我们利用发生在托管堆中的值类型问题。在许多情况下,值类型能够在托管堆中找到自己:
    通过隐式的框入/框出操作(boxing)――当值类型的某个实例被赋值给一对象时,或通过某个未被改写的值类型调用一个虚拟方法时;
    当值类型被当作为引用类类型的成员时;
    当值类型被当作CLI数组元素存储时;
  是否允许程式员处理这种值类型地址是设计CLI语言时必须要解决的问题。
  面对C /CLI,很多人的第一个问题自然是“什么是C /CLI”
  ,我个人喜欢将其看作是位于静态程序设计和动态程序设计
  之间的一座桥梁。C /CLI这个名称本身就包含着一组术语―
  ―而其中最重要的术语却是最不明显的那一个。
  C /CLI将动态的、基于组件的编程模型和ISO-C 集成在了一
  起,这种集成非常类似于我们当年在Bell实验室对使用模板
  的泛型编程和当时的C 所做的集成。在两种情况下,你已有
  的代码投资和编码经验都将得到保留。这是我们设计C /CLI
  时一个基本的需求。
  浅议C /CLI的gcnew关键字C /CLI中使用gcnew关键字表示在托管堆上分配内存,并且为了和以前的指针区分,用^来替换* ,就语义上来说他们的区别大致如下:
  1. gcnew返回的是个句柄(Handle),而new返回的是实际的内存地址.
  2. gcnew创建的对象由虚拟机托管,而new创建的对象必须自己来管理和释放.
    当然,从程式员的角度来说,管他是句柄还是什么其他的东西,总跑不掉是对某块内存地址的引用,实际上我们都能够理解成指针.下面我们就写一段代码来测试一下好了.
  using namespace System;
  ref class Foo
  {
  public:
  Foo()
  {
  System::Console::WriteLine("Foo::Foo");
  }
  ~Foo()
  {
  System::Console::WriteLine("Foo::~Foo");
  }
  public:
  int m_iValue;
  };
  int _tmain()
  {
  int* pInt = new int;
  int^ rInt = gcnew int;
  Foo^ rFoo = gcnew Foo;
  delete rFoo;
  delete rInt;
  delete pInt;
  }
  特别注意:本站所有转载文章言论不代表本站观点,本站所提供的摄影照片,插画,设计作品,如需使用,请与原作者联系 我把调试的时候JIT编译的汇编代码择录了部分如下显示(请注意红色部分):
  int* pInt = new int;
  0000004c mov ecx,4
  00000051 call dword ptr ds:[03B51554h]
  00000057 mov esi,eax
  00000059 mov dword ptr [esp 18h],esi
  int^ rInt = gcnew int;
  0000005d mov ecx,788EF9D8h
  00000062 call FCFAF66C
  00000067 mov esi,eax
  00000069 mov dword ptr [esi 4],0
  00000070 mov edi,esi
  Foo^ rFoo = gcnew Foo;
  00000072 mov ecx,3B51768h
  00000077 call FCFAF66C
  0000007c mov esi,eax
  0000007e mov ecx,esi
  00000080 call dword ptr ds:[03B517ACh]
  00000086 mov dword ptr [esp 1Ch],esi
  delete rFoo;
  0000008a mov ebx,dword ptr [esp 1Ch]
  0000008e test ebx,ebx
  00000090 je 000000A4
  00000092 mov ecx,ebx
  00000094 call dword ptr ds:[03FD0028h]
  0000009a mov dword ptr [esp 14h],0
  000000a2 jmp 000000AC
  000000a4 mov dword ptr [esp 14h],0
  delete rInt;
  000000ac mov edx,edi
  000000ae mov ecx,788F747Ch
  000000b3 call FC8D20FD
  000000b8 mov ebp,eax
  000000ba test ebp,ebp
  000000bc je 000000D0
  000000be mov ecx,ebp
  000000c0 call dword ptr ds:[03FD0020h]
  000000c6 mov dword ptr [esp 10h],0
  000000ce jmp 000000D8
  000000d0 mov dword ptr [esp 10h],0
  delete pInt;
  000000d8 mov ecx,dword ptr [esp 18h]
  000000dc call dword ptr ds:[03B51540h]
    我们先看分配内存这部分的代码
    1.调用new方式分配
  int* pInt = new int;
  0000004c mov ecx,4
  00000051 call dword ptr ds:[03B51554h]
  。 能够看到,和以前在vc6中相同,分配内存的步骤如下:
  1. 首先把sizeof(int) = 4 放到ecx中  2. 调用operator new 去分配4个字节
    3. 调用构造函数等等......(这里不是我们的重点)
  成功分配后,会把返回地址放在eax中。
    2.调用gcnew方式分配
  int^ rInt = gcnew int;
  0000005d mov ecx,788EF9D8h
  00000062 call FCFAF66C
  。。。
  Foo^ rFoo = gcnew Foo;
  00000072 mov ecx,3B51768h
  00000077 call FCFAF66C
  对真正的成功者来说,不论他的生存条件如何,都不会自我磨灭
    能够看到gcnew也是通过把一个参数放到ecx中,然后再调用一个函数来完成分配的操作,显然0x788EF9D8应该是个地址,而不可能是个数值。我们能够看到这里gcnew创建两个不同类型的变量,调用的函数地址却都是0xFCFAF66C,而存放到ecx中的两个地址就不相同。究竟这几个地址代表什么呢?
    和new相同gcnew也是把返回地址放在eax中。我们直接从内存窗口看eax指向的内存块好了。Aha,看到了没有?
    这次的eax = 0x00F73404 对应的内存块为
  0x00F73404 d8 f9 8e 78 00 00 00 00 。。。
  .
    这个不就是 mov 到 ecx中的值么?再回忆昨天写的分析Object对象布局的文章,能够肯定这个就是 MethodTable地址了,对于这个int来说,后面的4个字节对应的就是存放他的RawData,比如假如您初始化为 4 那么内存对应的就变化为 d8 f9 8e 79 04 00 00 00
    分析清楚存放到ecx中的是 MethodTable指针,我们再分析那个对应的call函数,从vm的代码能够看出,有三个全局函数用来根据MethodTable创建对象,同时MethodTable本身也提供一个成员函数Allocate(),只但是这个成员函数也是调用的下面的函数:
  OBJECTREF AllocateObject( MethodTable *pMT )
  OBJECTREF AllocateObjectSpecial( MethodTable *pMT )
  OBJECTREF FastAllocateObject( MethodTable *pMT )
    其中AllocateObject又是调用AllocateObjectSpecial来完成工作。那么我们调用的应该就是AllocateObject或FastAllocateObject了。
    在我们的例子里面两个call的地址都相同,但是您假如写下代码 double ^ pDouble = gcnew double;这个时候的地址是多少?他和int 的相同么?
  ..
    现在我还没有仔细去研究这个地址到底对应的是该类型的MethodTable::Allocate()或是上面的这三个全局函数,假如对应MethodTable::Allocate(),那么2.0中应该有个MethodTable::FastAllocate()吧,否则应该就是对应的全局函数AllocateObject 连同FastAllocateObject了。过几天一定要抽空再好好研究一下。
    下面看对应的delete函数。
  delete pInt;
  000000d8 mov ecx,dword ptr [esp 18h]
  000000dc call dword ptr ds:[03B51540h]
  比较简单,就是传入地址,然后调用operator delete来释放类存,会调用析构函数
  本站所提供的摄影照片,插画,设计作品,如需使用,请与原作者联系。
    对应的,释放gcnew创建的对象的代码如下:
  delete rInt;
  000000ac mov edx,edi
  000000ae mov ecx,788F747Ch
  000000b3 call FC8D20FD
  。
    这个也相对简单,他对应vm里面的一个函数:
  void CallFinalizer(Thread* FinalizerThread, Object* fobj)
    那么也就是
  fobjà edx
  FinalizerThread à ecx
  Call CallFinalizer
    但是,请注意!!!!!!!一个类包含析构函数和不包含析构函数,他对应的delete代码是不相同的,这点能够通过汇编代码比较得到,我这里就不多说了。
  1. gcnew返回的是个句柄(Handle),而new返回的是实际的内存地址.
    2. gcnew创建的对象由虚拟机托管,而new创建的对象必须自己来管理和释放.
    当然,从程式员的角度来说,管他是句柄还是什么其他的东西,总跑不掉是对某块内存地址的引用,实际上我们都能够理解成指针.下面我们就写一段代码来测试一下好了.
  using namespace System;
  ref class Foo
  {
  public:
  Foo()
  {
  System::Console::WriteLine("Foo::Foo");
  }
  ~Foo()
  {
  System::Console::WriteLine("Foo::~Foo");
  }
  public:
  int m_iValue;
  };
  int _tmain()
  {
  int* pInt = new int;
  int^ rInt = gcnew int;
  Foo^ rFoo = gcnew Foo;
  delete rFoo;
  delete rInt;
  delete pInt;
  }
    本文展示了欧洲计算机开发商协会正在发展的C++/CLI(一种不同的C++语言,它方便开发人员在微软的.NET框架下更容易地开发程序)语言在C++语言上的扩展。写这篇文章的目的并不是要建议标准C++包括这部分扩展,也不是对C++/CLI的认可,而只是在探讨C++/CLI语言在这一领域的发展方向。
    [b]一、基础知识
  [/b]
    C++/CLI中的属性是类似与各种数据成员(有各种操作限制)的可操作实体,但是这种操作往往被转化为调用存取函数(这主要是"getter"和"setter"函数)。例如:
  struct Demo1 {
   property int Val { // 一个非常简单的整型、分级属性。
    int get() const {
     ++Demo1::access_count;
     return this->value;
   }
   void set(int v) {
    ++Demo1::access_count;
    this->value = v;
   }
  }
  private:
   int value;
   static unsigned long access_count;
  };
  int main() {
   Demo1 d;
   d.Val = 3; // 调用"set"操作函数。
   return d.Val; //调用"get"函数。
  }
    存取函数的名字必须是get 或者是 set函数,两者之中的任何一个都可以被省略,但绝不能两者全省略。省略一个存取函数导致只存在一个读属性或只存在一个写属性。属性的地址是无法获取的,然而,存取函数作 为成员函数理所当然地可以被用来产生指向成员的指针常量(例如:&Demo1::Val::set)。
    属性可以使用关键字"virtual"进行声明,这意味者存取操作函数是虚函数,纯虚属性函数也是可能存在的,例如:
  struct VirtualProp {
   virtual property int Val = 0 {
    int get() const; // 纯虚函数.
    virtual void set(int v); //纯虚函数,这里关键词"virtual"是多余的。
   }
   // ...
  };
    上述例子显示了通常情况下遇到的一些简单的、非静态的、分层次的属性实例。C++/CLI文档包含了大量的概念变化,下文将进行解释。
    [b]二、动机
  [/b]
    在标准C++的上下文中,属性约定成俗地使用"get和set函数"文法,这种文法将暴露的数据和谐地转换为封闭地状态信息。在更精细的实时框架上下文中(具体的说是微软的.NET框架),属性是可以通过映射实时发觉和修改的元素。例如,现代的GUI库将它的组件参数声明为属性,可视化的界面构筑工具装载这些库,使用装载各种组件的属性列表并将结果展现到用户面前,当用户修改了一个属性,存取操作函数将被调用,例如这将触发各种GUI更新事件。
  [b]三、属性变量
  [/b]  除了上述代码中声明的简单的分层属性,C++/CLI还引进了其他几种类型属性变量。
    (一)静态分层属性
    静态分层属性使用关键字"Static"来声明,它们的存取操作函数是静态的,静态属性的存取操作与静态数据成员的存取操作非常一致。(例如:使用C::P语法来获取C类的静态属性P)
    (二)不明显的分层属性
    一个属性的定义(即括号内的存取操作函数声明)可以使用分号";"来代替,在这种情况下,get和set存取函数综合成一个简单的可以存取操作的属性值。例如,C++/CLI定义的一个类如下:
  struct TrivialProp {
   property int Val;
  };
    上述代码从本质上与下述代码相同:
  struct TrivialProp {
   property int Val {
    int get() const { return this->__Val; }
    void set(int v) { this->__Val = v; }
   }
   private:
    int __Val;
  };
    (三)指定索引属性
    使用操作数组成员的老语法,指定索引可以操作一个数值集合,下面的例子显示了一维索引属性的操作。
  struct Demo2 {
   property int x[std::string] {
    int get(std::string s) const { ... }
    void set(int v, std::string s) { ... }
   }
   // ...
  };
  int main() {
   Demo2 d;
   std::string s("CLI");
   d.x[s] = 3; // Calls Demo2::x::set(3, s)
   return d.x[s]; // Calls Demo2::x::get(s)
  }
    注意,指定索引的属性不能是静态变量。
    多维的索引属性也是可以的,它引入的操作语法与C/C++中数组元素操作方法不太一样,例如:
  struct Demo3 {
   property double x[std::string, int] {
    double get(std::string s, int n) const { ... }
    void set(double v, std::string s, int n) { ... }
   }
   // ...
  };
  int main() {
   Demo3 d;
   std::string s("CLI");
   d.x[s, 7] = 42.0; // Calls Demo3::x::set(42.0, s, 7)
   return d.x[s, 7] != 42.0; // Calls Demo3::x::get(s, 7)
  }
    后面的这一个例子说明了出现在括号内的操作索引属性的逗号符号是表达式操作符号,而不是一个逗号操作符。(下面将讨论这种规则带来的后果)。
    (四)默认的索引属性
    除了对象被编入伪域外,默认的索引属性与指定的索引属性非常相象,对象本身可以索引(仿佛它自身有一个[]操作成员函数一样),以前的代码只要稍微改动一下就可以说明这种变化。
  struct Demo4 {
   property double default[std::string, int] {
    double get(std::string s, int n) const { ... }
    void set(double v, std::string s, int n) { ... }
   }
   // ...
  };
  int main() {
   Demo4 d;
   std::string s("CLI");
   d[s, 7] = 42.0; // Calls Demo4::default::set(42.0, s, 7)
   return d[s, 7] != 42.0; // Calls Demo4::default::get(s, 7)
  }
    请关注关键词"default"代替属性名的用法。
    [b]四、一些技术性问题[/b]
    欧洲计算机制造商协会(C++/CLI标准的制订者)已经研究并解决了引入属性所带来的若干问题,下面这些内容尤其值得关注。
    (一)多维索引属性的操作
    p->x[2, 3]表达式拥有不同的意思,这要视成员x是否是属性(这种情况下逗号分隔两个索引属性)或其它成员变量(这种情况下逗号是个操作符号,表达式的意思等同于p->x[3])而定。为了在一个属性索引中获取逗号操作符的效果,开发人员可以使用圆括号(即p->x[(2, 3)])。
    (注意,在依赖模版的表达式中,这将产生模糊性,并且直到实例化时问题才能得到解决)
    (二)属性名与类型名冲突
    微软.NET框架带有很多包含属性的类(这些类最初并不是使用C++/CLI来开发的),这些包含的属性名与属性类型的名字相同,例如:
  typedef int Color;
  struct Conflict {
   property Color Color { // Property name hides type name
    typename Color get() const;
    void set(typename Color);
   }
   // ...
  };
  }
    为了帮助在这种上下文中书写代码,C++/CLI计划添加语法,使用关键词typename来标识不标准的类型(特别是"属性"),查找标志符的过程中将被忽视。上述的代码就以这种新的形式使用typename关键词。
    (三)重载的索引属性
    索引属性可以被重载,即,几个指定索引属性可以使用同一个名字共存于同一个类中,假定它们可以根据属性的类型来区分开来。相似地,默认的索引属性可以使用其他属性或操作符[]来重载。解决两意性与重载行为的规则已经被建立起来,来处理上述情况。
    (四)保留的成员名字
    C++/CLI属性通过综合特定的成员来实现,这些成员的名义由微软的.NET框架来规定,并且必须得到保留。
    如果一个类包含分层的属性或指定索引属性X,成员名 get_X 和set_X在类中得到保留(即使属性仅仅包含一个操作函数也是这样)。相似地,如果一个类包含有一个默认的索引属性,类中的成员函数get_Item 和set_Item也将得到保留。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值