现代编程语言: Kotlin 之美 - 当下最火的编程语言欣赏

Kotlin 作为后起之秀,站在巨人们的肩膀上是她得天独厚的优势,而这个巨人也包括—《Effective Java》(EJ),得益于这个巨人,Kotlin 到处散发着高效的味道,这篇文章让我们一起来领略下 Kotlin 的高效之道。

EJ 第1条:考虑使用静态工厂方法代替构造器

在实例化对象的方式中,使用静态工厂方法相比构造器有几个好处:

  1. 工厂方法拥有名字,易于开发者理解。

  2. 不必在每次调用的时候都创建一个新对象,比如可以事先缓存好实例。

  3. 可以返回原类型的任何子类型。

Kotlin 并没有 static 关键字,也没有静态成员的概念,取而代之的是『伴生对象』,因此,对于第一条准则,Kotlin 使用伴生对象关键字 companion 来定义静态工厂方法,代码风格如下:

class User private constructor(val account:String){

    companion object {

        fun newWeiboUser(email:String):User{
            return User(email)
        }

        fun newTelUser(tel:Long):User{
            return User(tel.toString())
        }
    }

}

调用方式类似 Java 中的静态方法:

val newTelUser = User.newTelUser(18888888888)
val weiBoUser = User.newWeiboUser("geniusmart")

EJ 第3条:用私有构造器或者枚举类型强化Singleton属性

对于开发者而言,单例模式是最耳熟能详的设计模式,正如这第3条准则所述,单例模式有懒汉式、饿汉式、枚举等多种写法,其中前两者我们必须用私有构造器来禁止在单例之外的实例化。

Kotlin 对单例模式做了更彻底的精简,简直易如反掌,可以通过 object 关键字声明一个单例类的同时创建一个实例,如:

object singleton{//由于同时创建了实例,因此类名使用小写
    fun action(){
        println(this.hashCode())
    }
}

简单验证如下:

@Test
fun test(){
     val instance1 = singleton
     val instance2 = singleton
     assertEquals(instance1,instance2)
}

如果将 object singleton 转换成 Java,代码如下,大家可以感受下如何在声明一个单例类的同时创建一个实例:

public final class singleton {
   //在Java中使用singleton.INSTANCE来访问单例
   public static final singleton INSTANCE;
   private singleton() {
      INSTANCE = (singleton)this;
   }

   static {
      new singleton();
   }
}

Kotlin 让创建单例变得更高效。

EJ 第13条:使类和成员的可访问性最小化

封装(也称之为信息隐藏)是面向对象的四大特性之一,体现在具体的实现层面便是四种访问权限:private、default、protected 和 public

面向对象编程,我们的代码充满着类、成员属性和成员方法,这些都是我们对外的契约,如果类和成员都是可访问的,意味着我们后续的迭代版本都必须保持兼容,这显然是一项巨大的工程。

反之,充分利用好四种访问权限,将类和成员的可访问性控制到最小,更有利于程序的扩展。在这点上,Java 和 Kotlin 是大体一致的,但有细微区别:

  1. Kotlin 的默认访问权限为 public

  2. Kotlin 没有包级别访问权限。因为 Kotlin 认为包级别的访问权限很容易被破坏:只要使用者创建一个一模一样的包名即可访问,取代方案参照下一点。

  3. Kotlin 新增了模块可见的访问权限 internal

  4. Kotlin 新增了顶层声明的类别(顶层函数和顶层属性,无需放在类中的属性和方法)。

关于 internal,举个栗子:假设工程里有两个 module,app 和 lib,app 依赖于 lib 工程,代码层级如下:

app
-- class Activity
lib
-- internal class StringUtils

StringUtils 仅在 lib 工程中可视,app 工程中的 Activity 无法访问该类。

Kotlin 在访问权限的设计更彻底的贯彻了使可访问性最小化的准则。

EJ 第14条:在公有类中使用访问方法而非公有域

public class Point {
    public double x;
    public double y;
}

如上代码,我们会直接调用 public 修饰的成员属性(即准则中的公有域),《Effective Java》 不建议这么用,取而代之的是将成员属性定义成私有的,并且提供 public 修饰的 set 和 get 方法。

原因很简单:如果直接暴露成员属性,将来想改变其内部实现是不可能的,反之,如果是暴露方法,则可以在方法中轻易地修改实现。

对于这条准则,Kotlin 在语法层面直接默认约束了:

class User{
    val num = 10//属性默认为private,且拥有public的getNum()
    var nickname = "geniusmart"//同上
}

调用属性的时候,看似直接访问,实则访问的是 get 和 set 方法:

@Test
fun test(){
    val user = User()
    println(user.num)//实际上调用的是getNum()
    user.nickname = "Mr.Geniusmart"//实际上调用的是setNum()
    println(user.nickname)
}

如果哪一天,业务需要我们将所有昵称带上邮箱,此时亡羊补牢显得轻而易举:

class User{

    val num = 10
    var nickname = "geniusmart"
        get() = field.plus("@email.com")

}

Kotlin 的 setter 和 getter 规约完美吻合第14条准则。

EJ 第16条:组合优先于继承(原书是复合优先于继承)

组合优先于继承 是面向对象中非常重要的原则之一。继承破坏了封装性,父类必须暴露更多的细节让子类知道(比如使用 protected 访问权限),同时子类依赖于父类的实现,一旦父类改变,子类都会受影响。

举例说明,我们想对 HashSet 增加『计算新增元素个数』的能力,经过多年面向对象的熏陶,我们信誓旦旦的采用继承的方式:定义 HashSet 的子类,在子类中进行扩展:

class CountingSet: HashSet<String>() {

    var count = 0

    override fun add(element: String): Boolean {
        count++
        return super.add(element)
    }

    override fun addAll(elements: Collection<String>): Boolean {
        count+=elements.size
        return super.addAll(elements)
    }
}

然而事与愿违的是,父类的 addAll() 将会循环调用 add(),因此,计数器会成倍的增加计数,测试代码如下:

@Test
fun test(){

   val countingSet = CountingSet()
   countingSet.addAll(setOf("1","2","3"))
   println("countingSet.count=${countingSet.count}")//期望是3,实际上是6

}

这个例子告诉我们,继承是多么不可靠,子类与父类的耦合度太强,需要了解太多父类的实现。

『继承』不是最优解,相较而言,『组合』在这种场景下是更可靠的解决方案:

class CountintSetComposite(val countingSet : HashSet<String> ){

    var count = 0

    fun contains(element: String) {
        countingSet.contains(element)
    }

    fun add(element: String): Boolean {
        count++
        return countingSet.add(element)
    }

    // 庞大的工作量:声明HashSet的所有方法。。
}

但是,这里最大的问题在于:我们必须将父类的所有方法都声明一遍,仅仅是为了扩展其中两个方法 add 和 addAll。

Kotlin 再次体现了其追求高效的本质,『类委托』是 Kotlin 用来简化『组合』的利器:

class CountingSetBy(val countingSet: MutableCollection<String>):MutableCollection<String> by countingSet{

    var count = 0

    override fun add(element: String): Boolean {
        count++
        return countingSet.add(element)
    }

    override fun addAll(elements: Collection<String>): Boolean {
        count+=elements.size
        return countingSet.addAll(elements)
    }
}

此例中,MutableCollection(在 Kotlin 中作为 HashSet 的父接口)将其实现委托给 countingSet,我们只需要专注于需要扩展的方法即可。

注:准确来说,组合更多的目的是增加原始对象的能力,因此是『装饰』而非『代理』,而 Kotlin 的委托类在字面意思上更多的还是体现『代理』的味道。

EJ 第17条:要么为继承而设计,并提供文档说明,要么就禁止继承

继承的缺点我们已经在上条准则领略到了,更进一步地,接下来这条准则告诉我们:如没有必要提供继承,则禁止。那么如何来禁止继承?其实很简单,将类定义为 final 类,退而求其次,如果类允许继承,则定义不允许重写的方法为 final 方法。

既然这是个更好的实践,为什么将其作为默认设计?Kotlin 便是这个思路的践行者,Kotlin 中创建的类和方法默认都是 final 的:

class Parent{
    fun action(){

    }
}

/*
// 等价于:
public final class Parent {
   public final void action() {
   }
}
*/

如果经过深思熟虑,一定要提供继承和重写,则对类或方法增加 open 修饰符即可。

EJ 第21条:用函数对象表示策略

关于这条准则,我们从策略模式讲起:

 

以 Java 的思维模式而言,首先要定义策略接口,及具体的策略实现类:

interface Strategy{
    fun action()
}

class StrategyA : Strategy{
    override fun action() {
        println("StrategyA")
    }
}

class StrategyB : Strategy{
    override fun action() {
        println("StrategyB")
    }
}

class Context(var strategy: Strategy){

    fun preform(){
        strategy.action()
    }
}

使用策略的代码如下:

val context1 = Context(StrategyA())
val context2 = Context(StrategyB())
val context3 = Context(object : Strategy{
    override fun action() {
        println("匿名内部类--StrategyC")
    }

})
context1.preform()

这些代码如同我们两点一线的工作一般毫无新意,Kotlin 的 lambda 表达式则激发了我们内心的一点涟漪:

class ContextKotlin{

    fun perform(strategy: ()->Unit){
        strategy()
    }
}

@Test
fun testAdavance(){
    val context = ContextKotlin()
    context.perform {
        println("StrategyA")
    }
    val strategyB = { println("strategyB")}
    context.perform(strategyB)
}

『用函数对象表示策略』,Kotlin 诠释得如此淋漓尽致。

EJ 第22条:优先考虑静态成员类

在 Java 中,我们经常要把一个类定义在另外一个类的内部,该类被称之为内部类。内部类有四种:静态成员类、非静态成员类、匿名类和局部类。

该条款建议优先考虑静态成员类,原因在于静态成员类相比非静态成员类而言,不会持有外部类的引用,会带来几个好处:

  1. 无需实例外部类就可以使用

  2. 当外部类可以被垃圾回收时,不会因为内部类的持有而导致内存泄露。

Kotlin 在语法层面直接对该条款进行支持,静态成员类在 Kotlin 中称为『嵌套类』,默认的内部类便是嵌套类,比如:

class Outer {

    class Inner { // 默认便是静态成员类,等价于public static final class Inner

    }
}

这种『默认的规约』可以减少不必要的非静态成员类,当然如果经过深思熟虑,一定要使用非静态成员类,可以通过 inner 关键字来实现:

class Outer{

    class Inner{ // 静态成员类,等价于public final class Outer

    }

    inner class OtherInner{ // 非静态成员类

        fun action(){
            // 调用外部类实例
            this@Outer.toString()
        }
    }
}

EJ 第36条:坚持使用 Override 注解

回顾上文提到的具备计数能力的 HashSet,采用继承的方式时,需要对 add 方法进行重写:

class CountingSet: HashSet<Any>() {

    var count = 0

    //1.正确的重写
    /*
    override fun add(element: Any): Boolean {
        count++
        return super.add(element)
    }
    */

    //2.错误的重写
    fun add(element: Int): Boolean {
        count++
        return super.add(element)
    }

}

看上文的第2个 add 方法,实际是重载而非重写,与我们的本意背道而驰,如果对该方法加上 override 注解 ,编译器将提示我们问题所在,从而避免不必要的程序 bug。

Kotlin 同样是这条准则的兢兢业业的践行者,因为在 Kotlin 中重写方法,必须必须必须强制加上 override

Kotlin 与 《Effective Java》相映成辉,显得美不胜收。对照《Effective Java》,我们能更好地理解 Kotlin 的诸多语法的设计初衷。

理解 DSL

DSL(domain specific language),即领域专用语言:专门解决某一特定问题的计算机语言,比如大家耳熟能详的 SQL 和正则表达式。

Kotlin DSL 把 Kotlin 的语法糖演绎得淋漓尽致,这些语法糖可谓好吃、好看又好玩,但是,仅痴迷于语法糖只会对语言的理解游离于表面,了解其实现原理,是我们阅读优秀源码、设计整洁代码和理解编程语言的必经之路,本文我们通过 DSL 来感受 Kotlin 之美。

通用编程语言 vs DSL

通用编程语言(如 Java、Kotlin、Android等),往往提供了全面的库来帮助开发者开发完整的应用程序,而 DSL 只专注于某个领域,比如 SQL 仅支持数据库的相关处理,而正则表达式只用来检索和替换文本,我们无法用 SQL 或者正则表达式来开发一个完整的应用。

API vs DSL

无论是通用编程语言,还是领域专用语言,最终都是要通过 API 的形式向开发者呈现。良好的、优雅的、整洁的、一致的 API 风格是每个优秀开发者的追求,而 DSL 往往具备独特的代码结构和一致的代码风格,从 SQL 和正则表达式的语法风格便可感受一二。

下文我们也将提到,Kotlin 构建的 DSL,代码风格更具表现力和想象力,也更加优雅。

内部 DSL

但是,如果为解决某一特定领域问题就创建一套独立的语言,开发成本和学习成本都很高,因此便有了内部 DSL 的概念。所谓内部 DSL,便是使用通用编程语言来构建 DSL。比如,本文提到的 Kotlin DSL,我们为 Kotlin DSL 做一个简单的定义:

“使用 Kotlin 语言开发的,解决特定领域问题,具备独特代码结构的 API 。”

下面,我们就来领略下千变万化的 Kotlin DSL 。

有趣的 Kotlin DSL

如果说 Kotlin 是一位魔术师,那么 DSL 便是其赖以成名,令人啧啧称赞的魔术作品,我们先来看下 Kotlin 在各个特定领域的有趣实现。

  1. 日期

val yesterday = 1.days.ago // 也可以这样写:val yesterday = 1 days ago
val twoMonthsLater = 2 months fromNow

以上日期处理的代码,真正做到见名知意,深谙代码整洁之道,更多细节可参考此库:kxdate 。

如果不考虑规范,基于该库的设计思路,我们甚至可以设计出如下的 api:

val yesterday = 1 天 前
val twoMonthsLater = 2 月 后

这个日期处理领域的 DSL 体现出来的代码结构是链式的,并且近似于我们日常使用的英语

  1. 单元测试

val str = "kotlin"
str should startWith("kot")
str.length shouldBe 6

与上述日期库的 api 风格类似,该单元测试的代码也是赏心悦目,更多细节可参考此库:kotlintest 。

基于该库的设计思路,我们甚至可以实现如下的代码风格,如同写英语句子一般简洁:

"kotlin" should start with "kot"
"kotlin" should have substring "otl"

这个 DSL 的代码结构近似于我们日常使用的英语。

  1. HTML 构建器

fun createTable() =
    table{
        tr{
            td{

            }
        }
    }

>>> println(createTable())
<table><tr><td></td></tr></table>

这个 DSL 的代码结构使用了 lambda 嵌套,并且语义清晰,一目了然。更多详情参考此库:kotlinx.html。

  1. SQL

(Users innerJoin Cities).slice(Users.name, Cities.name).
            select {(Users.id.eq("andrey") or Users.name.eq("Sergey")) and
                    Users.id.eq("sergey") and Users.cityId.eq(Cities.id)}.forEach {
            println("${it[Users.name]} lives in ${it[Cities.name]}")
        }

这类 SQL api 的风格,如果有用过 ORM 的框架,如 ActiveAndroid 或者 Realm 就不会陌生。以上代码来自于此库:Exposed 。

  1. Android 布局

Anko Layouts 是一套帮助我们更简洁的开发和复用 Android 布局的 DSL ,它的代码风格如下:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {

        super.onCreate(savedInstanceState)
        verticalLayout {
            val name = editText()
            button("Say Hello") {
                onClick { toast("Hello, ${name.text}!") }
            }
        }

    }

}

相比于笨重的 XML 布局方式,Anko DSL 显然是更先进和更高效的解决方案。

  1. Gradle 构建

Gradle 的构建脚本是 groovy,对 Android 程序员有一定的学习成本,目前,Gradle 官方也提供了基于 Kotlin 的构建脚本:Gradle Kotlin DSL , 并提供了类 groovy 的代码风格:

dependencies {
    compile("com.android.support:appcompat-v7:27.0.1")
    compile("com.android.support.constraint:constraint-layout:1.0.2")
}

完整代码请参考:build.gradle.kts。

综上,Kotlin DSL 所体现的代码结构有如下特点:链式调用,大括号嵌套,并且可以近似于英语句子。

实现原理

看了那么多 Kotlin DSL 的风格和使用场景,相较于刻板的、传统的 Java 而言,更加神奇和富有想象力。要理解 Kotlin DSL 这场魔术盛宴,就必须了解其背后用到的魔术道具——扩展函数、lambda、中缀调用和 invoke 约定。

扩展函数(扩展属性)

对于同样作为静态语言的 Kotlin 来说,扩展函数(扩展属性)是让他拥有类似于动态语言能力的法宝,即我们可以为任意对象动态的增加函数或属性。

比如,为 String 扩展一个函数: lastChar():

package strings

fun String.lastChar(): Char = this.get(this.length - 1)

调用扩展函数:

>>> println("Kotlin".lastChar())
n

与 JavaScript 这类动态语言不一样,Kotlin 实现原理是:提供静态工具类,将接收对象(此例为 String )做为参数传递进来,以下为该扩展函数编译成 Java 的代码

/* Java */
char c = StringUtilKt.lastChar("Java");

回顾前文讲到的日期的 DSL:

val yesterday = 1.days.ago

为配合扩展函数,我们先降低 api 的整洁程度,先实现一个扩展函数的版本:

val yesterday = 1.days().ago()

1 为 Int 类型,显然 Int 并没有 days() 函数,因此days() 为扩展函数,伪代码如下:

fun Int.days() = {//逻辑实现}

结合 Java8 的 Time api,此处将会涉及到两个扩展函数,完整实现如下:

fun Int.days() = Period.ofDays(this)
fun Period.ago() = LocalDate.now() - this

若要实现最终的效果,实际上就是将扩展函数修改为扩展属性的方式即可(扩展属性需提供getter或setter,本质上等同于扩展函数):

val Int.days:Period
    get() = Period.ofDays(this)

val Period.ago:LocalDate
    get() = LocalDate.now() - this

代码虽少,却天马行空,妙趣横生。

lambda

lambda 为 Java8 提供的新特性,于2014年3月18日发布。在2018年的今天我们依然无法使用或者要花很大的代价才能在 Android 编程中使用,而 Kotlin 则帮助我们解决了这一瓶颈,这也是我们拥抱 Kotlin 的原因之一。

lambda 是构建整洁代码的一大利器。

1. lambda 表达式

下图是 lambda 表达式,他总是用一对大括号包装起来,可以作为值传递给下节要提到的高阶函数。

 

 

2. 高阶函数

关于高阶函数的定义,参考《Kotlin 实战》:

高阶函数就是以另一个函数作为参数或返回值的函数

如果用 lamba 来作为高价函数的参数(此时为形参),就必须先了解如何声明一个函数的形参类型,如下:

 

 

相对于上一小节,我们应该弄清楚 lambda 作为实参和形参时的表现形式:

// printSum 为高阶函数,定义了 lambda 形参
fun printSum(sum:(Int,Int)->Int){
        val result = sum(1, 2)
        println(result)
}

// 以下 lambda 为实参,传递给高阶函数 printSum
val sum = {x:Int,y:Int->x+y}
printSum(sum)

有了高阶函数,我们可以很轻易地做到一个 lambda 嵌套另一个 lambda 的代码结构。

3. 大括号放在最后

Kotlin 的 lambda 有个规约:如果 lambda 表达式是函数的最后一个实参,则可以放在括号外面,并且可以省略括号,如:

person.maxBy({ p:Person -> p.age })

// 可以写成
person.maxBy(){
    p:Person -> p.age
}

// 更简洁的风格:
person.maxBy{
    p:Person -> p.age
}

这个规约是 Kotlin DSL 实现嵌套结构的本质原因,比如上文提到的 anko Layout:

verticalLayout {
            val name = editText()
            button("Say Hello") {
                onClick { toast("Hello, ${name.text}!") }
            }
        }

这里 verticalLayout 中 嵌套了 button,想必该库定义了如下函数:

fun verticalLayout( ()->Unit ){

}

fun button( text:String,()->Unit ){

}

verticalLayout 和 button 均是高阶函数,结合大括号放在最后的规约,就形成了 lambda 嵌套的语法结构。

4. 带接收者的 lambda

lambda 作为形参函数声明时,可以携带接收者,如下图:

 

 

带接收者的 lambda 丰富了函数声明的信息,当传递该 lambda值时,将携带该接收者,比如:

// 声明接收者
fun kotlinDSL(block:StringBuilder.()->Unit){
  block(StringBuilder("Kotlin"))
}

// 调用高阶函数
kotlinDSL {
  // 这个 lambda 的接收者类型为StringBuilder
  append(" DSL")
  println(this)
}

>>> 输出 Kotlin DSL

总而言之,lambda 在 Kotlin 和 Kotlin DSL 中扮演着很重要的角色,是实现整洁代码的必备语法糖。

中缀调用

Kotlin 中有种特殊的函数可以使用中缀调用,代码风格如下:

"key" to "value"

// 等价于
"key.to("value")

而 to() 的实现源码如下:

infix fun Any.to(that:Any) = Pair(this,that)

这段源码理解起来不难,infix 修饰符代表该函数支持中缀调用,然后为任意对象提供扩展函数 to,接受任意对象作为参数,最终返回键值对。

回顾下我们上文提到的不太规范的中文 api:

val yesteraty = 1 天 前

使用扩展函数和中缀调用便可实现:

object 前
infix fun Int.天(ago:前) = LocalDate.now() - Period.ofDays(this)

再比如上文提到的:

"kotlin" should start with "kot"

// 等价于
"kotlin".should(start).with("kot")

使用两个中缀调用便可实现,以下是伪代码:

object start
infix fun String.should(start:start):String = ""
infix fun String.with(str:String):String = ""

所以,中缀调用是实现类似英语句子结构 DSL 的核心。

invoke 约定

Kotlin 提供了 invoke 约定,可以让对象向函数一样直接调用,比如:

class Person(val name:String){
    operator fun invoke(){
        println("my name is $name")
    }
}

>>>val person = Person("geniusmart")
>>> person()
my name is geniusmart

回顾上文提到的 Gradle Kotlin DSL:

dependencies {
    compile("com.android.support:appcompat-v7:27.0.1")
    compile("com.android.support.constraint:constraint-layout:1.0.2")
}

// 等价于:
dependencies.compile("com.android.support:appcompat-v7:27.0.1")
dependencies.compile("com.android.support.constraint:constraint-layout:1.0.2")

这里,dependencies 是一个实例,既可以调用成员函数 compile,同时也可以直接传递 lambda 参数,后者便是采用了 invoke 约定,实现原理简化如下:

class Dependencies{

    fun compile(coordinate:String){
        println("add $coordinate")
    }

    operator fun invoke(block:Dependencies.()->Unit){
        block()
    }
}

>>>val dependencies = Dependencies()
>>>// 以两种方式分别调用 compile()

invoke 约定让对象调用函数的语法结构更加简洁。

总结

细细品味 Kotlin,你会发现她将代码整洁之道(Clean Code)和高效 Java 编程(Effective Java)中的部分精华融入到的语法和默认的规约中,因此她可以让开发者无形中写出整洁和高效的代码。

而更进一步, Kotlin DSL 则是对 Kotlin 所有语法糖的一个大融合,她的代码结构通常是链式调用、lambda 嵌套,并且接近于日常使用的英语句子,我们可以愉悦的使用 DSL 风格的 API,同时,也可以以此为思路,为社区贡献各种 Kotlin DSL。

Kotlin DSL 体现了代码的整洁之道,体现了天马行空的想象力,在 DSL 的点缀下,Kotlin 显示出整洁的美,自由的美。

Kotlin 有趣的外表之下,是一个更有趣的灵魂。

参考文章

  • 《Effective Java 中文版第2版》

  • 《Kotlin 实战》

原文:https://www.jianshu.com/p/f5f0d38e3e44


Kotlin 开发者社区

国内第一Kotlin 开发者社区公众号,主要分享、交流 Kotlin 编程语言、Spring Boot、Android、React.js/Node.js、函数式编程、编程思想等相关主题。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

AI天才研究院

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值