13、流式编程


   集合优化了对象的存储,而流和对象的处理有关。

   利用流,无需迭代集合中的元素,就可以提取和操作它们。组合在一起,在流上形成一条操作管道。

   lambda表达式、方法引用、流式编程结合使用。当 Lambda 表达式和方法引用(method references)和流一起使用的时候会让人感觉自成一体。

   声明式编程(Declarative programming)是一种:声明要做什么,而非怎么做的编程风格。正如我们在函数式编程中所看到的。

   流式编程采用内部迭代,这是流式编程的核心特性之一。这种机制使得编写的代码可读性更强,也更能利用多核处理器的优势。通过放弃对迭代过程的控制,我们把控制权交给并行化机制。
   另一个重要方面,流是懒加载的。这代表着它只在绝对必要时才计算。你可以将流看作“延迟列表”。由于计算延迟,流使我们能够表示非常大(甚至无限)的序列,而不需要考虑内存问题。

1、JDK13中对流的介绍

  Package java.util.stream
  这个包中引入的关键抽象是流。类Stream、IntStream、LongStream和DoubleStream是对象和原始int、long和double类型上的流。流与集合在以下几个方面不同:

  • 没有存储。流不是存储元素的数据结构;相反,它通过计算操作的管道传递来自源的元素,如数据结构、数组、生成器函数或I/O通道。
  • Functional in nature 。流上的操作产生结果,但不修改其源。例如,对从集合中获得的流进行过滤将产生一个没有过滤元素的新流,而不是从源集合中删除元素。
  • Laziness-seeking。许多流操作,如过滤、映射或重复删除,可以延迟实现,从而暴露了优化的机会。例如,“查找包含三个连续元音的第一个字符串”不需要检查所有的输入字符串。流操作分为中间(流生产)操作和终端(值或副作用生产)操作。中间操作总是延迟的。
  • 可能是无限的。集合的大小是有限的,而流则不必如此。诸如limit(n)或findFirst()之类的短路操作允许在有限时间内完成无限流上的计算。
  • consumable。流的元素在流的生命周期中只访问一次。与迭代器一样,必须生成新的流来重新访问源的相同元素。

  流可以通过多种方式获得。一些例子包括:

2、流支持

   如何将一个全新的流的概念融入到现有类库中呢?
   接口部分怎么改造呢?特别是涉及集合类接口的部分。如果你想把一个集合转换为流,直接向接口添加新方法会破坏所有老的接口实现类。

   Java 8 采用的解决方案是:在接口中添加被 default(默认)修饰的方法。通过这种方案,设计者们可以将流式(stream)方法平滑地嵌入到现有类中。流方法预置的操作几乎已满足了我们平常所有的需求。流操作的类型有三种:

  • 创建流

  • 修改流元素(中间操作, Intermediate Operations)

  • 消费流元素(终端操作, Terminal Operations)通常意味着收集流元素(通常是到集合中)。

3、流创建

Stream.of()

   通过Stream().of()将一组元素转化为流

import java.util.stream.*;
public class StreamOf {
   public static void main(String[] args) {
       Stream.of("It's ", "a ", "wonderful ", "day ", "for ", "pie!")
           .forEach(System.out::print);
       System.out.println();
       Stream.of(3.14159, 2.718, 1.618)
           .forEach(System.out::println);
   }
}

输出结果:
在这里插入图片描述
   每个集合都可以通过调用 stream() 方法来产生一个流。

import java.util.*;
import java.util.stream.*;

public class CollectionToStream {
    public static void main(String[] args) {
        Set<String> w = new HashSet<>(Arrays.asList("It's a wonderful day for pie!".split(" ")));
        w.stream()
        .map(x -> x + "")
        .forEach(System.out::println);
        
    }
}

range()

IntStream 类提供了 range() 方法用于生成整型序列的流。编写循环时,这个方法会更加便利

import static java.util.stream.IntStream.*;

public class Ranges {
    public static void main(String[] args) {
        System.out.println(range(10, 20).sum());
        Repeat.repeat(5, Run::run);
    }
}

class Run {
    public static void run() {
        System.out.println("this is run");
    }
}

class Repeat {
    public static void repeat(int n, Runnable runnable) {
        range(0, n).forEach(i -> runnable.run());
    }
}

实用小功能 repeat() 可以用来替换简单的 for 循环。代码示例:

import static java.util.stream.IntStream.*;
public class Repeat {
    public static void repeat(int n, Runnable action) {
        range(0, n).forEach(i -> action.run());
    }
}

static Stream generate​(Supplier<? extends T> s)

返回一个无限连续的无序流,其中每个元素由提供的Supplier<? extends T>生成。这适用于生成常量流、随机元素流等。

static <T> Stream<T> iterate​(T seed,Predicate<? super T> hasNext,UnaryOperator<T> next)

Stream.iterate() 以种子(第一个参数)开头,并将其传给方法(第二个参数)。方法的结果将添加到流,并存储作为第一个参数用于下次调用 iterate(),依次类推。

以满足给定的hasNext谓词为条件,将给定的下一个函数迭代应用于初始元素,从而返回一个连续的有序流。一旦hasNext谓词返回false,流就终止。

Stream.iterate应该生成与对应的for循环生成的元素序列相同的元素序列:

for (T index=seed; hasNext.test(index); index = next.apply(index)) {
         ...
     }

流的建造者模式

Interface Stream.Builder
在建造者设计模式(也称构造器模式)中,首先创建一个 builder 对象,传递给它多个构造器信息,最后执行“构造”。Stream 库提供了这样的 Builder。

Arrays.stream()

Arrays 类中含有一个名为 stream() 的静态方法用于把数组转换成为流。

正则表达式

Java 8 在 java.util.regex.Pattern 中增加了一个新的方法 splitAsStream()。这个方法可以根据传入的公式将字符序列转化为流。但是有一个限制,输入只能是 CharSequence,因此不能将流作为 splitAsStream() 的参数。

中间操作

跟踪和调试

peek() 操作的目的是帮助调试。它允许你无修改地查看流中的元素。

流元素排序

Stream sorted()
返回由该流的元素组成的流,按自然顺序排序。如果此流的元素不可比较,则使用java.lang。在执行终端操作时,可能会抛出ClassCastException。
对于有序流,排序是稳定的。对于无序流,没有稳定性保证。

Stream sorted​(Comparator<? super T> comparator)
返回一个由这个流的元素组成的流,根据提供的比较器进行排序。
对于有序流,排序是稳定的。对于无序流,没有稳定性保证。

sorted()中可以传入一个 Comparator 参数
sorted() 预设了一些默认的比较器.
也可以把 Lambda 函数作为参数传递给 sorted().

移除元素

返回由该流的不同元素(根据Object.equals(Object))组成的流。
对于有序流,不同元素的选择是稳定的(对于重复的元素,首先出现在相遇顺序中的元素将被保留)。对于无序流,没有稳定性保证。
Stream distinct()

返回一个由与给定谓词匹配的流的元素组成的流。
Stream filter​(Predicate<? super T> predicate)

应用函数到元素

 `<R> Stream<R> map​(Function<? super T,? extends R> mapper)`

返回一个流,该流包含将给定函数应用于该流元素的结果。传入的函数式接口参数为 ? super T。
将作用的结果放入Stream中,得到的是元素流的流,不关闭原来的流,输入流和输出流是一对一的映射。

  • IntStream mapToInt​(ToIntFunction<? super T> mapper) 返回一个IntStream,其中包含将给定函数应用于该流元素的结果。
  • mapToLong(ToLongFunction):操作同上,但结果是 LongStream
  • mapToDouble(ToDoubleFunction):操作同上,但结果是 DoubleStream。

在 map() 中组合流

<R> Stream<R> 	flatMap​(Function<? super T,? extends Stream<? extends R>> mapper)

将提供的映射函数应用于每个元素,
返回一个流,该流包含将此流的每个元素替换为通过将提供的映射函数应用于每个元素而生成的映射流的内容的结果。

flatMap() 做了两件事:将产生流的函数应用在每个元素上(与 map() 所做的相同),然后将每个流都扁平化为元素,也就是在函数作用后关闭原来的流,把作用后得到的元素放到新的输出流中。将多个输入流的整合为一个

flatMap(Function):当 Function 产生流时使用。

flatMapToInt(Function):当 Function 产生 IntStream 时使用。

flatMapToLong(Function):当 Function 产生 LongStream 时使用。

flatMapToDouble(Function):当 Function 产生 DoubleStream 时使用。

import java.util.stream.*;

public class TestMapFlatMap {
    public static void main(String[] args) {
        String[] strings = new String[]{"liu yang", "lee bruce"};
        Stream<String> stringStream1 = Stream.of(strings);
        Stream<String> stringStream2 = Stream.of(strings);
        stringStream1.map(s -> s.split(" ")).forEach(System.out::println);
        stringStream2.flatMap(s -> Stream.of(s.split(" "))).forEach(System.out::println);
    }
}

在这里插入图片描述

import java.util.stream.*;

public class TestMapFlatMap {
    public static void main(String[] args) {
        String[] strings = new String[]{"liu yang", "lee bruce"};
        Stream<String> stringStream1 = Stream.of(strings);
        Stream<String> stringStream2 = Stream.of(strings);
        Stream<String> stringStream3 = Stream.of(strings);
        Stream<String> stringStream4 = Stream.of(strings);
        stringStream1.map(s -> s.split(" ")).forEach(System.out::println);
        stringStream2.map(s -> Stream.of(s.split(" "))).forEach(System.out::println);
        //下面写法错误
        //stringStream4.flatMap(s -> s.split(" ")).forEach(System.out::println);
        stringStream4.flatMap(s -> Stream.of(s.split(" "))).forEach(System.out::println);  
    }
}

在这里插入图片描述

import java.util.stream.*;

public class TestMapFlatMap {
    public static void main(String[] args) {
        String[] strings = new String[]{"liu yang", "lee bruce"};
        Stream<String> stringStream1 = Stream.of(strings);
        Stream<String> stringStream2 = Stream.of(strings);
        Stream<String> stringStream3 = Stream.of(strings);
        Stream<String> stringStream4 = Stream.of(strings);
        stringStream1.map(s -> s.split(" ")).forEach(System.out::println);//Stream<String[]>
        stringStream2.map(s -> Stream.of(s.split(" "))).forEach(System.out::println);//Stream<Stream<String>>
        stringStream4.flatMap(s -> Stream.of(s.split(" "))).forEach(System.out::println);  //Stream<String>
    }
}

在这里插入图片描述flatMap中lambda表达式的返回应当是流

Optional类

是否有某种对象,可作为流元素的持有者,即使查看的元素不存在也能友好地提示我们(也就是说,不会发生异常)?
Optional 可以实现这样的功能。一些标准流操作返回 Optional 对象,因为它们并不能保证预期结果一定存在。包括:

  • findFirst() 返回一个包含第一个元素的 Optional 对象,如果流为空则返回 Optional.empty

  • findAny() 返回包含任意元素的 Optional 对象,如果流为空则返回 Optional.empty

  • max() 和 min() 返回一个包含最大值或者最小值的 Optional 对象,如果流为空则返回 Optional.empty

  • reduce() 不再以 identity 形式开头,而是将其返回值包装在 Optional 中。(identity 对象成为其他形式的
    reduce() 的默认结果,因此不存在空结果的风险)

注意,空流是通过 Stream.empty() 创建的。如果你在没有任何上下文环境的情况下调用 Stream.empty(),Java 并不知道它的数据类型;这个语法解决了这个问题。

便利函数

有许多便利函数可以解包 Optional ,这简化了上述“对所包含的对象的检查和执行操作”的过程:

  • ifPresent(Consumer):当值存在时调用 Consumer,否则什么也不做。
  • orElse(otherObject):如果值存在则直接返回,否则生成 otherObject。
  • orElseGet(Supplier):如果值存在则直接返回,否则使用 Supplier 函数生成一个可替代对象。
  • orElseThrow(Supplier):如果值存在直接返回,否则使用 Supplier 函数生成一个异常。

创建Optional

当我们在自己的代码中加入 Optional 时,可以使用下面 3 个静态方法:

  • empty():生成一个空 Optional。
  • of(value):将一个非空值包装到 Optional 里。
  • ofNullable(value):针对一个可能为空的值,为空时自动生成 Optional.empty,否则将值包装在 Optional
    中。

我们不能通过传递 null 到 of() 来创建 Optional 对象。最安全的方法是, 使用 ofNullable() 来优雅地处理 null。

Optional 对象操作

当我们的流管道生成了 Optional 对象,下面 3 个方法可使得 Optional 的后续能做更多的操作:

  • filter(Predicate):将 Predicate 应用于 Optional 中的内容并返回结果。当 Optional 不满足Predicate 时返回空。如果 Optional 为空,则直接返回。

  • map(Function):如果 Optional 不为空,应用 Function 于 Optional中的内容,并返回结果。否则直接返回 Optional.empty。

  • flatMap(Function):同 map(),但是提供的映射函数将结果包装在 Optional 对象中,因此 flatMap()不会在最后进行任何包装。

以上方法都不适用于数值型 Optional。一般来说,流的 filter() 会在 Predicate 返回 false 时移除流元素。而 Optional.filter() 在失败时不会删除 Optional,而是将其保留下来,并转化为空。

同 map() 一样 , Optional.map() 应用于函数。它仅在 Optional 不为空时才应用映射函数,并将 Optional 的内容提取到映射函数。

同 Optional.map(),Optional.flatMap() 将提取非空 Optional 的内容并将其应用在映射函数。唯一的区别就是 flatMap() 不会把结果包装在 Optional 中,因为映射函数已经被包装过了。

Optional 流

假设你的生成器可能产生 null 值,那么当用它来创建流时,你会自然地想到用 Optional 来包装元素

终端操作

这些操作接收一个流并产生一个最终结果;它们不会向后端流提供任何东西。因此,终端操作总是你在管道中做的最后一件事情

转化成数组

  • toArray():将流转换成适当类型的数组。

  • toArray(generator):在特殊情况下,生成器用于分配自定义的数组存储。
    这组方法在流操作产生的结果必须是数组形式时很有用。假如我们想在流里复用获取的随机数,可以将他们保存到数组中。

forEach

应用最终的操作

  • forEach(Consumer):你已经看到过很多次 System.out::println 作为 Consumer 函数
  • forEachOrdered(Consumer): 保证 forEach 按照原始流顺序操作。

第一种形式:显式设计为任意顺序操作元素,仅在引入 parallel() 操作时才有意义。parallel():可实现多处理器并行操作。实现原理为将流分割为多个(通常数目为 CPU 核心数)并在不同处理器上分别执行操作。因为我们采用的是内部迭代,而不是外部迭代,所以这是可能实现的。

import java.util.*;
import java.util.stream.*;

public class ForEach {
    private static int[] rints = new Random(47).ints(0, 1000).limit(100).toArray();
    public static IntStream rands() {
        return Arrays.stream(rints);
    }
    static final int SZ = 14;
    public static void main(String[] args) {
        rands().limit(SZ).forEach(n -> System.out.format("%d ", n));
        System.out.println();
        rands().limit(SZ).parallel().forEach(n -> System.out.format("%d ", n));
        System.out.println();
        rands().limit(SZ)
                .parallel()
                .forEachOrdered(n -> System.out.format("%d ", n));
    }
}

运行结果
在这里插入图片描述

收集

  • collect(Collector):使用 Collector 收集流元素到结果集合中。

  • collect(Supplier, BiConsumer, BiConsumer):同上,第一个参数 Supplier
    创建了一个新结果集合,第二个参数 BiConsumer 将下一个元素包含到结果中,第三个参数 BiConsumer 用于将两个值组合起来。

可以通过将集合的构造函数引用传递给 Collectors.toCollection(),从而构建任何类型的集合。
比如:.collect(Collectors.toCollection(TreeSet::new));

// streams/TreeSetOfWords.java
import java.util.*;
import java.nio.file.*;
import java.util.stream.*;
public class TreeSetOfWords {
    public static void
    main(String[] args) throws Exception {
        Set<String> words2 =
                Files.lines(Paths.get("TreeSetOfWords.java"))
                        .flatMap(s -> Arrays.stream(s.split("\\W+")))
                        .filter(s -> !s.matches("\\d+")) // No numbers
                        .map(String::trim)
                        .filter(s -> s.length() > 2)
                        .limit(100)
                        .collect(Collectors.toCollection(TreeSet::new));
        System.out.println(words2);
    }
}

Files.lines() 打开 Path 并将其转换成为行流。下一行代码将匹配一个或多个非单词字符(\w+)行进行分割,然后使用 Arrays.stream() 将其转化成为流,并将结果扁平映射成为单词流。使用 matches(\d+) 查找并移除全数字字符串(注意,words2 是通过的)。接下来我们使用 String.trim() 去除单词两边的空白,filter() 过滤所有长度小于3的单词,紧接着只获取100个单词,最后将其保存到 TreeSet 中。

我们也可以在流中生成 Map。代码示例:

// streams/MapCollector.java
import java.util.*;
import java.util.stream.*;
class Pair {
    public final Character c;
    public final Integer i;
    Pair(Character c, Integer i) {
        this.c = c;
        this.i = i;
    }
    public Character getC() { return c; }
    public Integer getI() { return i; }
    @Override
    public String toString() {
        return "Pair(" + c + ", " + i + ")";
    }
}
class RandomPair {
    Random rand = new Random(47);
    // An infinite iterator of random capital letters:
    Iterator<Character> capChars = rand.ints(65,91)
            .mapToObj(i -> (char)i)
            .iterator();
    public Stream<Pair> stream() {
        return rand.ints(100, 1000).distinct()
                .mapToObj(i -> new Pair(capChars.next(), i));
    }
}
public class MapCollector {
    public static void main(String[] args) {
        Map<Integer, Character> map =
                new RandomPair().stream()
                        .limit(8)
                        .collect(
                                Collectors.toMap(Pair::getI, Pair::getC));
        System.out.println(map);
    }
}

在 Java 中,我们不能直接以某种方式组合两个流。所以这里创建了一个整数流,并且使用 mapToObj() 将其转化成为 Pair 流。

在大多数情况下,你可以在 java.util.stream.Collectors寻找到你想要的预先定义好的 Collector。在少数情况下当你找不到想要的时候,你可以使用第二种形式的 collect()。

// streams/SpecialCollector.java
import java.util.*;
import java.util.stream.*;
public class SpecialCollector {
    public static void main(String[] args) throws Exception {
        ArrayList<String> words =
                FileToWords.stream("Cheese.dat")
                        .collect(ArrayList::new,
                                ArrayList::add,
                                ArrayList::addAll);
        words.stream()
                .filter(s -> s.equals("cheese"))
                .forEach(System.out::println);
    }
}

组合所有流元素

  • reduce(BinaryOperator):使用 BinaryOperator 来组合所有流中的元素。因为流可能为空,其返回值为Optional。
  • reduce(identity, BinaryOperator):功能同上,但是使用 identity 作为其组合的初始值。因此如果流为空,identity 就是结果。
  • reduce(identity, BiFunction, BinaryOperator):这个形式更为复杂(所以我们不会介绍它),在这里被提到是因为它使用起来会更有效。通常,你可以显式地组合 map() 和 reduce() 来更简单的表达它。
// streams/Reduce.java
import java.util.*;
import java.util.stream.*;
class Frobnitz {
    int size;
    Frobnitz(int sz) { size = sz; }
    @Override
    public String toString() {
        return "Frobnitz(" + size + ")";
    }
    // Generator:
    static Random rand = new Random(47);
    static final int BOUND = 100;
    static Frobnitz supply() {
        return new Frobnitz(rand.nextInt(BOUND));
    }
}
public class Reduce {
    public static void main(String[] args) {
        Stream.generate(Frobnitz::supply)
                .limit(10)
                .peek(System.out::println)
                .reduce((fr0, fr1) -> fr0.size < 50 ? fr0 : fr1)
                .ifPresent(System.out::println);
    }
}

Lambda 表达式中的第一个参数 fr0 是上一次调用 reduce() 的结果。而第二个参数 fr1 是从流传递过来的值。

reduce() 中的 Lambda 表达式使用了三元表达式来获取结果,当其 size 小于 50 的时候获取 fr0 否则获取序列中的下一个值 fr1。因此你会取得第一个 size 小于 50 的 Frobnitz,只要找到了就这个结果就会紧紧地攥住它,即使有其他候选者出现。虽然这是一个非常奇怪的约束,但是它确实让你对 reduce() 有了更多的了解。

匹配

  • allMatch(Predicate) :如果流的每个元素根据提供的 Predicate 都返回 true 时,结果返回为
    true。这个操作将会在第一个 false 之后短路;也就是不会在发生 false 之后继续执行计算。

  • anyMatch(Predicate):如果流中的任意一个元素根据提供的 Predicate 返回 true 时,结果返回为
    true。这个操作将会在第一个 true 之后短路;也就是不会在发生 true 之后继续执行计算。

  • noneMatch(Predicate):如果流的每个元素根据提供的 Predicate 都返回 false 时,结果返回为
    true。这个操作将会在第一个 true 之后短路;也就是不会在发生 true 之后继续执行计算。

import java.util.function.BiPredicate;

interface Matcher extends BiPredicate<Stream<Integer>,Predicate<Integer>> {}

public class Matching {
    static void show(Matcher match, int val) {
    System.out.println(match.test(IntStream
    .rangeClosed(1, 9).boxed().peek(n -> System.out.format("%d ", n)), 
    n -> n < val));
    }

    public static void main(String[] args) {
        show(Stream::allMatch, 10);
        show(Stream::allMatch, 4);
        show(Stream::anyMatch, 2);
        show(Stream::anyMatch, 0);
        show(Stream::noneMatch, 5);
        show(Stream::noneMatch, 0);
    }
}

BiPredicate 是一个二元谓词,这意味着它只能接受两个参数并且只返回 true 或者 false。它的第一个参数是我们要测试的流,第二个参数是一个谓词 Predicate。因为 Matcher 适用于所有的 Stream::*Match 方法形式,所以我们可以传递每一个到 show() 中。match.test() 的调用会被转换成 Stream::*Match 函数的调用。

show() 获取两个参数,Matcher 匹配器和用于表示谓词测试 n < val 中最大值的 val。这个方法生成一个从 1 到 9 的整数流。peek() 是用于向我们展示测试在短路之前的情况。你可以在输出中发现每一次短路都会发生。

元素查找

  • findFirst():返回一个含有第一个流元素的 Optional,如果流为空返回 Optional.empty。

  • findAny(:返回含有任意流元素的 Optional,如果流为空返回 Optional.empty。

// streams/SelectElement.java
import java.util.*;
import java.util.stream.*;
import static streams.RandInts.*;
public class SelectElement {
    public static void main(String[] args) {
        System.out.println(rands().findFirst().getAsInt());
        System.out.println(
                rands().parallel().findFirst().getAsInt());
        System.out.println(rands().findAny().getAsInt());
        System.out.println(
                rands().parallel().findAny().getAsInt());
    }
}

findFirst() 无论流是否为并行化的,总是会选择流中的第一个元素。对于非并行流,findAny()会选择流中的第一个元素(即使从定义上来看是选择任意元素)。在这个例子中,我们使用 parallel() 来并行流从而引入 findAny() 选择非第一个流元素的可能性。

如果必须选择流中最后一个元素,那就使用 reduce()

// streams/LastElement.java
import java.util.*;
import java.util.stream.*;
public class LastElement {
    public static void main(String[] args) {
        OptionalInt last = IntStream.range(10, 20)
                .reduce((n1, n2) -> n2);
        System.out.println(last.orElse(-1));
        // Non-numeric object:
        Optional<String> lastobj =
                Stream.of("one", "two", "three")
                        .reduce((n1, n2) -> n2);
        System.out.println(
                lastobj.orElse("Nothing there!"));
    }
}

信息

  • count():流中的元素个数。
  • max(Comparator):根据所传入的 Comparator 所决定的“最大”元素。
  • min(Comparator):根据所传入的 Comparator 所决定的“最小”元素。

数字流信息

  • average() :求取流元素平均值。
  • max() 和 min():因为这些操作在数字流上面,所以不需要 Comparator。
  • sum():对所有流元素进行求和。
  • summaryStatistics():生成可能有用的数据。目前还不太清楚他们为什么觉得有必要这样做,因为你可以使用直接的方法产生所有的数据。
// streams/NumericStreamInfo.java
import java.util.stream.*;
import static streams.RandInts.*;
public class NumericStreamInfo {
    public static void main(String[] args) {
        System.out.println(rands().average().getAsDouble());
        System.out.println(rands().max().getAsInt());
        System.out.println(rands().min().getAsInt());
        System.out.println(rands().sum());
        System.out.println(rands().summaryStatistics());
    }
}

运行结果

507.94
998
8
50794
IntSummaryStatistics{count=100, sum=50794, min=8, average=507.940000, max=998}
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值