《Java 8 函数式编程》笔记

前记

在图书馆找有关 Java 的书时发现了这本动物出版社的书,大一时就听过 lambda,匿名函数之类的了(虽然两者不同),一直没机会接触前者,鉴于前段时间写的程序有好多匿名函数,很是冗余,本着好奇以及打算重构的心,就借来这本书打算学习一遍 lambda。本笔记按照章节顺序从第 2 章开始。

纸质书读起来很有滋味,建议买来或借来品味一番。

随书的资料在作者的 github 中可以下载到。

本笔记代码也全部放在 github 中,建议搭配代码食用~

设置断言

IdeaEclipse 都是默认不开启断言的,请先设置好。

lambda 表达式

第一行 lambda

使用匿名类将按钮和点击行为关联起来:

button.addActionListener(new ActionListener() {
    @Override
    public void actionPerformed(ActionEvent event) {
        System.out.println("clicked");
    }
});

使用 lambda 表达式:

button.addActionListener(event -> System.out.println("clicked"));

匿名类:
- 方法参数类型需要显式声明:ActionEvent event

lambda:
- 无需指定类型,javac 根据上下文推断出 event 的类型(addActionListener 方法的签名)

注:
1. lambda 声明参数时也可以包括参数类型(比如:(int x, int y) -> x + y);
2. 有时编译器不一定能根据上下文推断出参数类型,这时要显示声明。

lambda 表达式的不同形式:

// 该 Runnable 接口只有 1 个 void run() 方法
// 使用空括号 () 表示没有参数
Runnable noArguments = () -> System.out.println("Hello");

// 只有 1 个参数,可省略括号
ActionListener oneArgument = event -> System.out.println("clicked");

// lambda 主体不仅可以是表达式,也可以是一段代码块
// 用大括号 {} 括起来的代码块
// 遵循的规则和普通方法一样
Runnable multiStatement = () -> {
    System.out.println("Hello 1");
    System.out.println("Hello 2");
};

// 显式声明参数类型
BinaryOperator<Integer> addExplicitType = (Integer x, Integer y) -> x + y;

// 这段代码是创建一个函数,用来计算出两个数字相加的结果
// add 不是两个数字的和,而是将两个数字相加的那行代码
BinaryOperator<Integer> add = (x, y) -> x + y;
System.out.println(add.apply(1, 2));

引用值,而不是变量

匿名类中使用所在方法的变量时需要 final 修饰:

final String username = getUsername();

button.addActionListener(new ActionListener() {
    @Override
    public void actionPerformed(ActionEvent event) {
        System.out.println(username + " clicked");
    }
});

lambda 表达式中使用时虽然可以没有 final 修饰符,但实际上仍是 final 变量:

String username = getUsername();

button.addActionListener(event -> System.out.println(username + " clicked"));

使用 final 变量,实际上就是使用赋给该变量的一个特定值。

函数接口

函数接口:只有 1 个抽象方法的接口,用作 lambda 表达式的类型。

比如 ActionListener 接口:

public abstract ActionListener extends EventListener {
    public abstract void actionPerformed(ActionEvent event);
}

该单一抽象方法的命名并不重要,只要方法签名和 lambda 表达式的类型匹配即可。

一些最重要的函数接口:

接口参数返回类型示例
PredicateTboolean这张唱片发行了吗
ConsumerTvoid输出一个值
FunctionTR获得 A 对象的名字
SupplierNoneT工厂方法
UnaryOperatorTT逻辑非(!)
BinaryOperator(T,T)T求两个数的和(+)

从外部迭代到内部迭代

使用 for 循环统计来自美国的艺术家:

int count = 0;

for(Artist artist: allArtists) {
    if(artist.isFrom("US")) {
        count++;
    }
}

for 循环本质是封装了迭代的语法糖,外部迭代:

int count = 0;

Iterator<Artist> iterator = allArtists.iterator();
while(iterator.hasNext()) {
    Artist artist = (Artist) iterator.next();
    if(artist.isFrom("US")) {
        count++;
    }
}

外部迭代本质上是一种串行化操作。

使用内部迭代改写:

// stream() 方法和上面的 iterator() 作用一样
// 但该方法返回的是内部迭代中的相应接口:Stream
int count = allArtists.stream()
                      .filter(artist -> artist.isFrom("US"))
                      .count();

注:Stream 是用函数式编程方式在集合类上进行复杂操作的工具

内部迭代的实现机制

只过滤,不计数:

allArtists.stream()
          .filter(artist -> artist.isFrom("London"));

filter 只是刻画出了 Stream,并没有产生新的集合。
- 这些不产生新集合方法叫:惰性求值方法;
- 像 count 这样最终会从 Stream 产生值的方法叫:及早求值方法。

即使在 filter 过滤器中加上 println,也不会输出任何信息:

allArtists.stream()
          .filter(artist -> {
              System.out.println(artist.getName);
              return artist.isFrom("London");
          });

但只要加入一个拥有终止操作的流,艺术家的名字就会被输出:

allArtists.stream()
          .filter(artist -> {
              System.out.print(artist.getName);
              return artist.isFrom("London");
          })
          .count();

如何判断一个操作是惰性求值还是及早求值?

看它的返回值:
- 返回值是 Stream:惰性求值;
- 返回值是另一个值或 null:及早求值。

常用的流操作

collect()

Stream 里的值生成一个 ListSetMap 或其他。

比如,生成 List

// 使用 Stream 的 of 方法:
// 由一组初始值生成新的 Stream
List<String> collected = Stream.of("a", "b", "c")
                               .collect(Collectors.toList());

assert Arrays.asList("a", "b", "c").equals(collected);

map

将一个流中的值转换为一个新的流。

lambda 表达式的函数接口是 Function

比如,将一组字符串都转为大小形式:

List<String> collected = Stream.of("a", "b", "abc")
                               .map(string -> string.toUpperCase())
                               .collect(Collectors.toList());

assert Arrays.asList("A", "B", "ABC").equals(collected);

filter

保留 Stream 中符合条件的元素,而过滤掉其他的。

lambda 表达式的函数接口是 Predicate

比如,找出一组字符串中以数字开头的字符串:

List<String> startWithDigits = Stream.of("1a", "b", "abc")
                                     .filter(string -> isDigit(string.charAt(0)))
                                     .collect(Collectors.toList());

assert Arrays.asList("1a").equals(startWithDigits);

flatMap

将多个 Stream 连接成一个 Stream。

lambda 表达式的函数接口是 Function,返回值是 Stream

比如,一个包含多个列表的 Stream 连接成只有一个列表的 Stream

List<Integer> together = Stream.of(Arrays.asList(1, 2), Arrays.asList(3, 4))
                               .flatMap(numbers -> numbers.stream())
                               .collect(Collectors.toList());

assert Arrays.asList(1, 2, 3, 4).equals(together);

max 和 min

找出 Stream 中的最大最小值。

比如,找出播放长度最短的曲目:

List<Track> tracks = Arrays.asList(
        new Track("BaKai", 524),
        new Track("Violets", 378),
        new Track("Time Was", 451));

Track shortestTrack = tracks.stream()
                            .min(Comparator.comparing(track -> track.getLength()))
                            .get();

assert tracks.get(1).equals(shortestTrack);

reduce

从一组值中生成一个值,上面用到的 countminmax 方法都属于 reduce 操作。因为常用而被纳入标准库。

比如,求和:

// 0 是初始值,acc 是累加器
int count = Stream.of(1, 2, 3)
                  .reduce(0, (acc, element) -> acc + element);

assert 6 == count;

阶乘:

BigInteger k = Stream.iterate(BigInteger.ONE, x -> x.add(BigInteger.ONE))
                      .limit(n)
                      .reduce(BigInteger.ONE, (m, current) -> m.multiply(current));

整合操作

举例说明如何把问题分解成简单的 Stream 操作:

如何找出某张专辑上乐队所有成员的国籍?

将问题分解:
1. 找出专辑上的所有表演者
2. 分辨出哪些表演者是乐队
3. 找出乐队每个中每个成员的国籍
4. 将找出的国籍放在一个集合里

找出对应的 Stream API
1. 专辑 Album 类有 getMusicians 方法,该方法返回一个 Stream 对象,包含整张专辑中所有的表演者
2. 使用 filter 方法对表演者进行过滤,只保留乐队
3. 使用 flatMap 方法将乐队成员加入流中
4. 使用 map 方法将成员映射为其所属国家
5. 使用 collect 方法将找出的国籍放到集合里

Set<String> origins = album.getMusicians()
                           .filter(artist -> artist.getName().startsWith("The"))
                           .flatMap(artist -> artist.getMembers())
                           .map(member -> member.getNationality())
                           .collect(Collectors.toSet());

类库

默认方法

Collection 接口中新增了 stream 方法,如果继承它的子类没有实现 stream 方法,就使用它的 stream 方法,这样的方法叫默认方法。

Iterable 接口中也新增了一个默认方法:forEach,允许用户使用 lambda 表达式作为循环体。

JDKforEach 的实现方法:

default void forEach(Consumer<? super T> action) {
    for (T t : this) {
        action.accept(t);
    }
}

默认方法和子类

Parent 接口定义了默认方法 welcome,而 ParentImpl 类没有实现 welcome 方法,因此它自然继承了默认方法。

public class Main {
    public static void main(String[] args) {
        Parent parent = new ParentImpl();
        parent.welcome();
        assert "Parent: Hi!".equals(parent.getLastMessage());
    }
}

// 实现接口时没有实现 welcome
class ParentImpl implements Parent {
    String body;

    @Override
    public void message(String body) {
        this.body = body;
    }

    @Override
    public String getLastMessage() {
        return this.body;
    }
}

interface Parent {
    void message(String body);

    // 默认方法
    default void welcome() {
        message("Parent: Hi!");
    }

    String getLastMessage();
}

新增一个 Child 接口,该类继承 Parent 接口,并且重写 Parent 的默认方法:

public class Main {
    public static void main(String[] args) {
        Child child = new ChildImpl();
        child.welcome();
        assert "Child: Hi!".equals(child.getLastMessage());
    }
}

class ChildImpl implements Child {
    String body;

    @Override
    public void message(String body) {
        this.body = body;
    }

    @Override
    public String getLastMessage() {
        return this.body;
    }
}

interface Child extends Parent {
    @Override
    default void welcome() {
        message("Child: Hi!");
    }
}

interface Parent {
    void message(String body);

    default void welcome() {
        message("Parent: Hi!");
    }

    String getLastMessage();
}

现在,默认方法成了虚方法。
任何时候,一旦子类定义的方法和父类的产生冲突,都会优先选择子类定义的方法:

public class Main {
    public static void main(String[] args) {
        Parent parent = new OverridingParent();
        // 调用的是类的具体方法,而不是默认方法
        parent.welcome();
        assert "Override Parent".equals(parent.getLastMessage());
    }
}

class OverridingParent extends ParentImpl {
    // 重写 welcome 默认实现的父类
    @Override
    public void welcome() {
        message("Override Parent");
    }
}

class ParentImpl implements Parent {
    String body;

    @Override
    public void message(String body) {
        this.body = body;
    }

    @Override
    public String getLastMessage() {
        return this.body;
    }
}

interface Parent {
    void message(String body);

    default void welcome() {
        message("Parent: Hi!");
    }

    String getLastMessage();
}

新增 OverridingChild 类,该类本身并没有任何操作,只是继承 Child 接口和 OverridingParent 类。
但调用的 welcome 方法来自 OverridingParent 类。

原因:与 Child 接口定义的默认方法相比,OverridingParent 类中重写后的 welcome 方法更具体。

public class Main {
    public static void main(String[] args) {
        Child child = new OverridingChild();
        child.welcome();
        assert "Override Parent".equals(child.getLastMessage());
    }
}

class OverridingChild extends OverridingParent implements Child {

}

class OverridingParent extends ParentImpl {
    @Override
    public void welcome() {
        message("Override Parent");
    }
}

interface Child extends Parent {
    @Override
    default void welcome() {
        message("Child: Hi!");
    }
}

class ParentImpl implements Parent {
    String body;

    @Override
    public void message(String body) {
        this.body = body;
    }

    @Override
    public String getLastMessage() {
        return this.body;
    }
}

interface Parent {
    void message(String body);

    default void welcome() {
        message("Parent: Hi!");
    }

    String getLastMessage();
}

多重继承

接口允许多重继承,因此有可能遇到 2 个接口包含签名相同的默认方法的情况:

// 编译器会报错
// 因为 javac 不明确继承了哪个接口的 rock 方法
class MusicalCarriage implements Carriage, Jukebox {

}

interface Jukebox {
    default String rock() {
        return "...all over the world";
    }
}

interface Carriage {
    default String rock() {
        return "...from side to side";
    }
}

解决:可以使用增强的 super 语法,指定使用某个接口的默认方法。

class MusicalCarriage implements Carriage, Jukebox {
    @Override
    public String rock() {
        return Carriage.super.rock();
    }
}

三定律

如果对多重继承下的默认方法工作原理没有把握,可以参考以下3条:

  1. 类 > 接口。如果继承链中有方法体或抽象的方法声明,那就可以忽略接口中定义的方法。
  2. 子类 > 父类。如果一个接口继承了另一个接口,且两个接口都定义了一个默认方法,则子类优先。
  3. 没有3。如果以上2条不适用,子类要么实现该方法,要么将该方法声明为抽象方法。

Optional

Optional 是核心类库新设计的数据类型,用来替换 null 值。

Optional 对象相当于值的容器,可以使用 get() 获得该值。

创建某个值的 Optional 对象:

Optional<String> a = Optional.of("a");
assert "a".equals(a.get());

Optional 对象可以为空:

// 创建空 Optional 对象
Optional<String> emptyOptional = Optional.empty();

// 将空值转换为空 Optional 对象
Optional<String> alsoEmpty = Optional.ofNullable(null);

// isPresent() 检查 Optional 对象是否有值
assert emptyOptional.isPresent() == false;

assert alsoEmpty.isPresent() == false;

但对象为空时,如果希望使用备选值,可以使用 orElse
如果备选值计算太繁琐,可以使用 orElseGet,该方法接受 Supplier 对象。

Optional<String> emptyOptional = Optional.empty();

Optional<Integer> alsoEmpty = Optional.ofNullable(null);

assert "b".equals(emptyOptional.orElse("b"));

assert 3 == alsoEmpty.orElseGet(() -> 1 + 2);

高级集合类和收集器

方法引用

标准语法:Classname::methodName

比如想得到艺术家的名字:

lambda:artist -> artist.getName()

方法引用:Artist::getName

Arrays.stream(artist).map(Artist::getName).forEach(System.out::println)

构造方法同样可以缩写:

lambda:(name, nationality) -> new Artist(name, nationality)

方法引用:Artist::new

元素顺序

本身是有序集合,比如 List,创建流时,流中的元素就有顺序:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4);

List<Integer> sameOrder = numbers.stream()
                                 .collect(Collectors.toList());

assert sameOrder.equals(numbers);

本身是无序集合,比如 HashSet,由此生成的流也是无序的:

Set<Integer> numbers = new HashSet<>(Arrays.asList(4, 3, 2, 1));

List<Integer> sameOrder = numbers.stream()
                                 .collect(Collectors.toList());
// 断言有时会失败
assert Arrays.asList(4, 3, 2, 1).equals(sameOrder);

可以使用 sorted(),让流里的元素有序:

Set<Integer> numbers = new HashSet<>(Arrays.asList(4, 3, 2, 1));

List<Integer> sameOrder = numbers.stream()
                                 .sorted()
                                 .collect(Collectors.toList());

assert Arrays.asList(1, 2, 3, 4).equals(sameOrder);

或者使用 unordered(),变无序:

Set<Integer> numbers = new HashSet<>(Arrays.asList(4, 3, 2, 1));

List<Integer> sameOrder = numbers.stream()
                                 .unordered()
                                 .collect(Collectors.toList());

assert Arrays.asList(4, 3, 2, 1).equals(sameOrder);

使用收集器

collect(Collectors.toList()),在流中生成列表。
类似的还有 MapSet 等。

转换为其他集合

比如转换为 TreeSet,而不是框架背后为你指定的一种类型的 Set

List<Integer> numbers = Arrays.asList(4, 3, 2, 1);
Set<Integer> treeSet = numbers.stream()
                              .collect(Collectors.toCollection(TreeSet::new));

转换为值

maxByminBy

找出成员最多的乐队:

public Optional<Artist> biggestGroup(Stream<Artist> artists) {
    Function<Artist, Long> getCount = artist -> artist.getMembers().count();
    return artists.collect(maxBy(comparing(getCount)));
}

找出一组专辑上单曲的平均数:

public double averageNumberOfTracks(List<Album> albums) {
    return albums.stream().collect(averagingInt(album -> album.getTrackList().size()));
}

数据分块

假设有一个艺术家组成的流,一部分是独唱歌手,另一部分是乐队。
如果你希望将其分成两部分,可以使用收集器 partitioningBy,它接受一个流, 并将其分成两部分:

public Map<Boolean, List<Artist>> soloAndBands(Stream<Artist> artists) {
    return artists.collect(partitioningBy(Artist::isSolo));
}

数据分组

与将数据分成 truefalse 两块不同,数据分组是一种更自然的分割数据操作,可以使用任意值对数据分组。

比如,现在有一个专辑组成的流,可以按专辑当中的乐队主唱对专辑分组:

public Map<Artist, List<Album>> albumsByArtist(Stream<Album> albums) {
    return albums.collect(groupingBy(Album::getMainMusician));
}

字符串

比如要得到 “[{A, B, C}]” 这样的字符串:

public String getString() {
    List<String> strings = Arrays.asList("A", "B", "C");
    return strings.stream()
            .collect(Collectors.joining(", ", "[{", "}]"));
}

Collectors.joining(分隔符, 前缀, 后缀)

组合收集器

如何计算一个艺术家的发行的专辑数量?

最简单的就是使用前面的方法:对专辑先按艺术家分组,然后计数:

Map<Artist, List<Album>> albumsByArtist = albums.collect(groupingBy(Album::getMainMusician));

Map<Artist, Integer> numberOfAlbums = new HashMap<>();
for (Entry<Artist, List<Album>> entry : albumsByArtist.entrySet()) {
    numberOfAlbums.put(entry.getKey(), entry.getValue().size());
}

这段代码固然简单,但有点杂乱,命令式的代码,也无法自动适应并行化的操作。

使用 counting 重写:

Map<Artist, Long> numberOfAlbums = albums.collect(
        groupingBy(Album::getMainMusician,
                counting())
);

groupingBy 先将元素分块,每块都与 getMainMusician 提供的键相关联,然后使用下游的另一个收集器收集每块中的元素,最后将结果映射为 Map

另一个例子:如何获得每个艺术家的每张专辑名,而不是每张专辑?

Map<Artist, List<Album>> albumsByArtist = albums.collect(groupingBy(Album::getMainMusician));

Map<Artist, List<String>> nameOfAlbums = new HashMap<>();
for (Entry<Artist, List<Album>> entry : albumsByArtist.entrySet()) {
    nameOfAlbums.put(entry.getKey(), entry.getValue()
            .stream()
            .map(Album::getName)
            .collect(toList()));
}

groupingBy 将专辑按主唱分组,输出了 Map<Artist, List<Album>>,它将每个艺术家和他的专辑列表关联起来。

但我们需要的是 Map<Artist, List<String>>,将每个艺术家和他的专辑名列表关联起来。

mapping 可以像 map 一样将 groupingBy 的值做映射,生成我们想要的结果:

albums.collect(
        groupingBy(Album::getMainMusician,
                mapping(Album::getName,
                        toList()))
);

Map 类的变化

Map 实现缓存,传统方法:先试着取值,如果值为空,创建一个新值并返回。

public Artist getArtist(String name) {
    Artist artist = artistCache.get(name);
    if (artist == null) {
        artist = readArtistFromDB(name);
        artistCache.put(name, artist);
    }
    return artist;
}

computeIfAbsent 方法会在值不存在时,使用 lambda 表达式计算新值:

public Artist getArtistUsingComputeIfAbsent(String name) {
    return artistCache.computeIfAbsent(name, this::readArtistFromDB);
}

你可能试过在 Map 上迭代,比如:

Map<Artist, List<Album>> albumsByArtist = albums.collect(groupingBy(Album::getMainMusician));

Map<Artist, Integer> numberOfAlbums = new HashMap<>();

for (Entry<Artist, List<Album>> entry : albumsByArtist.entrySet()) {
    Artist artist = entry.getKey();
    List<Album> albums = entry.getValue();
    numberOfAlbums.put(artist, albums.size());
}

虽然工作正常,但是看起来挺丑的。

使用 forEach 内部迭代 Map 里的值:

Map<Artist, List<Album>> albumsByArtist = albums.collect(groupingBy(Album::getMainMusician));

Map<Artist, Integer> numberOfAlbums = new HashMap<>();

albumsByArtist.forEach(
        (artist, albumList) -> numberOfAlbums.put(artist, albumList.size())
);

数据并行化

并行和并发

并行:两个任务在同一时间发生,比如在多核 CPU 上,A 任务在三核,B 任务在四核。
并发:两个任务共享时间段,比如在 1s 内 A 任务和 B 任务交替运行 0.5s。

并行化流操作

在一个 Stream 对象上调用 parallel 方法即可拥有并行操作的能力。
如果想从一个集合类创建一个流,调用 parallelStream 即可获得拥有并行能力的流。

串行化计算所有专辑曲目长度:

public int serialArraySum(List<Album> albums) {
    return albums.stream()
            .flatMap(Album::getTracks)
            .mapToInt(Track::getLength)
            .sum();
}

改成调用 parallelStream 方法并行处理:

public int parallelArraySum(List<Album> albums) {
    return albums.parallelStream()
            .flatMap(Album::getTracks)
            .mapToInt(Track::getLength)
            .sum();
}

并行并不一定比串行快,要视情况选用,后面的性能小节会详细说明。

模拟系统

暂略

限制

虽然只需一点改动就能让已有代码使用并行流工作,但前提是代码写得符合约定,所以写代码是必须遵守一些规则和限制。

比如,reduce 方法的初始值可以是任意值。但为了让其在并行化时能工作正常,初值必须为组合函数的恒等值。
举个栗子:使用 reduce 操作求和时,组合函数为 (acc, element) -> acc + element,则其初值必须为 0。因为任何数字加 0,值不变。

reduce 操作的另一个限制是组合操作必须符合结合律。(只要序列值不变,组合操作的顺序就不重要)
举个栗子: (4 + 2) + 1 = 4 + (2 + 1) = 7、(4 x 2) x 1 = 4 x (2 x 1) = 8。

避免持有锁。流框架会在需要时自己处理同步操作。

parallel 并行和 sequential 串行不能同时使用在流上,要么并行,要么串行。
如果同时使用,只有最后调用的那个方法生效。

性能

影响并行流性能的主要 5 个因素:

数据大小
将问题分解之后并行化处理,再将结果合并会带来额外的开销。
因此只有在数据足够大时,每个数据处理管道花费的时间足够多时,并行化处理才有意义。

源数据结构
每个管道的操作都基于一些初始数据源,通常是集合。
将不同的数据源分割相对容易,这里的开销影响了在管道中并行处理数据是到底能带来多少性能上的提升。

装箱
处理基本类型比处理装箱类型要快。

核的数量
极端情况下,只有一个核,因此完全没必要并行化。
核的数量不单指你的机器上有多少核,更是指运行时你的机器能使用多少核。这也就是说同时运行的其他进程,或者线程关联性(强制线程在某些核或 CPU 上运行)会影响性能。

单元处理开销
比如数据大小,这是一场并行执行花费时间和分解合并操作开销之间的战争。
花在流中每个元素身上的时间越长,并行操作带来的性能提升越明显。

根据性能的好坏,将核心类库提供的通用数据结构分成以下 3 组:

性能好
ArrayList、数组或 IntStream.range,这些数据结构支持随机读取,也就是它们能轻而易举地被任意分解。

性能一般
HashSetTreeSet,这些数据结构不易公平地被分解,但是大多数时候分解是可能的。

性能差
有些数据结构难于分解,比如,可能要花 O(N) 的时间复杂度来分解问题。
其中包括 LinkedList,对半分解太难了。还有 Streams.iterateBufferedReader.lines,它们长度未知,因此很难预测该在哪里分解。

选用无状态操作,而不是有状态,就能获得更好的并行性能。
无状态操作: mapfilterflatMap
有状态操作:sorteddistinctlimit

并行化数组操作

数组上的并行化操作:

方法名操作
parallelPrefix任意给定一个函数,计算数组的和
parallelSetAll使用 lambda 表达式更新数组元素
parallelSort并行化对数组元素排序

for 循环初始化数组:

public double[] imperativeInitialize(int size) {
    double[] values = new double[size];
    for (int i = 0; i < values.length; i++) {
        values[i] = i;
    }
    return values;
}

使用 parallelSetAll 并行化以上过程:

public double[] imperativeInitializeParallelSetAll(int size) {
    double[] values = new double[size];
    Arrays.parallelSetAll(values, i -> i);
    return values;
}

测试、调试和重构

孤独的覆盖

ThreadLocal 能创建一个工厂,为每个线程最多只产生一个值。这是确保非线程安全的类在并发环境下安全使用的一种简单方式。

假设要在数据库查询一个艺术家,但希望每个线程值做一次这种查询:

ThreadLocal<Album> thisAlbum = new ThreadLocal<Album>() {
    @Override
    protected Album initialValue() {
        return database.findCurrentAlbum();
    }
};

为工厂方法 withInitial 传入一个 Supplier 对象实例来创建对象:

ThreadLocal<Album> thisAlbum = ThreadLocal.withInitial(
        () -> database.findCurrentAlbum()
);

同样的东西写两遍

DRY:Don’t Repeat Yourself
WET:Write Everything Twice

不是所有的 WET 都适合 lambdas 化。有时重复是唯一可以避免系统过紧耦合的方式。

什么时候该将 WET 的代码 lambda 化?
如果有一个整体上大概相似的模式,只是行为上有所不同,就可以试着加入一个 lambda 表达式。

举个栗子:

用户想要了解购买的专辑的一些信息,比如音乐家的人数、曲目和专辑时长等。

使用命令式 Java 编写的 Order 类:

public long countMusicians() {
    long count = 0;
    for (Album album : albums) {
        count += album.getMusicianList().size();
    }
    return count;
}

public long countTracks() {
    long count = 0;
    for (Album album : albums) {
        count += album.getTrackList().size();
    }
    return count;
}

public long countRunningTime() {
    long count = 0;
    for (Album album : albums) {
        for (Track track : album.getTrackList()) {
            count += track.getLength();
        }
    }
    return count;
}

每个方法里,都有样板代码将将每个专辑里的属性和总数相加。
没有重用共有的概念,写出了更多需要测试和维护的代码。

新增 OrderStream 类,使用 Stream 来抽象 Order 类:

public long countMusicians() {
    return albums.stream()
            .mapToLong(album -> album.getMusicians().count())
            .sum();
}

public long countTracks() {
    return albums.stream()
            .mapToLong(album -> album.getTracks().count())
            .sum();
}

public long countRunningTime() {
    return albums.stream()
            .mapToLong(album -> album.getTracks()
                    .mapToLong(Track::getLength)
                    .sum())
            .sum();
}

然而这段代码仍然有重用可读性的问题,因为有一些抽象和共性只能使用领域内的知识来表达。
流不会提供一个方法统计每张专辑上的信息——这是程序猿自己要编写的领域知识。

新增 OrderStreamDSL 类,用领域方法重构 OrderStream 类:

private long countFeature(ToLongFunction<Album> function) {
    return albums.stream()
            .mapToLong(function)
            .sum();
}

public long countMusicians() {
    return countFeature(album -> album.getMusicians().count());
}

public long countTracks() {
    return countFeature(album -> album.getTracks().count());
}

public long countRunningTime() {
    return countFeature(album -> album.getTracks()
            .mapToLong(Track::getLength)
            .sum());
}

设计和架构的原则

命令者模式

命令者是一个对象,它封装了调用另一个方法的所有细节,命令者模式使用该对象,可以编写出根据运行期条件,顺序调用方法的一般化代码。

命令者模式中有四个类参与其中:

命令接收者
执行实际任务

命令者
封装了所有调用命令执行者的信息

发起者
控制一个或多个命令的顺序和执行

客户端
创建具体的命令者实例

[发起者]  ->  [命令者]

  ↑              ↑
  |创建          |实现

[客户端]  ->  [具体命令者]

                调用|
                    ↓

              [命令接收者]

举个栗子:
假设有个 GUI Editor 组件,可以执行 opensave 等一系列操作。
现在我们像实现宏功能——就是把一系列操作录下来,日后作为一个操作执行,这就是命令的接受者。

文本编辑器可能有的一般功能:

public interface Editor {
    void save();

    void open();

    void close();
}

opensave 这样的操作称为命令,我们需要一个统一的接口来概括这些不同的操作。

通过 Action 接口,所有操作均可实现:

public interface Action {
    void perform();
}

现在让每个操作都实现该接口:

public class Save implements Action {
    private final Editor editor;

    Save(Editor editor) {
        this.editor = editor;
    }

    @Override
    public void perform() {
        editor.save();
    }
}
public class Open implements Action {
    private final Editor editor;

    Open(Editor editor) {
        this.editor = editor;
    }

    @Override
    public void perform() {
        editor.open();
    }
}
public class Close implements Action {
    private final Editor editor;

    Close(Editor editor) {
        this.editor = editor;
    }

    @Override
    public void perform() {
        editor.close();
    }
}

实现一个宏:

public class Macro {
    // 一系列操作
    private final List<Action> actions;

    Macro() {
        actions = new ArrayList<>();
    }

    // 记录操作
    void record(Action action) {
        actions.add(action);
    }

    // 运行一系列动作
    void run() {
        actions.forEach(Action::perform);
    }
}

别忘了实现一个具体的文本编辑器 EditorImpl

public class EditorImpl implements Editor {
    @Override
    public void save() {
        System.out.println("success save");
    }

    @Override
    public void open() {
        System.out.println("success open");
    }

    @Override
    public void close() {
        System.out.println("success close");
    }
}

现在就可以通过录制这些操作做一个宏,来方便自己的工作了:

命令者模式构建宏:

Macro macro = new Macro();
macro.record(new Open(editor));
macro.record(new Save(editor));
macro.record(new Close(editor));
macro.run();

lambda 表达式构建宏:

Macro macro = new Macro();
macro.record(() -> editor.open());
macro.record(() -> editor.save());
macro.record(() -> editor.close());
macro.run();

方法引用构建宏:

Macro macro = new Macro();
macro.record(editor::open);
macro.record(editor::save);
macro.record(editor::close);
macro.run();

宏只是使用使用命令者模式中的一个例子,它被大量用在实现组件化的图形界面系统、撤销功能、线程池、事务和向导中。

策略模式

策略模式能在运行时改变软件的算法模式。
其主要思想是定义一个通用的问题。使用不同的算法来实现,然后将这些算法都封装在统一接口的背后。

以文件压缩为例,我们为用户提供压缩各种文件的方式,可以使用 zip 算法,也可以使用 gzip 算法,我们实现一个通用的 Compressor 类,能用任何算法压缩文件。

首先,为策略定义 API CompressionStrategy,每种文件压缩算法都要实现该接口。
该接口有一个 compress 方法,接受并返回一个压缩后 OutputStream 对象。

压缩器 -调用-> 压缩策略
               ↗ ↖
          实现/     \实现
             /       \
         zip压缩    gzip压缩

定义压缩数据的策略接口:

public interface CompressionStrategy {
    OutputStream compress(OutputStream data) throws IOException;
}

使用 gzip 算法压缩数据:

public class GzipCompressionStrategy implements CompressionStrategy {
    @Override
    public OutputStream compress(OutputStream data) throws IOException {
        return new GZIPOutputStream(data);
    }
}

使用 zip 算法压缩数据:

public class ZipCompressionStrategy implements CompressionStrategy {
    @Override
    public OutputStream compress(OutputStream data) throws IOException {
        return new ZipOutputStream(data);
    }
}

压缩器 Compressor

public class Compressor {
    private final CompressionStrategy strategy;

    // 构造时使用用户提供的压缩策略
    public Compressor(CompressionStrategy strategy) {
        this.strategy = strategy;
    }

    // 读入文件,根据策略压缩文件
    public void compress(Path inFile, File outFile) throws IOException {
        try (OutputStream outputStream = new FileOutputStream(outFile)) {
            Files.copy(inFile, strategy.compress(outputStream));
        }
    }
}

到此就可以开始使用我们的压缩器来压缩文件了:

使用具体策略类初始化 Compressor

Compressor gzipCompressor = new Compressor(new GzipCompressionStrategy());
gzipCompressor.compress(inFile, outFile);

Compressor zipCompressor = new Compressor(new ZipCompressionStrategy());
zipCompressor.compress(inFile, outFile);

使用方法引用初始化 Compressor

Compressor gzipCompressor = new Compressor(GZIPOutputStream::new);
gzipCompressor.compress(inFile, outFile);

Compressor zipCompressor = new Compressor(ZipOutputStream::new);
zipCompressor.compress(inFile, outFile);

观察者模式

观察者模式是另一种可被 lambda 表达式简化和改进的行为模式。
在观察者模式中,被观察者持有一个观察者列表。当被观察者的状态发生改变,会通知观察者。

观察者模式被大量应用于基于 MVCGUI 工具中,以此让模型状态发生变化时,自动刷新视图模块,达到二者之间的解耦。

举个栗子:

NASA 和外星人都对登陆到月球上的东西感兴趣,都希望可以记录这些信息。
NASA 希望确保阿波罗号上的航天员成功登月;外星人则希望在 NASA 注意力分散时进攻地球。

这里他们的观察对象就是登陆到月球的东西。

首先,定义观察者的 API LandingObserver,它只有 observeLanding 方法,当有东西登陆到月球上时会调用该方法:

public interface LandingObserver {
    void observerLanding(String name);
}

被观察者就是月球 Moon,它持有一组 LandingObserver 实例,有东西着陆时会通知这些观察者,还可以增加新的 LandingObserver 实例观测 Moon 对象:

public class Moon {
    private final List<LandingObserver> observers = new ArrayList<>();

    public void land(String name) {
        observers.forEach(observer -> observer.observerLanding(name));
    }

    public void startSpying(LandingObserver observer) {
        observers.add(observer);
    }
}

外星人观察到阿波罗号登陆月球,就开始发出进攻地球的信号:

public class Aliens implements LandingObserver {
    @Override
    public void observerLanding(String name) {
        if (name.contains("Apollo")) {
            System.out.println("They're distracted, lets invade earth!");
        }
    }
}

NASA 观察到阿波罗号登陆到月球,会很兴奋:

public class Nasa implements LandingObserver {
    @Override
    public void observerLanding(String name) {
        if (name.contains("Apollo")) {
            System.out.println("We made it!");
        }
    }
}

传统方式,就是使用以上写好的模版类 AliensNasa 来调用:

Moon moon = new Moon();
moon.startSpying(new Nasa());
moon.startSpying(new Aliens());

moon.land("An asteroid");
moon.land("Apollo 11");

但使用 lambda 表达式的话,就不用写以上的模版类了:

Moon moon = new Moon();
moon.startSpying(name -> {
    if (name.contains("Apollo")) {
        System.out.println("We made it!");
    }
});
moon.startSpying(name -> {
    if (name.contains("Apollo")) {
        System.out.println("They're distracted, lets invade earth!");
    }
});

moon.land("An asteroid");
moon.land("Apollo 11");

注意:
无论是使用观察者还是策略模式,实现时采用 lambda 表达式,还是传统的类,取决于观察者和策略代码的复杂度。
这里举的例子很简单,所以更能展示新的语言特性。

使用 lambda 表达式的 SOLID 原则

SOLID 原则是涉及面向对象程序是的一些基本原则。
分别是 Single responsibilityOpen/closedLiskov substitutionInterface segregationDependency inversion

这里主要关注如何 lambda 表达式的环境下应用其中的三条原则。

单一功能原则

程序中的类或方法只能有一个改变的理由。

当软件的需求发生变化,实现这些功能的类和方法也需要变化。
如果你的类有多个功能,一个功能引起的代码变化会影响该类其他功能。这可能会引入缺陷,还会影响代码演进的能力。

举个栗子:
有一个程序,可以由资产列表生成 BalanceSheet 表格,然后输出一份 PDF 格式的报告。
如果实现时将制表和输出功能都放进同一个类,那么该类就有两个变化的理由。
你可能想改变输出功能,输出不同的格式,比如 HTML,可能还想改变 BalanceSheet 的细节。
这将问题分解成两个类提供了很好的理由:一个负责将 BalanceSheet 生成表格,一个负责输出。

单一功能原则不止于此:一个类不仅要功能单一,而且还需要将功能封装好。
以上面的例子就是:如果我想改变输出格式,那么只需要改变负责输出的类,而不必关心负责制表的类。

这是强内聚性设计的一部分。说一个类是内聚的,是指它的方法和属性需要统一对待,因为它们紧密相关。
如果你试着将一个内聚的类拆分,可能会得到刚才创建的那两个类。

那么问题来了,这和 lambda 表达式有什么关系?

lambda 表达式在方法级别能更容易实现单一功能原则。

举个栗子:

计算质数个数:

public long countPrimes(int upTo) {
    long total = 0;
    for (int i = 1; i < upTo; i++) {
        boolean isPrime = true;
        for (int j = 2; j < i; j++) {
            if (i % j == 0) {
                isPrime = false;
            }
        }
        if (isPrime) {
            total++;
        }
    }
    return total;
}

显然,上面的方法塞了两个职责:判断一个数是否是质数、计数。

拆分这两个功能:

public long countPrimes(int upTo) {
    long total = 0;
    for (int i = 1; i < upTo; i++) {
        if (isPrime(i)) {
            total++;
        }
    }
    return total;
}

public boolean isPrime(int num) {
    for (int i = 2; i < num; i++) {
        if (num % i == 0) {
            return false;
        }
    }
    return true;
}

既然遵守单一功能原则,那么我们可以对迭代过程封装:

public long countPrimes(int upTo) {
    return IntStream.range(1, upTo)
            .filter(this::isPrime)
            .count();
}

public boolean isPrime(int num) {
    return IntStream.range(2, num)
            .allMatch(x -> (num % x) != 0);
}

如果我们想利用多核加速计数,可以使用 parallel 方法,而不用修改任何其他代码:

public long countPrimes(int upTo) {
    return IntStream.range(1, upTo)
            .parallel()
            .filter(this::isPrime)
            .count();
}

开闭原则

软件应该对扩展开放,对修改闭合。

开闭原则的首要目标和单一功能原则类似:让软件易于修改。

一个新增功能或一处改动,会影响整个代码,容易引入新的缺陷。

开闭原则保证已有的类在不修改内部实现的基础上可扩展,这样就努力避免了上述问题。

举个栗子:

现在我们有个描述计算机花在用户空间、内核空间和输入输出上的时间散点图 MetricDataGraph 接口:

public interface MetricDataGraph {
    void updateUserTime(int value);

    void updateSystemTime(int value);

    void updateIOTime(int value);
}

但这个接口有点问题:对扩展不友好。因为要想添加新的时间点,比如 XXTime,就要修改这个接口,添加对应的 updateXXTime 方法。

如何解决扩展问题呢?一般是通过引入抽象解决。

使用新的类 TimeSeries 来表示各种时间点,这样 MetricDataGraph 接口也得以简化,不必依赖某项具体指标。

public interface MetricDataGraph {
    void updateTimeSeries(TimeSeries time);
}
public interface TimeSeries {
    int getValue();
}

每项具体指标都实现 TimeSeries 接口,在需要时能直接插入:

public class UserTime implements TimeSeries {
    private int value;

    @Override
    public int getValue() {
        return this.value;
    }
}

现在,要添加新的时间点,比如,“被浪费的CPU时间”:

public class WasteTime implements TimeSeries {
    private int value;

    @Override
    public int getValue() {
        return this.value;
    }
}

高阶函数也展示了同样的特性:对扩展开放,对修改闭合。

ThreadLocal 类有一个特殊变量,每个线程都有一个该变量的副本与之交互。该类的静态方法 withInitial 是一个高阶函数,传入一个负责生成初始值的 lambda 表达式。即不用修改 ThreadLocal 类就能获得新的行为,所以符合开闭原则。

withInitial 方法传入不同的工厂方法,就能得到有着不同行为的 ThreadLocal 实例。

比如,使用 ThreadLocal 生成一个 DateFormatter 实例,该实例是线程安全的:

// 实现
ThreadLocal<DateFormat> localFormatter = 
        ThreadLocal.withInitial(() -> new SimpleDateFormat());

// 使用
DateFormat formatter = localFormatter.get();

或者为每个 Java 线程创建唯一,有序的标识符:

AtomicInteger threadId = new AtomicInteger();

ThreadLocal<Integer> localId =
        ThreadLocal.withInitial(() -> threadId.getAndIncrement());

int idForeThisThread = localId.get();

依赖反转原则

抽象不应依赖细节,细节应该依赖抽象。

该原则的目的:让程序猿脱离底层粘合代码,编写上层业务逻辑代码。这就让上层代码依赖于底层细节的抽象,从而可以重用上层代码。
这种模块化和重用方式是双向的:既可以替换不同的细节重用上层代码,也可以替换不同的业务逻辑重用细节的实现。

以下代码是从一种标记语言中提取标题,其中标题以冒号(:)结尾。

public List<String> findHeadings(Reader input) {
    // 读取文件
    try (BufferedReader reader = new BufferedReader(input)) {
        // 逐行检查
        return reader.lines()
                // 滤出标题
                .filter(line -> line.endsWith(":"))
                .map(line -> line.substring(0, line.length() - 1))
                .collect(toList());
    } catch (IOException e) {
        // 将和读写文件有关的异常封装成待解决的异常
        throw new HeadingLookupException(e);
    }
}

这段代码,将提取标题,资源管理,文件处理都混在了一起。我们真正想要的是编写提取标题的代码,而将操作文件相关细节交给另一个方法。

剥离文件处理功能后的业务逻辑:

public List<String> findHeadings2(Reader input) {
    return withLinesOf(input,
            lines -> lines.filter(line -> line.endsWith(":"))
                    .map(line -> line.substring(0, line.length() - 1))
                    .collect(toList()),
            HeadingLookupException::new);
}

// Stream 对象更安全,而且不容易被滥用
// 使用 Stream<String> 做抽象,让代码依赖它,而不是文件
private <T> T withLinesOf(Reader input,
                          Function<Stream<String>, T> handler,
                          Function<IOException, RuntimeException> error) {
    try (BufferedReader reader = new BufferedReader(input)) {
        return handler.apply(reader.lines());
    } catch (IOException e) {
        throw error.apply(e);
    }
}

使用 lambda 表达式编写并发程序

未完待续…

java精神(基于函数式组合子逻辑的javaparser框架) 一。 释名。 为什么叫精神? 如果你熟悉c++,那么你可能知道一个叫做”spirit”的parser库。它利用c++的模板元编程能力,使用c++语言本身提供了一个递归下降文法解析的框架。 我这里介绍的jparsec库,就是一个java里面的递归下降文法解析框架。 不过,它并非是spirit的java版本。 Jparsec的蓝本来自Haskell语言的parsec库。Parsec是一个基于monad的parser组合子库。 这个库的目的是要在java中提供一个类似parsec, spirit的库,这种组合子库并非c++的专利,java/c#也可以做到。这个库还将在java5.0上被改写,类型安全上它将也不再逊色于c++。 那么,为什么叫“函数式”呢?java是面向对象的嘛。 如果你使用过haskell, lisp等语言,这个函数式不用解释你也知道是怎么回事了。 如果你是一个老牌的c++/java程序员,那么这里还要稍微解释一下。当然如果您对这些虚头八脑的名词不感兴趣,那么,你尽可以跳过这一章,不知道什么是“函数式”,并不会影响你对这个库的理解的。 C++这几年随着gp的普及,“函数式”这个老孔乙己逐渐又被人从角落里面拽了出来。一个c++程序员所熟悉的“函数式”很可能是stl的for_each, transform,count_if这些函数。 怎么说呢,就象我不能否定str.length()这个调用属于OO一样,我也无法说for_each, transform不是函数式。 但是,“函数式”的精髓不在于此。 一般归纳起来,就像我们说OO是什么多态,封装,继承一样,“函数式”的特征被总结为: 1。无副作用。 2。高阶函数。 3。延迟计算 而最最有意义的(至少我认为如此),是基于高阶函数函数组合能力。一些人把这叫做glue。 简短地说,什么让函数式编程如此强大?是用简单的函数组合出复杂函数的能力。 我可以想象,说到这里,你还是一头雾水。“什么是组合?1+1不是也把两个1组合成2了吗?new A(new B(), new C())不也是从B和C组合成A了?”
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值