Java8学习小记

2014年,Oracle发布了Java8新版本。对于Java来说,这显然是一个具有里程碑意义的版本。尤其是那函数式编程的功能,避开了Java那烦琐的语法所带来的麻烦。

这可以算是一篇Java8的学习笔记。将Java8一些常见的一些特性作了一个概要的笔记,其中未对:

  • 增强的Future:CompletableFuture 工具类

  • 时间API

等新API做出介绍,原因是任意搜索一下都是有较好的文章进行介绍。比如:

函数式编程

什么是函数式编程?

函数作为一等公民

我们常见的JavaScript就是视函数作为一等公民的。但它不属于严格的面向对象。但是,如果你愿意,你既可以把它作为面向对象语言,也可以它当作函数式语言,因此也可以称之它为多范式语言。

如果你使用过jQuery,你可能经常使用如下的代码:

${"button"}.click(function(){
  $("li").each(function(){
    alert($(this).text())
    });
  });

注意这里的each()函数的参数,这是一个匿名函数,在遍历所有的li节点时,会弹出li节点的文本内容。将函数作为参数传递给另一个函数,这是函数式编程的特性之一。

再来看看另一个栗子:

function f1(){
  var n =1;
  function f2(){
    alert(n);
  }
  return f2;
}
var result = f1();
result (); //1

这是一段js代码。在这段代码中,注意函数f1的返回值,它返回了函数f2。在倒数第2行,返回的f2函数并赋值给result,实际上,此时的result就是一个函数,并且指向f2。对result的调用,就会打印n的值。

函数可以作为另一个函数的返回值,也是函数式编程的重要特点。

无副作用

函数的副作用指的是函数在调用过程中,除了给出了返回值外,还修改了函数外部的状态,比如,函数在调用过程中,修改了某一个全局状态。函数式编程认为,函数的副用作应该被尽量避免。可以想象,如果一个函数肆意修改全局或者外部状态,当系统出现问题时,我们可能很难判断究竟是哪个函数引起的问题。这对于程序的调试和跟踪是没有好处的。如果函数都是显式函数,那么函数的执行显然不会受到外部或者全局信息的影响,因此,对于调试和排错是有益的。

注意:显式函数指函数与外界交换数据的唯一渠道就是参数和返回值,显式函数不会去读取或者修改函数的外部状态。与之相对的是隐式函数,隐式函数除了参数和返回值外,还会读取外部信息,或者可能修改外部信息。

申明式的(Declarative)

函数式编程是申明式的编程方式。相对于命令式(imperative)而言,命令式的程序设计喜欢大量使用可变对象和指令。我们总是习惯于创建对象或者变量,并且修改它们的状态或者值,或者喜欢提供一系列指令,要求程序执行。这种编程习惯在申明式的函数式编程中有所变化。对于申明式的编程范式,你不在需要提供明确的指令操作,所有的细节指令将会更好的被程序库所封装,你要做的只是提出你要的要求,申明你的用意即可。

请看下面一段程序,这一段传统的命令式编程,为了打印数组中的值,我们需要进行一个循环,并且每次需要判断循环是否结束。在循环体内,我们要明确地给出需要执行的语句和参数。


public static void imperative(){  
         int[]iArr={1,3,4,5,6,9,8,7,4,2};  
         for(int i=0;i<iArr.length;i++){  
                   System.out.println(iArr[i]);  
         }  
}  

与之对应的申明式代码如下:

public static void declarative(){  
         int[]iArr={1,3,4,5,6,9,8,7,4,2};  
         Arrays.stream(iArr).forEach(System.out::println);  
}  

可以看到,变量数组的循环体居然消失了!println()函数似乎在这里也没有指定任何参数,在此,我们只是简单的申明了我们的用意。有关循环以及判断循环是否结束等操作都被简单地封装在程序库中。

不变模式

如果读者熟悉多线程程序设计,那么一定对不变模式有所有了解。所谓不变,是指对象在创建后,就不再发生变化。比如,java.lang.String就是不变模式的典型。如果你在Java中创建了一个String实例,无论如何,你都不可能改变整个String的值。比如,当你使用String.replace()函数试图进行字符串替换时,实际上,原有的字符串对象并不会发生变化,函数本身会返回一个新的String对象,作为给定字符替换后的返回值。不变的对象在函数式编程中被大量使用。

请看以下代码:

static int[] arr={1,3,4,5,6,7,8,9,10};
Arrays.stream(arr).map((x)->x=x+1).forEach(System.out::println);
System.out.println();
Arrays.stream(arr).forEach(System.out::println);

代码第2行看似对每一个数组成员执行了加1的操作。但是在操作完成后,在最后一行,打印arr数组所有的成员值时,你还是会发现,数组成员并没有变化!在使用函数式编程时,这种状态是一种常态,几乎所有的对象都拒绝被修改。

易于并行

由于对象都处于不变的状态,因此函数式编程更加易于并行。实际上,你甚至完全不用担心线程安全的问题。我们之所以要关注线程安全,一个很大的原因是当多个线程对同一个对象进行写操作时,容易将这个对象“写坏”,更专业的说法是“使得对象状态不一致”。但是,由于不变模式的存在,对象自创建以来,就不可能发生改变,因此,在多线程环境下,也就没有必要进行任何同步操作。这样不仅有利于并行化,同时,在并行化后,由于没有同步和锁机制,其性能也会比较好。读者可以关注一下java.lang.String对象。很显然,String对象可以在多线程中很好的工作,但是,它的每一个方法都没有进行同步处理。

更少的代码

通常情况下,函数式编程更加简明扼要,Clojure语言(一种运行于JVM的函数式语言)的爱好者就宣称,使用Clojure可以将Java代码行数减少到原有的十分之一。一般说来,精简的代码更易于维护。而Java代码的冗余性也是出了名的,大部分对于Java语言的攻击都会直接针对Java繁琐,而且死板的语法(但我认为这也是Java的优点之一,正如本书第一段提到的“保守的设计思想是Java最大的优势”),然而,引入函数式编程范式后,这种情况发生了改变。我们可以让Java用更少的代码完成更多的工作。

请看下面这个例子,对于数组中每一个成员,首先判断是否是奇数,如果是奇数,则执行加1,并最终打印数组内所有成员。

数组定义:

    static int[] arr={1,3,4,5,6,7,8,9,10};  
    传统的处理方式:  
    for(int i=0;i<arr.length;i++){  
             if(arr[i]%2!=0){  
                       arr[i]++;  
             }  
             System.out.println(arr[i]);  
    }  

使用函数式方式:

Arrays.stream(arr).map(x->(x%2==0?x:x+1)).forEach(System.out::println);

可以看到,函数式范式更加紧凑而且简洁。

函数式编程基础

FunctionalInterface注释
Java 8提出了函数式接口的概念,所谓函数式接口,简单得来说,就是只定义了单一抽象方法的接口。比如下面的定义:

@FunctionalInterface
public static interface IntHandler{
    void handle(int i);

}

注释FunctionalInterface用于表明IntHandler接口时一个函数式接口。该接口被定义为只包含一个抽象方法handle()。因此它符合函数式接口的定义。如果一个函数满足函数式接口的定义,那么即使不标注为@FunctionalInterface,编译器依然会把它看做函数式接口。这有点像@Override注释,如果你的函数符合重载的要求,无论你是否标注了@Override ,编译器都会识别这个重载函数,但一旦你进行了标注,而实际的代码不符合规范,那么就会得到一个编译错误。

这里需要强调的是,函数式接口只能有一个抽象方法,而不是指只能有一个方法。这分两点来说明。首先,在Java 8中,接口运行存在实例方法(见默认方法一节),其次任何被java.lang.Object实现的方法,都不能视为抽象方法,因此,下面的NonFunc接口不是函数式接口,因为equals()方法在java.lang.Object中已经实现。

interface NonFunc {
boolean equals(Object obj);
}

同理,下面实现的IntHandler接口符合函数式接口要求,虽然看起来它不像,但实际上它是一个完全符合规范的函数式接口。

@FunctionalInterface
public static interface IntHandler{
    void handle(int i);
    boolean equals(Object obj);
}

函数式接口的实例可以由方法引用或者lambda表达式进行构造,在下文中,将进一步举例说明。

接口默认方法

在Java 8之前的版本,接口只能包含抽象方法。但从Java 8之后,接口也可以包含若干个实例方法。这一改进使得Java 8拥有了类似于多继承的能力。一个对象实例,将拥有来自于多个不同接口的实例方法。

比如,对于接口IHorse,实现如下:

public interface IHorse{
    void eat();
    default void run(){
        System.out.println(“hourse run”);
    }
}

在Java 8中,使用default关键字,可以在接口内定义实例方法。注意,这个方法是并非抽象方法,而是拥有特定逻辑的具体实例方法。

所有的动物都能自由呼吸,所以,这里可以再定义一个IAnimal接口,它也包含一个默认方法breath()。

public interface IAnimal {
  default void breath(){
      System.out.println(“breath”);
    }
}

骡是马和驴的杂交物种,因此骡(Mule)可以实现为IHorse,同时骡也是动物,因此有:

public class Mule implements IHorse,IAnimal{
    @Override
    public void eat() {
        System.out.println(“Mule eat”);
      }
    public static void main(String[] args) {
     Mule m=new Mule();
     m.run();
     m.breath();
      }
}

注意上述代码中Mule实例同时拥有来自不同接口的实现方法。在这Java 8之前是做不到的。从某种程度上说,这种模式可以弥补Java单一继承的一些不便。但同时也要知道,它也将遇到和多继承相同的问题,如果IDonkey也存在一个默认的run()方法,那么同时实现它们的Mule,就会不知道所措,因为它不知道应该以哪个方法为准。

增加一个IDonkey的实现:

public interface IDonkey{
    void eat();
    default void run(){
        System.out.println(“Donkey run”);
    }

}

修改Mule的实现如下,注意它同时实现了IHorse和IDonkey。

public class Mule implements IHorse,IDonkey,IAnimal{
    @Override
    public void eat() {
        System.out.println(“Mule eat”);
    }
    public static void main(String[] args) {
     Mule m=new Mule();
     m.run();
     m.breath();
   }
}

此时,由于IHorse和IDonkey拥有相同的默认实例方法,故编译器会抛出一个错误:

Duplicate default methods named run with the parameters () and () are inherited from the types IDonkey andIHorse

为了让Mule同时实现IHorse和IDonkey,在这里,我们不得不重新实现一下run()方法。让编译器可以进行方法绑定。修改Mule的实现如下:

public class Mule implements IHorse,IDonkey,IAnimal{
  @Override
  public void run(){
     IHorse.super.run();
}
    @Override
  public void eat() {
     System.out.println(“Mule eat”);
    }
  public static void main(String[] args) {
     Mule m=new Mule();
     m.run();
     m.breath();
   }
}

在这里,将Mule的run()方法委托给IHorse实现,当然,大家也可以有自己的实现。

接口默认实现对于整个函数式编程的流式表达非常重要。比如,大家熟悉的java.util.Comparator接口,它在JDK 1.2时就已经被引入,用于在排序时给出两个对象实例的具体比较逻辑。在Java 8中,Comparator接口新增了若干个默认方法,用于多个比较器的整合。其中一个常用的默认如下:

default Comparator<T> thenComparing(Comparator<? super T> other) {
  Objects.requireNonNull(other);
  return (Comparator<T> & Serializable) (c1, c2) -> {
    int res = compare(c1, c2);
    return (res != 0) ? res : other.compare(c1, c2);
  };

}

有个这个默认方法,在进行排序时,我们就可以非常方便得进行元素的多条件排序,比如,如下代码构造一个比较器,它先按照字符串长度排序,继而按照大小写不敏感的字母顺序排序。

Comparator<String> cmp = Comparator.comparingInt(String::length)
.thenComparing(String.CASE_INSENSITIVE_ORDER);

lambda表达式

为了让Java 8支持函数式编程,Java 8引入的最大的功能点可以说就是对lambda表达式的支持。lambda表达式即匿名函数,它是一段没有函数名的函数体。可以作为参数直接传递给相关的调用者,lambda表达式极大得增强了Java语言的表达能力。

下例展示了lambda表达式的使用,在forEach()函数中,传入的就是一个lambda表达式,它完成了对元素的标准输出操作。可以看到这段表达式并不像函数一样有名字,非常类似匿名内部类,它只是简单的描述了应该执行的代码段。

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
numbers.forEach((Integer value) -> System.out.println(value));

和匿名对象一样,lambda表达式也可以访问外部的局部变量,如下所示:

final int num = 2;
Function<Integer, Integer> stringConverter = (from) -> from * num;
System.out.println(stringConverter.apply(3));

上述代码可以编译通过,正常执行,并输出6。与匿名内部对象一样,在这种情况下,外部的num变量必须申明为final,这样才能保证在lambda表示中合法的访问它。

但奇妙的是,对于lambda表达式而言,即使去掉上述的final定义,程序依然可以编译通过!但千万不要以为这样你就可以修改num的值了。实际上,这只是Java 8做了一个掩人耳目的小处理,它会自动将在lambda表达式中使用的变量视为final。因此,下述代码是可以编译通过的:

int num = 2;
Function<Integer, Integer> stringConverter = (from) -> from * num;
System.out.println(stringConverter.apply(3));

但是,如果像下面这么写,就不行!

int num = 2;
Function<Integer, Integer> stringConverter = (from) -> from * num;
num++;
System.out.println(stringConverter.apply(3));

上述的num++会引起一个编译错误:

Local variable num defined in an enclosing scope must be final or effectively final

方法引用

方法引用是Java 8中提出的用来简化lambda表达式的一种手段。它通过类名和方法名来定位到一个静态方法或者实例方法。

方法引用在Java 8中的使用非常灵活。总的来说,可以分为以下几种:

  • 静态方法引用:ClassName::methodName

  • 实例上的实例方法引用:instanceReference::methodName

  • 超类上的实例方法引用:super::methodName

  • 类型上的实例方法引用:ClassName::methodName

  • 构造方法引用:Class::new

  • 数组构造方法引用:TypeName[]::new

首先,方法引用使用”::”定义,”::”的前半部分表示类名或者实例名,后半部分表示方法名称。如果是构造函数,则使用new表示。

下例展示了方法引用的基本使用:

public class InstanceMethodRef {
  public static void main(String[] args) {
    List<User> users=new ArrayList<User>();
    for(int i=1;i<10;i++){
      users.add(new User(i,”billy”+Integer.toString(i)));
    }
    users.stream().map(User::getName).forEach(System.out::println);
  }
}

对于第一个方法引用User::getName,表示User类的实例方法。在执行时,Java会自动识别流中的元素(这里指User实例)是作为调用目标还是调用方法的参数。在”User::getName”中,显然流内的元素都应该作为调用目标,因此实际上,在这里调用了每一个User对象实例的getName()方法,并将这些User的name作为一个新的流。同时,对于这里得到的所有name,使用方法引用System.out::println进行处理。这里的System.out为PrintStream对象实例,因此,这里表示System.out实例的println方法。系统也会自动判断,流内的元素此时应该作为方法的参数传入,而不是调用目标。

一般来说,如果使用的是静态方法,或者调用目标明确,那么流内的元素会自动作为参数使用。如果函数引用表示实例方法,并且不存在调用目标,那么流内元素就会自动作为调用目标。

因此,如果一个类中存在同名的实例方法和静态函数,那么编译器就会感到很困惑,因为此时,它不知道应该使用哪个方法进行调用。它既可以选择同名的实例方法,将流内元素作为调用目标,也可以使用静态方法,将流元素作为参数。

请看下面的例子:

public class BadMethodRef {
  public static void main(String[] args) {
    List<Double> numbers=new ArrayList<Double>();
    for(int i=1;i<10;i++){
      numbers.add(Double.valueOf(i));
    }
    numbers.stream().map(Double::toString).forEach(System.out::println);
  }
}

上述代码试图将所有的Double元素转为String并将其输出,但是很不幸,在Double中同时存在以下两个函数:

public static String toString(double d)

public String toString()

此时,对函数引用的处理就出现了歧义,因此,这段代码在编译时就会抛出如下错误:

Ambiguous method reference: both toString() and toString(double) from the type Double are eligible

方法引用也可以直接使用构造函数。首先,查看模型类User的定义:

public class User{
  private int id;
  private String name;
  public User(int id,String name){
    this.id=id;
    this.name=name;
}
    //这里省略对字段的setter和getter
}

下面的方法引用调用了User的构造函数:

public class ConstrMethodRef {
    @FunctionalInterface
    interface UserFactory<U extends User> {
     U create(int id, String name);
    }
    static UserFactory<User> uf=User::new;

    public static void main(String[] args) {
      List<User> users=new ArrayList<User>();
      for(int i=1;i<10;i++){
        users.add(uf.create(i, “billy”+Integer.toString(i)));
      }
      users.stream().map(User::getName).forEach(System.out::println);
    }
}

在此,UserFactory作为User的工厂类,是一个函数式接口。当使用User::new创建接口实例时,系统会根据UserFactory.create()的函数签名来选择合适的User构造函数,在这里,很显然就是public User(int id,String name)。在创建UserFactory实例后,对UserFactory.create()的调用,都会委托给User的实际构造函数进行,从而创建User对象实例。

一步一步走入函数式编程

先来个常见的例子

static int []arr={1,4,3,6,5,7,2,9};

public static void main(Strng[]args){
  for(int i:arr){
    System.out.println(i);
  }
}

上述代码循环遍历了数组内的元素,并且进行了数值的打印,这是最传统的做法。如果使用Java8中的流,可以写成这样:

static int []arr={1,4,3,6,5,7,2,9};

public static void main(Strung[]args){\
  //Array.stream()方法返回了一个流对象。类似于集合或者数组,流对象也是一个对象的集合,它将给予我们遍历处理流内元素的功能
  Array.stream(arr).forEach(new IntConsumer)(){
    public void accept(int value){
      System.out.println(value);
    }
  }
}

这里值得注意的是这个流对象的forEach()方法,它接收一个IntConsumer接口的实现,用于对每个流内的对象进行处理。之所以是IntConsumer接口,因为当前流是IntStream,也就是装有Integer元素的流,因此,它自然需要一个处理Integer元素的接口。函数forEach()会挨个将流内的元素送入IntConsumer进行处理,循环过程被封装在forEach内部,也就是JDK框架内。

不仅如此,Arrays.stream()还支持DoubleStream、 LongStream和普通的对象流Stream,这完全取决于它接受的参数。

然而这样的写法似乎还是令人无法满意——代码量多了。而且除了引入了不必要的接口和匿名类等复杂性外,似乎也看不出有什么太大的好处。但是,我们的脚步并未就此打住。forEach()函数的参数是可以从上下文推导出来的,那为什么还要去写呢?这些机械式的推导工作,交给编译器吧!

static int []arr={1,4,3,6,5,7,2,9};

public static void main(String[] args) {
  Arrays.stream(arr).forEach((final int x)->{
    System.out.println(x);
  });
}

从上面的例子中可以看到,IntStream接口名称被省略了,这里只使用了参数名和一个实现体,看起来简洁很多了。但是还不够,因为参数的类型也是可以推导的。既然是IntConsumer接口,参数自然是int了,于是:

static int []arr={1,4,3,6,5,7,2,9};

public static void main(String[]args){
  Arrays.stream(arr).forEach((x)->{
    System.out.println(x);
  });
}

现在连参数类型都省略了,然而这两个花括号也是可以去掉的。

static int [] arr={1,4,3,6,5,7,2,9};

public static void main(String[]args){
  Arrays.stream(arr).forEach((x)->System.out.println(x));
}

此时,forEach()函数的参数依然是IntConsumer,但是它却以一种新的形式被定义,这就是lambda表达式。表达式由"->"分割,左半部分表示参数,右半部分表示实现体。

然而其实还可以再简化——通过方法引用的推导。你甚至连参数申明和传递都可以省略:

static int [] arr={1,4,3,6,5,7,2,9};

public static void main(String[]args){
  Arrays.stream(arr).forEach(System.out::println);
}

并行流与并行排序

在Java8中,可以在接口不变的情况下,将流改为并行流。这样,就可以很自然地使用多线程进行集合中的数据处理。

使用并行流过滤数据

现在让我们考虑这么一个简单的demo:我们希望可以统计一个1~1000000内所有的质数的数量。首先,我们需要一个判断质数的函数:

public class PrimeUtil{
  public static boolean isPrime(int number){
    int tmp = number;
    int (tmp<2){
      return false;
    }
    for(int i =2;Math.sqrt(tmp)>i;i++){
      if(tmp%i==0){
        return false;
      }
    }
    return true;
  }
}

上述函数给定一个数字,如果这个数字是质数就返回true,否则返回false。

接着,使用函数式编程统计给定范围内所有的质数:

IntStream.range(1,1000000).filter(PrimeUtil::isPrime).count();

上述代码首先先生成一个1到1000000的数字流。接着使用过滤函数,只选择所有的函数,最后进行数量统计。

上述代码是串行的,将它改造成并行计算机是非常简单的——只需将流并行化即可:

IntStream.range(1,1000000).parallel().filter(PrimeUtil::isPrime).count();

上述代码中,首先parallel()方法得到一个并行流,接着,在并行流进行过滤,此时,PrimeUtil.isPrime()函数会被多线程并发调用,应用于流中的所有元素。

从集合得到并行流

从函数式编程中,我们可以从集合得到一个流或者并行流。下面这段代码试图统计集合内所有学生的平均分:

List<Student> ss =new ArrayList<Student>();
double average = ss.stream().mapToInt(s->s.score).average().getAsDouble();

从集合对象List中,我们使用stream()方法可以得到一个流。如果希望将这段代码并行化,则可以使用parallelStream()函数。

double ave = ss.parallelStream().mapToInt(s->s.score).average().getAsDouble();

可以看到,将原有的串行方式改造成并行执行是非常容易的。

并行排序

除了并行流外,对于普通数字,Java8中也提供了简单的并行功能。比如,对于数组排序,我们有Arrays.sort()方法。当然这是串行排序,但在Java8中,我们可以使用新增的Arrays.parallelSort()方法直接使用并行排序。

比如,你可以这样使用:

int[]arr = new int [10000000];
Arrarys.parallelSort(arr);

除了并行排序外,Arrays中还增加了一些API用于数组中数据的赋值,比如:

public static void setAll(int [] array,IntUnaryOperator generator)

这个接口充满了函数味道,它的第2个参数是一个函数式接口。如果我们想给数组中每一个元素都附上一个随机值,则可以这么做:

Random r = new Random();
Arrays.setAll(arr,(i)->r.nextInt());

当然,以上过程是串行的。但是只要使用setAll()对应的并行版本,你就可以很快将它执行在多个CPU上:

Random r =new Random();
Arrays.parallelSetAll(arr,(i)->r.nextInt());
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Protobuf是一种高效的序列化协议,可以用于数据交换和数据存储。它的主要优势是大小小,速度快,可扩展性强。下面是使用Protobuf的一些小记: 1. 定义消息格式 首先,需要定义消息格式,以便Protobuf可以将数据序列化和反序列化。消息格式定义在.proto文件中,使用protobuf语言编写。例如,下面是一个简单的消息格式定义: ``` syntax = "proto3"; message Person { string name = 1; int32 age = 2; } ``` 这个消息格式定义了一个名为Person的消息,包含两个字段:name和age。 2. 生成代码 一旦消息格式定义好,就可以使用Protobuf编译器生成代码。编译器将根据消息格式定义生成相应的代码,包括消息类、序列化和反序列化方法等。可以使用以下命令生成代码: ``` protoc --java_out=. message.proto ``` 这将生成一个名为message.pb.javaJava类,该类包含Person消息的定义以及相关方法。 3. 序列化和反序列化 一旦生成了代码,就可以使用Protobuf序列化和反序列化数据。例如,下面是一个示例代码,将一个Person对象序列化为字节数组,并将其反序列化为另一个Person对象: ``` Person person = Person.newBuilder() .setName("Alice") .setAge(25) .build(); byte[] bytes = person.toByteArray(); Person deserializedPerson = Person.parseFrom(bytes); ``` 这个示例代码创建了一个Person对象,将其序列化为字节数组,然后将其反序列化为另一个Person对象。在这个过程中,Protobuf使用生成的代码执行序列化和反序列化操作。 以上是使用Protobuf的一些基本步骤和注意事项,希望对你有所帮助!
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值