Java 8 Stream 流如何使用

上一篇 讲了流的基础知识 ,本篇讲流具体如何使用
       在本篇中,你将会看到Stream API支持的许多操作。这些操作能让你快速完成复杂的数据查询,如筛选、切片、映射、查找、匹配和归约。

筛选和切片

用谓词筛选

Streams接口支持filter方法(你现在应该很熟悉了)。该操作会接受一个谓词(一个返回boolean的函数)作为参数,并返回一个包括所有符合谓词的元素的流。

筛选各异的元素

流还支持一个叫作distinct的方法,它会返回一个元素各异(根据流所生成元素的hashCode和equals方法实现)的流。
示例:

List<Integer> numbers = Arrays.asList(1, 2, 1, 3, 3, 2, 4,5,4); 
numbers.stream() 
	   .filter(i -> i % 2 == 0) 
	   .distinct() 
	   .forEach(System.out::println);
截短流

流支持limit(n)方法,该方法会返回一个不超过给定长度的流。所需的长度作为参数传递给limit。如果流是有序的,则最多会返回前n个元素。
比如,你可以建立一个List,选出热量超过300卡路里的头三道菜:

List<Dish> dishes = menu.stream() 
						.filter(d -> d.getCalories() > 300) 
						.limit(3) 
						.collect(toList());

请注意limit也可以用在无序流上,比如源是一个Set。这种情况下,limit的结果不会以任何顺序排列。

跳过元素

流还支持skip(n)方法,返回一个扔掉了前n个元素的流。如果流中元素不足n个,则返回一个空流。请注意,limit(n)和skip(n)是互补的!

List<Dish> dishes = menu.stream() 
						.filter(d -> d.getCalories() > 300) 
						.skip(2) 
						.collect(toList());
映射

一个非常常见的数据处理套路就是从某些对象中选择信息。比如在SQL里,你可以从表中选择一列。Stream API也通过map和flatMap方法提供了类似的工具。

对流中每一个元素应用函数

流支持map方法,它会接受一个函数作为参数。这个函数会被应用到每个元素上,并将其映射成一个新的元素(使用映射一词,是因为它和转换类似,但其中的细微差别在于它是“创建一个新版本”而不是去“修改”)。例如,下面的代码把方法引用Dish::getName传给了map方法,来提取流中菜肴的名称:

List<String> dishNames = menu.stream() 
							 .map(Dish::getName) 
							 .collect(toList());
流的扁平化

你已经看到如何使用map方法返回列表中每个单词的长度了。让我们拓展一下:对于一张单词表,如何返回一张列表,列出里面各不相同的字符呢?

使用flatMap

List<String> uniqueCharacters = 
   words.stream() 
		.map(w -> w.split("")) 
		.flatMap(Arrays::stream) 
		.distinct() 
		.collect(Collectors.toList());

使用flatMap方法的效果是,各个数组并不是分别映射成一个流,而是映射成流的内容。所有使用map(Arrays::stream)时生成的单个流都被合并起来,即扁平化为一个流。
示例:
给定两个数字列表,如何返回所有的数对呢?例如,给定列表[1, 2, 3]和列表[3, 4],应该返回[(1, 3), (1, 4), (2, 3), (2, 4), (3, 3), (3, 4)]。为简单起见,你可以用有两个元素的数组来代表数对。

        List<Integer> number1 = Arrays.asList(1, 2, 3);
        List<Integer> number2 = Arrays.asList(3, 4);
        number1.stream()
               .flatMap(i-> number2.stream()
                                   .map(j->new int[]{i,j})
                       )
               .collect(toList());

如何扩展前一个例子,只返回总和能被3整除的数对呢?例如(2, 4)和(3, 3)是可以的。

List<int[]> pairs = 
 numbers1.stream() 
         .flatMap(i -> numbers2.stream() 
                               .filter(j -> (i + j) % 3 ==0) 
                               .map(j -> new int[]{i, j}) 
                 )
         .collect(toList());
查找和匹配

另一个常见的数据处理套路是看看数据集中的某些元素是否匹配一个给定的属性。Stream API通过allMatch、anyMatch、noneMatch、findFirst和findAny方法提供了这样的工具。

检查谓词是否至少匹配一个元素

anyMatch方法可以回答“流中是否有一个元素能匹配给定的谓词”。比如,你可以用它来看看菜单里面是否有素食可选择:

if(menu.stream().anyMatch(Dish::isVegetarian)){ 
 System.out.println("The menu is (somewhat) vegetarian friendly!!"); 
}

anyMatch方法返回一个boolean,因此是一个终端操作。

检查谓词是否匹配所有元素

allMatch方法的工作原理和anyMatch类似,但它会看看流中的元素是否都能匹配给定的谓词。

noneMatch

和allMatch相对的是noneMatch。它可以确保流中没有任何元素与给定的谓词匹配。

anyMatch、allMatch和noneMatch这三个操作都用到了我们所谓的短路,这就是大家熟悉的Java中&&和||运算符短路在流中的版本。

短路求值
有些操作不需要处理整个流就能得到结果。例如,假设你需要对一个用and连起来的大布尔表达式求值。不管表达式有多长,你只需找到一个表达式为false,就可以推断整个表达式将返回false,所以用不着计算整个表达式。这就是短路。
对于流而言,某些操作(例如allMatch、anyMatch、noneMatch、findFirst和findAny)不用处理整个流就能得到结果。只要找到一个元素,就可以有结果了。同样,limit也是一个短路操作:它只需要创建一个给定大小的流,而用不着处理流中所有的元素。在碰到无限大小的流的时候,这种操作就有用了:它们可以把无限流变成有限流。

查找元素

findAny方法将返回当前流中的任意元素。它可以与其他流操作结合使用。比如,你可能想找到一道素食菜肴。你可以结合使用filter和findAny方法来实现这个查询:

Optional<Dish> dish = 
	menu.stream()
	    .filter(Dish::isVegetarian)
	    .findAny();

流水线将在后台进行优化使其只需走一遍,并在利用短路找到结果时立即结束。

查找第一个元素

有些流有一个出现顺序(encounter order)来指定流中项目出现的逻辑顺序(比如由List或排序好的数据列生成的流)。对于这种流,你可能想要找到第一个元素。为此有一个findFirst方法,它的工作方式类似于findany。
例如,给定一个数字列表,下面的代码能找出第一个平方能被3整除的数:

List<Integer> someNumbers = Arrays.asList(1, 2, 3, 4, 5); 
Optional<Integer> firstSquareDivisibleByThree = 
	 someNumbers.stream()
			    .map(x -> x * x) 
			    .filter(x -> x % 3 == 0)
			    .findFirst(); // 9

何时使用findFirst和findAny
    你可能会想,为什么会同时有findFirst和findAny呢?答案是并行。找到第一个元素在并行上限制更多。如果你不关心返回的元素是哪个,请使用findAny,因为它在使用并行流时限制较少。

归约

下面,你将看到如何把一个流中的元素组合起来,使用reduce操作来表达更复杂的查询,比如“计算菜单中的总卡路里”或“菜单中卡路里最高的菜是哪一个”。此类查询需要将流中所有元素反复结合起来,得到一个值,比如一个Integer。这样的查询可以被归类为归约操作(将流归约成一个值)。用函数式编程语言的术语来说,这称为折叠(fold),因为你可以将这个操作看成把一张长长的纸(你的流)反复折叠成一个小方块,而这就是折叠操作的结果。

元素求和

你可以像下面这样对流中所有的元素求和:

int sum = numbers.stream().reduce(0, (a, b) -> a + b);

reduce接受两个参数:

  • 一个初始值,这里是0;
  • 一个BinaryOperator来将两个元素结合起来产生一个新值,这里我们用的是lambda (a, b) -> a + b。

在Java 8中,Integer类现在有了一个静态的sum
方法来对两个数求和,这恰好是我们想要的,用不着反复用Lambda写同一段代码了:

int sum = numbers.stream().reduce(0, Integer::sum);

reduce还有一个重载的变体,它不接受初始值,但是会返回一个Optional对象:

Optional<Integer> sum = numbers.stream().reduce((a, b) -> (a + b));

为什么它返回一个Optional<Integer>呢?考虑流中没有任何元素的情况。reduce操作无法返回其和,因为它没有初始值。这就是为什么结果被包裹在一个Optional对象里,以表明和可能不存在。

最大值和最小值
Optional<Integer> max = numbers.stream().reduce(Integer::max);

要计算最小值,你需要把Integer.min传给reduce来替Integer.max:

Optional<Integer> min = numbers.stream().reduce(Integer::min);

归约方法的优势与并行化

相比于前面写的逐步迭代求和,使用reduce的好处在于,这里的迭代被内部迭代抽象掉了,这让内部实现得以选择并行执行reduce操作。而迭代式求和例子要更新共享变量sum,这不是那么容易并行化的。如果你加入了同步,很可能会发现线程竞争抵消了并行本应带来的性能提升!这种计算的并行化需要另一种办法:将输入分块,分块求和,最后再合并起来。但这样的话代码看起来就完全不一样了。你在第7章会看到使用分支/合并框架来做是什么样子。
但现在重要的是要认识到,可变的累加器模式对于并行化来说是死路一条。你需要一种新的模式,这正是reduce所提供的。

int sum = numbers.parallelStream().reduce(0, Integer::sum);
但要并行执行这段代码也要付一定代价:传递给reduce的Lambda不能更改状态(如实例变量),而且操作必须满足结合律才可以按任意顺序执行。
流操作:无状态和有状态

    你已经看到了很多的流操作。乍一看流操作简直是灵丹妙药,而且只要在从集合生成流的时候把Stream换成parallelStream就可以实现并行。
    当然,对于许多应用来说确实是这样,就像前面的那些例子。你可以把一张菜单变成流,用filter选出某一类的菜肴,然后对得到的流做map来对卡路里求和,最后reduce得到菜单的总热量。这个流计算甚至可以并行进行。但这些操作的特性并不相同。它们需要操作的内部状态还是有些问题的。
    诸如map或filter等操作会从输入流中获取每一个元素,并在输出流中得到0或1个结果。这些操作一般都是无状态的:它们没有内部状态(假设用户提供的Lambda或方法引用没有内部可变状态)。
    但诸如reduce、sum、max等操作需要内部状态来累积结果。在上面的情况下,内部状态很小。在我们的例子里就是一个int或double。不管流中有多少元素要处理,内部状态都是有界的。
    相反,诸如sort或distinct等操作一开始都和filter和map差不多——都是接受一个流,再生成一个流(中间操作),但有一个关键的区别。从流中排序和删除重复项时都需要知道先前的历史。例如,排序要求所有元素都放入缓冲区后才能给输出流加入一个项目,这一操作的存储要求是无界的。要是流比较大或是无限的,就可能会有问题(把质数流倒序会做什么呢?它应当返回最大的质数,但数学告诉我们它不存在)。我们把这些操作叫作有状态操作

中间操作和终端操作
操作类型返回类型使用的类型/函数式接口函数描述符
filter中间Stream<T>Predicate<T>T -> boolean
distinct中间(有状态-无界)
skip中间 (有状态-有界)Stream<T>long
limit中间 (有状态-有界)Stream<T>long
map中间Stream<R>Function<T, R>T -> R
flatMap中间Stream<R>Function<T, Stream<R>>T -> Stream<R>
sorted中间(有状态-无界)Stream<T>Comparator<T>(T, T) -> int
anyMatch终端booleanPredicate<T>T -> boolean
noneMatch终端booleanPredicate<T>T -> boolean
allMatch终端booleanPredicate<T>T -> boolean
findAny终端Optional<T>
findFirst终端Optional<T>
forEach终端voidConsumer<T>T -> void
collect终端RCollector<T, A, R>
reduce终端(有状态-有界)Optional<T>BinaryOperator<T>(T, T) -> T
count终端long
示例

以下是你要处理的领域,一个Traders和Transactions的列表:

//数据准备
Trader raoul = new Trader("Raoul", "Cambridge");
        Trader mario = new Trader("Mario", "Milan");
        Trader alan = new Trader("Alan", "Cambridge");
        Trader brian = new Trader("Brian", "Cambridge");

        List<Transactions> transactions = Arrays.asList(
                new Transactions(brian, 2011, 300),
                new Transactions(raoul, 2012, 1000),
                new Transactions(raoul, 2011, 400),
                new Transactions(mario, 2012, 710),
                new Transactions(mario, 2012, 700),
                new Transactions(alan, 2012, 950)
        );
  //Trader 与Transactions 结构如下 省略get和set
  public class Transactions {

    private Trader trader;
    private int year;
    private int value;

    public Transactions(Trader trader, int year, int value) {
        this.trader = trader;
        this.year = year;
        this.value = value;
    }
    //get和set
    ...
}

public class Trader {

    private String name;
    private String city;

    public Trader(String name, String city) {
        this.name = name;
        this.city = city;
    }
    //get和set
    ...
}
  1. 找出2011年的所有交易并按交易额排序(从低到高)
//找出2011年的所有交易并按交易额排序(从低到高)
        transactions.stream()
                    .filter(t -> t.getYear() == 2011)
                    .sorted(comparing(Transactions::getValue))
                    .collect(toList()).forEach(System.out::println);
  1. 交易员都在哪些不同的城市工作过
 transactions.stream()
             .map(t -> t.getTrader().getCity())
             .distinct()
             .collect(toList()).forEach(System.out::println);
//交易员都在哪些不同的城市工作过 使用set
transactions.stream()
            .map(t -> t.getTrader().getCity())
            .collect(Collectors.toSet())
            .forEach(System.out::println);
  1. 查找所有来自于剑桥的交易员,并按姓名排序
transactions.stream()
            .map(Transactions::getTrader)
            .filter(t -> Objects.equals(t.getCity(), "Cambridge"))
            .distinct()
            .sorted(comparing(Trader::getName))
            .collect(toList()).forEach(System.out::println);
  1. 返回所有交易员的姓名字符串,按字母顺序排序
 String nameStr = transactions.stream()
               .map(t -> t.getTrader().getName())
               .distinct()
               .sorted()
               .collect(joining());//joining 使用StringBuilder
       System.out.println("name=>" + nameStr);
  1. 有没有交易员是在米兰工作的
 boolean milan =
               transactions.stream()
                           .anyMatch(t -> Objects.equals(t.getTrader()
                           .getCity(), "Milan"));

       System.out.println("有没有交易员是在米兰工作的:" + milan);
  1. 打印生活在剑桥的交易员的所有交易额 方法1
transactions.stream()
            .filter(t -> t.getTrader().getCity().equals("Cambridge"))
            .mapToInt(t -> t.getValue()).forEach(System.out::println);
  1. 所有交易中,最高的交易额是多少
int maxValue = transactions.stream()
   		                   .mapToInt(t -> t.getValue())
   		                   .max().orElse(0);
       System.out.println("所有交易中,最高的交易额是多少=>" + maxValue);
  1. 找到交易额最小的交易 方法1
Transactions minTransactions =
   transactions.stream()
               .sorted(comparing(Transactions::getValue))
               .findFirst().get();
       System.out.println("找到交易额最小的交易=>" + minTransactions);
       
   //找到交易额最小的交易 方法2
       Optional<Transactions> mintransaction2 = 
       transactions.stream()
                   .min(comparing(Transactions::getValue));
       System.out.println("找到交易额最小的交易2=>" + mintransaction2);

数值流

原始类型流特化

Java 8引入了三个原始类型特化流接口来解决这个问题:IntStreamDoubleStreamLongStream,分别将流中的元素特化为int、double和long,从而避免了暗含的装箱成本。每个接口都带来了进行常用数值归约的新方法,比如对数值流求和的sum,找到最大元素的max。此外还有在必要时再把它们转换回对象流的方法。要记住的是,这些特化的原因并不在于流的复杂性,而是装箱造成的复杂性——即类似int和Integer之间的效率差异。

映射到数值流

将流转换为特化版本的常用方法是mapToInt、mapToDouble和mapToLong。这些方法和前面说的map方法的工作方式一样,只是它们返回的是一个特化流,而不是Stream<T>。例如,你可以像下面这样用mapToInt对menu中的卡路里求和:

//这里,mapToInt会从每道菜中提取热量(用一个Integer表示),并返回一个IntStream
//(而不是一个Stream<Integer>)。然后你就可以调用IntStream接口中定义的sum方法,对卡
//路里求和了!请注意,如果流是空的,sum默认返回0。IntStream还支持其他的方便方法,如
//max、min、average等。
int calories = menu.stream() 
				   .mapToInt(Dish::getCalories) 
				   .sum();		  
转换回对象流

同样,一旦有了数值流,你可能会想把它转换回非特化流。例如,IntStream上的操作只能产生原始整数: IntStream 的 map 操作接受的Lambda 必须接受 int 并返回 int (一个IntUnaryOperator)。但是你可能想要生成另一类值,比如Dish。为此,你需要访问Stream接口中定义的那些更广义的操作。要把原始流转换成一般流(每个int都会装箱成一个
Integer),可以使用boxed方法,如下所示:

//将Stream转换为数值流
IntStream intStream =menu.stream().mapToInt(Dish::getCalories); 
//将数值流转换为Stream
Stream<Integer> stream = intStream.boxed();
默认值OptionalInt

求和的那个例子很容易,因为它有一个默认值:0。但是,如果你要计算IntStream中的最大元素,就得换个法子了,因为0是错误的结果。如何区分没有元素的流和最大值真的是0的流呢?
前面我们介绍了Optional类,这是一个可以表示值存在或不存在的容器。Optional可以用Integer、String等参考类型来参数化。对于三种原始流特化,也分别有一个Optional原始类型特化版本:OptionalInt、OptionalDouble和OptionalLong。

//例如,要找到IntStream中的最大元素,可以调用max方法,它会返回一个OptionalInt:
OptionalInt maxCalories = menu.stream()
							  .mapToInt(Dish::getCalories) 
							  .max();
//现在,如果没有最大值的话,你就可以显式处理OptionalInt去定义一个默认值了:
int max = maxCalories.orElse(1);							  
数值范围

和数字打交道时,有一个常用的东西就是数值范围。比如,假设你想要生成1和100之间的所有数字。Java 8引入了两个可以用于IntStream和LongStream的静态方法,帮助生成这种范围:
range和rangeClosed。这两个方法都是第一个参数接受起始值,第二个参数接受结束值。但range是不包含结束值的,而rangeClosed则包含结束值。

//一个从1到100的偶数流
IntStream evenNumbers = IntStream.rangeClosed(1, 100) 
                                 .filter(n -> n % 2 == 0); 
System.out.println(evenNumbers.count());

示例:勾股数

        Stream<int[]> stream = IntStream.rangeClosed(1, 100)
                .boxed()
                .flatMap(a ->
                        IntStream.rangeClosed(a, 100)
                                 .filter(b -> Math.sqrt(a * a + b * b) % 1 == 0)
                                 .mapToObj(n -> new int[]{a, n, (int) Math.sqrt(a * a + n * n)})
                );
       //好的,flatMap又是怎么回事呢?首先,创建一个从1到100的数值范围来生成a的值。对每
        //个给定的a值,创建一个三元数流。要是把a的值映射到三元数流的话,就会得到一个由流构成的
        //流。flatMap方法在做映射的同时,还会把所有生成的三元数流扁平化成一个流。这样你就得到
        //了一个三元数流。还要注意,我们把b的范围改成了a到100。没有必要再从1开始了,否则就会造
        //成重复的三元数,例如(3,4,5)和(4,3,5)。

        stream.limit(5)
                .forEach(t ->
                        System.out.println(t[0] + ", " + t[1] + ", " + t[2]));
          //方法2:
          Stream<double[]> stream1 = IntStream.rangeClosed(1, 100)
                .boxed()
                .flatMap(a -> IntStream.rangeClosed(a, 100)
                        .mapToObj(b -> new double[]{a, b, Math.sqrt(a * a + b * b)})
                        .filter(c -> c[2] % 1 == 0)
                );
          stream1.limit(5).forEach(t->
                        System.out.println(t[0] + ", " + t[1] + ", " + t[2])
                );
构建流

本节将介绍如何从值序列、数组、文件来创建流,甚至由生成函数来创建无限流!

由值创建流
Stream<String> stream = Stream.of("Java 8 ", "Lambdas ", "In ", "Action"); 
stream.map(String::toUpperCase).forEach(System.out::println);
由数组创建流
int[] numbers = {2, 3, 5, 7, 11, 13}; 
//总和是41
int sum = Arrays.stream(numbers).sum();
由文件生成流

Java中用于处理文件等I/O操作的NIO API(非阻塞 I/O)已更新,以便利用Stream API。java.nio.file.Files中的很多静态方法都会返回一个流。例如,一个很有用的方法是Files.lines,它会返回一个由指定文件中的各行构成的字符串流。

//法看看一个文件中有多少各不相同的词
try {
            Files.lines(Paths.get("data.txt"), Charset.defaultCharset())
                 .flatMap(c -> Arrays.stream(c.split(" ")))
                 .distinct()
                 .count();
        } catch (IOException e) {
            e.printStackTrace();
        }
由函数生成流:创建无限流

Stream API提供了两个静态方法来从函数生成流:Stream.iterate和Stream.generate。这两个操作可以创建所谓的无限流:不像从固定集合创建的流那样有固定大小的流。由iterate和generate产生的流会用给定的函数按需创建值,因此可以无穷无尽地计算下去!一般来说,应该使用limit(n)来对这种流加以限制,以避免打印无穷多个值。

1. 迭代
        //无限流 从1开始求偶数
        Stream.iterate(1, n -> n * 2)
                .limit(10)
                .forEach(System.out::println);
//斐波纳契元组序列 …数列中开始的两个数字是0和1,后续的每个数字都是前两个数字之和
 Stream.iterate(new int[]{0,1},n->new int[]{n[1],n[0]+n[1]})
                .limit(20)
                .forEach(i-> System.out.println("i=>("+i[0]+","+i[1]+")"));
2. 生成

与iterate方法类似,generate方法也可让你按需生成一个无限流。但generate不是依次对每个新生成的值应用函数的。它接受一个Supplier<T>类型的Lambda提供新的值。

  Stream.generate(Math::random)
                .limit(5)
                .forEach(System.out::println);
        /**结果:
         * 0.13250979726098233
         * 0.6992099222914313
         * 0.3244067290054605
         * 0.03260430237078982
         * 0.6365489426199937
         */

在并行代码中使用有状态的供应源是不安全的。

代码创建了一个IntSupplier的实例。此对象有可变的状态:它在两个实例变量中
记录了前一个斐波纳契项和当前的斐波纳契项。getAsInt在调用时会改变对象的状态,由此在
每次调用时产生新的值。相比之下,使用iterate的方法则是纯粹不变的:它没有修改现有状态,
但在每次迭代时会创建新的元组。你将后面了解到,你应该始终采用不变的方法,以便并行
处理流,并保持结果正确。请注意,因为你处理的是一个无限流,所以必须使用limit操作来显
式限制它的大小;否则,终端操作(这里是forEach)将永远计算下去。同样,你不能对无限流
做排序或归约,因为所有元素都需要处理,而这永远也完不成!
        IntSupplier fib = new IntSupplier() {
            private int previous = 0;
            private int current = 1;

            @Override
            public int getAsInt() {
                int oldPrevious = this.previous;
                int nextValue = this.previous + this.current;
                this.previous = this.current;
                this.current = nextValue;
                return oldPrevious;
            }
        };
        IntStream.generate(fib).limit(10).forEach(System.out::println);
小结
  • Stream API可以表达复杂的数据处理查询。
  • 你可以使用filter、distinct、skip和limit对流对筛选和切片。
  • 你可以使用map和flatmap提取或转换流中的元素。
  • 你可以使用findFirst和 findAny方法查找流中的元素。你可以用allMatch、noneMatch和anyMatch方法让流匹配给定的谓词。
  • 这些方法都利用了短路:找到结果就立即停止计算;没有必要处理整个流。
  • 你可以利用reduce方法将流中所有的元素迭代合并成一个结果,例如求和或查找最大元素。
  • filter和map等操作是无状态的,它们并不存储任何状态。reduce等操作要存储状态才能计算出一个值。sorted和distinct等操作也要存储状态,因为它们需要把流中的所有元素缓存起来才能返回一个新的流。这种操作称为有状态操作。
  • 流有三种基本的原始类型特化:IntStream、DoubleStream和LongStream。它们的操作也有相应的特化。
  • 流不仅可以从集合创建,也可从值、数组、文件以及iterate与generate等特定方法创建。
  • 无限流是没有固定大小的流。
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值