阅读笔记–Java 8函数式编程,建议看书,作者高屋建瓴

阅读笔记–Java 8函数式编程

书籍代码

为什么需要再次修改Java

1996年1月,Java1.0发布,商业发展需要更复杂的应用,跑在功能强大的多核CPU机器上。带有高效运行时编译器的Java虚拟机的出现,使程序员将更多的精力放在编写干净,易于维护的代码上,而不是思考将每一个CPU时钟周期,每字节内存物尽其用。

多核CPU,涉及锁的编程算法不但容易出错,而且耗费时间。
java.util.concurrent包和很多第三方类库,试图将并发抽象化,但还不够。

处理大数据集合,Java还欠缺高效的并行操作。

为了编写这类处理批量数据的并行类库,需要再语言层面上修改现有的Java:增加Lambda表达式。

函数式编程

核心:在思考问题时,使用不可变值和函数,函数对一个值进行处理,映射成另一个值。

面向对象编程是对数据进行抽象
函数式编程是对行为进行抽象
现实世界中,数据和行为并存

Lambda表达式

目标类型:由编译器推断出来,Java初始化数组和将null赋值给一个变量,才能知道它的类型。

引用值: Lambda表达式引用的是值,而不是变量。这种行为也解释了为什么Lambda表达式也被成为闭包。未赋值的变量与周边环境隔离起来,进而被绑定到一个特定的值。
Lambda表达式都是静态类型的,因此,Lambda表达式本身的类型:接口函数。

函数接口

定义:函数接口是只有一个抽象方法的接口,用作Lambda表达式的类型。

类型推断

Lambda表达式中的类型推断,实际上是Java7就音域的目标类型推断的扩展。
菱形操作符
方法签名
Lambda表达式是一个匿名方法,将行为像数据一样进行传递。
Lambda表达式常见结构:BinaryOperator add = (x, y) -> x + y
函数接口指仅具有单个抽象方法的接口,用来表示Lambda表达式的类型。

外部迭代

for循环是一个封装了迭代的语法糖。首先调用Iterator方法,产生一个新的Iterator对象,进而控制整个迭代过程。
外部迭代 本质上来讲是一种串行化操作。总体来看,使用for循环会将行为和方法混为一谈。

内部迭代

stream()操作返回内部迭代中的相应接口:Stream。

stream()操作后返回的不是新的集合,而是创建新集合的配方。比如filter操作只是刻画出了Stream,但是没有产生新的集合。最终不产生新集合的方法叫做惰性求值方法;而像count这样的最终会从Stream产生值的方法叫做及早求值方法

类似于建造者模式

camparing()方法

实现是接受一个函数并返回另一个函数。

通用模式

max和min方法都属于更通用的一种编程模式。

Object accumulator = initialValue;
for(Object element : collection) {
    accumulator = combine(accululator, element);
}

这种模式中两个可变项是initialValue初始值和combine函数。

reduce操作

reduce操作可以实现从一组值中生成一个值。count、min和max都是reduce操作。

int count = Stream.of(1, 2, 3)
                .reduce(0, (acc, element) -> acc + element);

reducer的类型是BinaryOperator

展开操作

BinaryOperator accumulator = (acc, element) -> acc + element;
int count = accumulator.apply(
                    accumulator.apply(
                            accumulator.apply(0, 1),
                         2),
                    3);

使用命令编程方式求和

int acc = 0;
for(Integer element : asList(1, 2, 3)) {
    acc = acc + element;
}

每一次循环将集合中的元素和累加器相加,用相加后的结果更新累加器的值。对于集合来说,循环在外部,且需要手动更新变量。

使用Java8编程的时候,可以优先暴露Stream接口,而不是某个具体的实现。他很好的封装了内部实现的数据结构。仅暴露一个Stream接口,用户在实际操作中无论如何使用,都不会影响内部的List或Set。

高阶函数

高阶函数是指接受另外一个函数作为参数,或返回一个函数的函数。
高阶函数的参数列表里包含函数接口,或该函数返回一个函数接口,那么该函数就是高阶函数。

Comparator实际上应该是个函数,但是那是的Java只有对象,因此才再出一个类,一个匿名类。成为对象实属巧合,函数接口向正确的方向迈出了一步。

回调函数是一个合法的Lambda表达式,但并不能真正帮助用户写出更简单,更抽象的代码,因为他仍然在指挥计算机执行一个操作。

基本类型

在Java中有一些相伴的类型,比如int和Integer。基本类型内建在语言和运行环境中,是基本的程序构建模块,而装箱类型属于普通的Java类,只不过是对基本类型的一种封装。

Java的泛型是基于对泛型参数类型的擦出,换句话说,假设它是Object对象的实例—-因此只有装箱类型才能作为泛型参数。这就解释了为什么在Java中想要一个包含整型值得列表List,实际上得到的确实一个包含整型对象的列表List。

麻烦的是,整型在内存中占用4个字节,整型对象占16字节,数组上表现更严重,整型数据中的每个元素只占用基本类型的内存,而整型对象数组中,每个元素都是内存中的一个指针,指向Java堆中的某个对象。在最坏的情况下,同样大小的数组,Integer[]要比int[]多占用6倍内存。

装箱和拆箱操作都需要额外的计算开销。

ToLongFunction和LongFunction

Java8仅对整型,长整型和双浮点型做了特殊处理,因为他们在数值计算中用的最多

返回基本类型,在基本类型前加To,如果参数是基本类型,则不加前缀只需类型名即可。如果高阶函数使用基本类型,则在操作后加后缀To再加基本类型。

LongUnaryOperator

特殊mapToLong实现
通过一些高阶函数装箱方法,mapToObj,也可以从一个基本类型的Stream得到一个装箱后的Stream,如Stream.

尽可能多用对基本类型做过处理的方法,改善性能。

summaryStatistic方法

mapToInt返回一个IntStream对象,它包含一个summaryStatistics方法,这个方法能计算出各种各样的统计值。

重载解析

在Java中可以重载方法,造成多个方法有相同的方法名,但签名确不一样。这时,javac会挑出最具体的类型。

BinaryOperator是一种特殊的BiFunction类型,参数的类型和返回值的类型相同。比如,两个整数相加就是一个BinaryOperator。

Lambda表达式的类型就是对于的函数接口类型,因此,将Lambda表达式作为参数传递时,情况也依然如此。操作时可以重载一个方法,分别接受BinaryOperator和该接口的一个子类作为参数调用这些方法时,Java推导出的Lambda表达式的类型正是最具体的函数接口的类型。

规则:
- 如果只有一个可能的目标类型。由相应函数接口里的参数类型推导得出;
- 如果有多个可能的目标类型,由最具体的函数推导得出;
- 如果有多个可能的目标类型且最具体的类型不明确,则需人为指定类型。

@FunctionalInterface

事实上,每个用作函数接口的接口都应该添加这个注释。
原因:Java中有一些接口,虽然只含一个方法,但并不是为了使用Lambda表达式来实现的。比如,有些对象内部可能保存着某种状态,使用带有一个方法的接口可能纯属巧合。java.lang.Comparable和java.io.Closeable.
一个科关闭的对象必须持有某种打开的资源,比如一个需要关闭的文件句柄。同样,该接口也不能是一个纯函数,因为关闭资源是一个更改状态的另一种形式。
和Closeable和Compareable接口不同,为了提高Stream对象可操作性而引入的各种新接口,都需要有Lambda表达式可以实现它。它们存在的意义是在于将代码块作为数据打包起来。因此,它们都天剑了@FunctionalInterface注释。

该注释会强制javac检查一个接口是否符合函数接口的标准。

二进制接口的兼容性

二进制的兼容性一直被视为Java的关键优势所在。但在Java8中Collection接口增加stream方法,java类库实现方法就没问题,第三方类库就会存在问题。

默认方法

在任何接口中,无论函数接口还是非函数接口,都可以使用该方法。

default void forEach(Consumersuper T> action) {
    for(T t : this) {
        action.accept(t);
    }
}

default关键字告诉javac用户真正需要的是为接口添加一个新方法。
默认方法在集成规则是和普通方法也略有区别。
和类不同,接口没有成员变量,因此默认方法只能通过调用子类的方法来修改子类本身,避免了对子类的实现做出各种假设。

默认方法成了虚方法–和静态方法刚好相反,任何时候,一旦与类中定义的方法产生冲突,都要优先选择类中定义的方法。

public class OverridingChild extends OverridingParent implements Child {

}

与接口定义的默认方法相比,类中重写的方法更具体。

类中中邪的方法胜出,这样的设计主要是增加默认方法的目的决定的,增加默认方法主要是为了在接口上向后兼容。让类中重写的方法的优先级高于默认方法能简化很多集成问题。

多重继承

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

public interface Jukebox {
    public default String rock() {
        return "... all over the world!"
    }
}
public interface Carriage {
    public default String rock() {
        return "...from side to side";
    }
}
public class MusicalCarriage implements Carriage, Jukebox {

}

这种情况下编译器会报错:class Musical Carriage iniherits unrelated defaults for rock() from types Carriage and Jukebox.
在类中实现rock方法就能解决这个问题。

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

使用增强的super语法,用来指明使用接口Carriage中定义的默认方法。

三定律
  1. 类胜于接口
  2. 子类胜于父类
  3. 没有规则三

其中第一条规则是为了让代码向后兼容。

权衡

Java优于C++的原因之一是舍弃了多重继承。
语言特性的利弊也在不断的演化。很多人认为多重继承的问题在于对象状态的继承,而不是代码块的继承,默认方法避免了状态的继承,因此避免了C++中多继承的最大缺点。

突破语言上的局限性吸引着无数优秀的程序员不断尝试,现在已有一些博客文章,阐述在Java8中实现完全的多重继承做出的尝试,包括状态的继承和默认方法。尝试突破Java8这些有意为之的语言限制,却往往又掉进C++的旧陷阱之中。

接口和抽象类之间还是存在明显的区别。接口允许多重继承,却没有成员变量;抽象类可以继承成员变量,却不能多重继承。在对问题域建模时,需要根据具体情况进行权衡,而在以前的Java中可能并不需要这样。

接口的静态方法

Stream.of,Stream是个接口,Stream.of是接口的静态方法。旨在帮助编写类库的开发人员,但对于日常应用程序的开发人员也同样适用。

人们在编程过程当中总结出了一条经验,那就是一个包含很多静态方法的类。有时,类是一个放置工具方法的好地方,比如Java7中引入的Objects类,就包含很多工具方法,这些方法不是具体属于某个类。

如果一个方法有充分的语义原因和某个概念相关,那么就应该将该方法和相关的类或接口放在一起,而不是放到另一个工具类中。这有助于更好的组织代码,阅读代码的人也更容易找到相关方法。

Optional

reduce方法有两种形式,一种需要有一个初始值,另一种变式则不需要有初始值。在没有初始值的情况下,reduce的第一步使用Stream中的前两个元素。有时,reduce操作不存在有意义的初始值,这样做就是有意义的。

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

意义:检查变量,避免缺陷;将可能为空文档化。

of(), empty(), ofNullable()
isPresent(),表示一个Optional对象里是否有值;

orElse(), orElseGet();

assertEqual("b", emptyOptional.orElse("b");
assertEqual("c", emptyOptional.orElseGet(() -> "c")

高级集合类和收集器

方法引用

artist -> artist.getName()

Lambda经常调用参数,所以提供简写语法,叫做方法引用

Artist::getName

需要注意的是,这里并不调用该方法。我们只是提供了和Lambda表达式等价的一种结构,在需要的时候才会调用。

构造函数也有同样的缩写形式

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

可改为

Artist:new

这段代码不仅比原来的代码段,而且更易阅读。需要注意的是方法引用自动支持多个参数,前提是选对了正确的函数接口。

还可以用这种方式创建数组

String[]::new

实质是语法糖的语法糖

元素顺序

直观上看,流是有序的,因为流中的元素都是按顺序处理的。这种顺序成为出现顺序。出现顺序的定义依赖数据源和对流的操作。

在一个有序集合中创建一个流时,流中的元素就按出现顺序排列。

List<Integer> numbers = asList(1, 2, 3, 4);
List<Integer> sameOrder = numbers.stream().collect(toList());
assertEquals(numbers, sameOrder); // true.

一些操作在有序的流上开销更大,调用unordered方法消除这种顺序就能解决该问题。大多数操作在有序流觞效率更高,比如filter,map和reduce等。
使用并行流操作时,forEach方法并不能保证元素是按顺序处理的,保证按顺序使用forEachOrdered。

使用收集器

一种通用的,从流生成复杂值得结构。只要传给collect方法,所有的流就都可以使用它了。

转换集合

toCollection 接受一个函数作为参数,创建集合。

stream.collect(toCollection(TreeSet::new));
转换成值

maxBy和minBy允许用户按某种特定的顺序生成一个值。

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

类似还有averagingInt。

数据分块

partitioningBy()

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

数据分组是一种更自然的分割数据操作,可以对数据任意分组。

public Map<Artist, List<Album>> albumsByArtist(Stream<Album> albums) {
    return albums.collect(groupingBy(album -> album.getMainMusician()));
}
字符串

很多时候,收集流中的数据都是为了在最后生成一个字符串。

// 使用流和收集器格式化艺术家名字
String result = 
    artists.stream()
            .map(Artist::getName)
            .collect(Collectors.joining(", ", "[", "]"));
组合收集器
// 使用收集器计算每个艺术家的专辑数
pubic Map numberOfAlbums(Stream albums) {
    return albums.collect(groupingBy(album -> album.getMainMusician(), counting()));
}

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

我们需要有一种方法,可以告诉groupingBy将它的值做映射,生成最终结果。

所以,mapping收集器。

mapping允许在收集器的容器上执行类似map的操作。但是需要指明使用什么样的集合类存储结果,比如toList。

// 使用收集器求每个艺术家的专辑名
public Map<Artist, List<String>> nameOfAlbums(Stream<Album> albums) { 
    return albums.collect(groupingBy(Album::getMainMusician, mapping(Album::getName, toList()))); 
}

第二个收集器用以收集最终结果的一个子集。这些收集器叫做下游收集器.

重构和定制收集器

实现一个StringCollector
- 待收集元素的类型,这里是String;
- 累加器的类型StringCombiner;
- 最终结果的类型,这里依然是String;

// 定义字符串收集器
public class StringCollector implements Collector<String, StringCombiner, String> {}

一个收集器由四部分组成。首先是一个Supplier,这是一个工厂方法,用来创建容器,在这个例子中,就是StringCombiner. 和reduce操作中的第一个参数类似,它是后续操作的初始值。

// Supplier是创建容器的工厂
public Supplier supplier() {
    return () -> new StringCombiner(delim, prefix, suffix);
}
// 将元素叠加到收集器
public BiConsumer accumulator() {
    return StringCombiner::add;
}
// 合并两个容器
public BinaryOperator combiner() {
    return StringCombiner::merge;
}
// finisher方法返回收集操作的最终结果
public Function<StringCombiner, String> finisher() {
    return StringCombiner::toString;
}

由于收集器可以并行收集,如图(暂无)

  1. Supplier创建出新的容器
  2. 收集器accumulator,结合之前操作的结果和当前值,生成并返回新的值。
  3. combine,如果有两个容器,需要将其合并。
  4. 在收集阶段,容器被combiner方法成对合并进一个容器,直到最后只剩一个容器为止。
  5. Finisher,进行转换,在我们想创建字符串等不可变的值时特别有用,这里容器是可变的。

特征:特征是一组描述收集器的对象,框架可以对其适当优化。characteristic方法定义了特征。

自定义收集器适用于,在自己特定的领域内的类,希望从集合中构建一个操作,而标准的集合类并没有提供这种操作时;

identity函数:它返回传入参数的值。如果这样,收集器就会展现出IDENTITY_FINISH的特征,需要使用characteristics方法声明。

对收集器的归一化处理

如果你想要为自己领域内的类定制一个收集器,不妨考虑一下其他替代方案。最容易想到的方案是构建若干个集合对象,作为参数传给另一内类的构造函数。如果领域内类包含多种集合,这种方式又简单又适用。
如果领域内没有这些集合,需要在已有数据上计算,仍然可以使用reducing收集器代替,因为他为流上的归一操作提供了统一实现。

String result = 
        artists.stream()
                .map(Artist::getName)
                .collect(Collectors.reducing(
                    new StringCombiner(", ", "[", "]"),
                    name -> new StringCombiner(", ", "[", "]").add(name),
                    StringCombiner::merge))
                .toString();

这种方式很低效,Collectors.reducing的第二个参数,我们为流中每个元素创建了唯一的StringCombiner;

一些细节

Lambda表达式的引入也推动了一些新方法被加入集合类。

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

Java8引入了一个新方法computeIfAbsent,该方法接受一个Lambda表达式,值不存在时使用该Lambda表达式计算新值。

// 使用computeIfAbsent缓存
public Artist getArtist(String name) {
    return artistCache.computeIfAbsent(name, this::readArtistFromDB);
}
Map countOfAlbums = new HashMap<>();
albumsByArtist.forEach((artist, albums) -> {
    countOfAlbums.put(artist, albums.size());
})
并发

两个任务共享时间段

并行

两个任务在同一时间发生

数据并行化

并行化是指为缩短任务执行时间,将一个任务分解为几部分,然后并行执行。实际上,和顺序执行相比,并行化执行任务时,CPU承载的工作量更大。

数据并行化是指将数据分成块,为每块数据分配单独的处理单元。当需要在大量数据上执行同样操作时,数据并行化很管用。

在任务并行化中,线程不同,工作各异。我们最常遇到的Java EE应用容器便是任务并行化的例子之一,每个线程不光可以为不同用户服务,还可以为同一个用户执行不同的任务,比如登录或往购物车添加商品。

阿姆达尔定律,一个简单定律,预测搭载多核处理器的机器提升速度的理论最大值。以一段完全串行化的程序为例,如果将其一半改为并行化处理,则不管增加多少处理器,其理论上最大速度只是原来的2倍。有了大量的处理器后,现在这已经是现实了,问题的求解时间完全取决于它可被分解成几个部分。

并行化流操作

// 串行
public int serialArraySum() {
    return albums.stream()
                .flatMap(Album::getTracks)
                .mapToInt(Track::getLength)
                .sum();
}

// 并行
public int serialArraySum() {
    return albums.parallelArraySum()
                .flatMap(Album::getTracks)
                .mapToInt(Track::getLength)
                .sum();
}

模拟系统

并行化操作的用武之地是使用简单的操作处理大量数据,比如模拟系统。

蒙特卡洛模拟法,重复相同的模拟很多次,每次模拟都使用随机生成的种子。每次模拟的结果都被记录下来,汇总得到一个对系统全面的模拟。蒙特卡洛模拟法被大量用在工程,金融和计算科学领域。

public Map parallelDiceRolls() {
        double fraction = 1.0 / 100000;
        return IntStream.range(0, 100000) // 创建大小为N流
                .parallel() // 并行化
                .mapToObj(twoDiceThrows()) // 方便在流上使用该函数
                .collect(groupingBy(side -> side), // 合并所有结果的流 
                        summingDouble(n -> fraction)); // 将数字映射为1/N并且相加
    }

    private static IntFunction twoDiceThrows() {
        return i -> {
            ThreadLocalRandom random = ThreadLocalRandom.current();
            int firstThrow = random.nextInt(1, 7);
            int secondThrow = random.nextInt(1, 7);
            return firstThrow + secondThrow;
        };
    }

The sequence of values generated from a Random should be uncorrelated.
Similarly the sequence of values generated from a ThreadLocalRandom should be uncorrelated with values from itself, and values from any other ThreadLocalRandom with a different seed.
So switching to ThreadLocalRandom should give values with the same statistics as using Random.
The advantage is that you avoid any need for synchronization.

// 手动线程池写法
public class ManualDiceRolls {

    private static final int N = 1000000000;

    private final double fraction;
    private final Map results;
    private final int numberOfThreads;
    private final ExecutorService executor;
    private final int workPerThread;

    public static void main(String[] args) {
        ManualDiceRolls roles = new ManualDiceRolls();
        roles.simulateDiceRoles();
    }

    public ManualDiceRolls() {
        fraction = 1.0 / N;
        results = new ConcurrentHashMap<>();
        numberOfThreads = Runtime.getRuntime().availableProcessors();
        executor = Executors.newFixedThreadPool(numberOfThreads);
        workPerThread = N / numberOfThreads;
    }

    public void simulateDiceRoles() {
        List> features = submitJobs();
        awaitCompletion(features);
        printResults();
    }

    private void printResults() {
        results.entrySet().forEach(System.out::println);
    }

    private void awaitCompletion(List> features) {
        features.forEach((future -> {
            try {
                future.get();
            } catch (InterruptedException | ExecutionException e) {
                e.printStackTrace();
            }
        }));
        executor.shutdown();
    }

    private List> submitJobs() {
        List> futures = new ArrayList<>();
        for (int i = 0; i < numberOfThreads; i++) {
            futures.add(executor.submit(makeJob()));
        }
        return futures;
    }

    private Runnable makeJob() {
        return () -> {
            ThreadLocalRandom random = ThreadLocalRandom.current();
            for (int i = 0; i < workPerThread; i++) {
                int entry = twoDiceThrows(random);
                accumulateResult(entry);
            }
        };
    }

    private void accumulateResult(int entry) {
        results.compute(entry, (key, previous) ->
            previous == null ? fraction : previous + fraction
        );
    }

    private int twoDiceThrows(ThreadLocalRandom random) {
        int firstThrow = random.nextInt(1, 7);
        int secondThrow = random.nextInt(1, 7);
        return firstThrow + secondThrow;
    }
}

限制

之前调用reduce方法,初始值可以为任意值,为了让其在并行化时能工作正常,初始值必须为组合函数的恒等值。拿恒等值和其他值做reduce操作时,其它值保持不变。比如使用reduce操作求和,组合函数为(acc, element) -> acc + element,其初始值必须为0,因为任何数字加0,值不变。

reduce操作的另一个限制是组合操作必须符合结合律。这意味着只要序列的值不变,组合操作的顺序不重要。

要避免持有锁

parallel和sequential,最后调用的那个方法起效。

性能

影响并行流性能的主要因素有五个
1. 数据大小
2. 源数据结构 每个管道的操作都是基于一些初始数据源,通常是集合 。
3. 装箱 处理基本数据类型要比处理装箱类型快。
4. 核的数量 越多越好
5. 单元处理开销,这是一场并行执行花费时间和分解合并操作开销之间的战争。花在流中每个元素身上的时间越长,并行操作带来的性能提升越明显。

// 并行求和
private int addIntegers(List<Integer> values) {
        return values.parallelStream()
                .mapToInt(i -> i)
                .sum();
    }

在底层,并行流沿用了fork/join框架。fork递归式地分解问题,然后每段并行执行,最终由join合并结果,返回最后的值。

分解操作

性能好:ArrayLit,数组或IntStream.range,这些数据结构支持随机读取,也就是说它们可以被轻而易举地被任意分解。
性能一般:HashSet、TreeSet,这些数据结构不易,但大多数时候是可能的。
性能差:可能要花O(N)的时间复杂度来分解问题。其中包括LinkedList,Stream.iterate,BufferedReader.lines,后两种长度位置,很难预测应该哪里分解。

流单独操作每一块时,分成两种不同的操作:无状态和有状态,有状态则有维护状态所需的开销和限制。

无状态:map,filter,flatMap
有状态:sorted,distinct,limit。

并行化数组操作

Arrays.java

parallelPrefix 任意给定一个函数,计算数据的和
parallelSetAll 使用Lambda表达式更新数组元素
parallelSort 并行化数组元素排序。

// 使用并行化数组操作初始化数组
public static double[] parallelInitialize(int size) {
        double[] values = new double[size];
        Arrays.parallelSetAll(values, i -> i);
        return values;
    }

parallelPrefix擅长对时间序列数据做累加,他会更新一个数组,将每一个元素替换为当前元素和其前驱元素的和,这里的和是一个宽泛的概念,可以是任意一个BinaryOperator.

    // 计算简单滑动平均数
    public static double[] simpleMovingAverage(double[] values, int n) {
        double[] sums = Arrays.copyOf(values, values.length); // 复制一份输入数据(并行改变数组内容)
        Arrays.parallelPrefix(sums, Double::sum); // 执行并行操作,将数组元素相加
        int start = n - 1;
        return IntStream.range(start, sums.length)
                .mapToDouble(i -> {
                    double prefix = i == start ? 0 : sums[i - n]; // 前一个值
                    return (sums[i] - prefix) / n; // 总和减去窗口起始值,然后再除以n得到平均值
                })
                .toArray(); // 流转数组
    }

测试驱动开发

Kent Beck 写的
Test-Driven Development,以及由 Steve Freeman 和 Nat Pryce 写的 Growing Object-Oriented
Software, Guided by Tests(两本书均由 Addison-Wesley 出版社出版)

在选择内部设计模型时,想想以何种形式向外展示API是大有裨益的。

进进出出,摇摇晃晃

如果你发现自己代码不断地查询和操作某对象,目的只是为了在最后给该对象设个值,那么这段代码本该属于你所操作的对象。

一种方式:传入代码即是数据

与其查询并设置一个对象的值,不如传入一个Lambda,该表达式按照计算的出的值执行相应的行为。

Logger logger = new Logger();
logger.debug(() -> "Look at this: " + expensiveOperation());

孤独的覆盖

这个代码异味是使用集成,其目的只是为了覆盖一个方法。ThreadLocal就是一个很好的例子。ThreadLocal能创建一个工厂,为每个线程最多产生一个值。这是确保非线程安全的类在并发环境下安全使用的一种简单方式。

// 在数据库中查找艺术家
ThreadLocal thisAlbum = new ThreadLocal() {
        @Override
        protected Album initialValue() {
            return database.lookupCurrentAlbum();
        }
    };

在Java8中,可以为工厂方法withInitial传入一个Supplier实例来创建对象。

ThreadLocal thisAlbum = 
        ThreadLocal.withInitial(() -> database.lookupCurrentAlbum());

有点是任何已有的Supplier实例不需要重新封装,鼓励了重用和组合。
信躁降低,JVM少加载一个类。

同样的东西写两遍

Don`t Repeat Yourself, 重复的样板代码,产生了更多需要测试的代码。这样的代码难于重构,一改就坏。

如果有一个整体上大概相似的模式,只是行为上有所不同,就可以试着加入一个Lambda表达式。

ToLongFunction,传入不同的数据结构,返回相同的数据结构;

Lambda表达式的单元测试

单元测试是测试一段代码的行为是否符合预期的方式。

第一种,将Lambda表达式放入一个方法测试,这种方法时测试方法本身,而不是Lambda表达式本身。

第二种,不使用Lambda。

第三种,改成方法引用,任何Lambda都可以改写为普通方法,然后使用方法引用直接引用。

在测试替身时使用Lambda表达式

编写但愿测试的常用方式之一是使用测试替身描述系统中其他模块的期望行为。因为单元测试可以脱离其他模块来测试你的类或方法,测试替身让你能用但愿测试来实现这种隔离。

测试替身也常被成为模拟,事实上测试存根和模拟都属于测试替身。区别是模拟可以验证代码的行为。

测试代码时,使用Lambda表达式的最简单方式就是实现轻量级的测试存根。如交互的类本身就是一个函数接口,实现这样的存根就非常简单和自然。

// 结合Mockito框架使用Lambda表达式
List<String> list = mock(List.class);
when(list.size()).thenAnswer(inv -> otherList.size());
assertEqual(3, list.size());

Mockito使用Answer接口允许用户提供其他行为(代码即数据)。

惰性求值和调试

在传统的命令式编程看来,代码就是达到某种目的的一系列行动,在行动前后查看程序状态是有意义的。在Java8中,你仍然可以使用IDE提供的各种调试工具,但有时需要调整实现方式,以期达到更好的结果。

日志和打印消息

解决方案:peek

流有一个方法能让你查看每个值,同时能继续操作流。这就是peek方法。

Set nationalities
    = album.getMusician()
            .filter(artist -> artist.getName.startsWith("The"))
            .map(artist -> artist.getNationality())
            .peek(nation -> System.out.println("Found nationality: " + nation))
            .collect(Collectors.toSet());

peek方法还能以同样的方式,将输出定向到现有的日志系统中,比如log4j。

在流中设置断点

可以在peek方法中加入断点,这样就逐个调试流中的元素。

此时,peek方法可知包含一个空的方法体,只要能设置断点就行。有一些调试器不允许在空的方法体重设置断点,此时,我将值简单的映射为其本身。

设计和架构的原则

软件开发最重要的设计工具不是什么技术,而是一个在设计原则方面训练有素的头脑。

SOLID原则

Lambda表达式改变了设计模式

设计模式是人们熟悉的另一种设计思想,他是软件架构中解决通用问题的模板。

曾经风靡一时的单例模式,在过去十年里,人们批评它让程序变得脆弱,且难于测试。敏捷开发的流行,让测试显得更加重要,单例模式的这个问题把它变成了一个反模式:一种应该避免使用的模式。

命令者模式

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

命令接受者:执行实际任务
命令者:封装了所有调用命令执行者的信息
发起者:控制一个或多个命令的顺序和执行
客户端:创建具体的命令者实例

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

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

策略模式

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

文件压缩就是一个很好的例子。

// 定义压缩数据的策略接口
public interface CompressionStrategy {
    public OutputStream compress(OutputStream data) throws IOException;
}
// 使用gzip算法压缩数据
public class GzipCompressionStrategy implements CompressionStategy {
    @Override
    public OutputStream compress(OutputStream data) throws IOException {
        return new GZIPOutputStream(data);
    }
}
// 使用zip算法压缩数据
public class ZipCompressionStrategy implements CompressionStategy {
    @Override
    public OutputStream compress(OutputStream data) throws IOException {
        return new ZIPOutputStream(data);
    }
}
// 构造类时提供压缩策略
public class Compressor {
    private final CompressionStrategy strategy;

    public Compressor(CompressionStrategy strategy) {
        this.stragegy = stragegy;
    }

    public compress(Path inFile, File outFile) throws IOException {
        try (OutputStream outStream = new FileOutputStream(outFile)) {
            Files.copy(inFile, stragegy.compress(outStream));
        }
    }
}
// 使用具体的策略类初始化
Compressor gzipCompressor = new Compressor(new GzipCompressStragegy());
gzipCompressor.compress(inFile, outFile);

// 使用方法引用去样板化代码
Compressor gzipCompressor = new Compressor(GZIPOutputStream::new);
gzipCompressor.compress(inFile, outFile);

从某种角度来说,将大量代码塞进一个方法会让可读性变差是决定如何使用Lambda表达式的黄金法则。编写一般方法时的黄金法则!

模板方法

模板方法真正要做的是将一组方法调用按一定顺序组织起来。。如果用函数接口表示函数,用Lambda表达或者方法引用实现这些接口,相比使用继承构建算法,就好得到极大的灵活性。

public class LoanApplication {
    private final Criteria identity;
    private final Criteria creditHistory;
    private final Cruteria incomeHistory;

    public LoanApplication(Criteria identity,
                            Criteria crediHistory,
                            Criteria incomeHistory) {
        this.identity = identity;
        this.creditHistory = creditHistory;
        this.incomeHistory = incomeHistory;
    }

    public void checkLoanApplication() throws ApplicationDenied {
        identity.check();
        creditHistory.check();
        incomeHistory.check();
        reportFindings();
    }
}

// 如果申请失败,函数接口Criteria抛出异常
public interface Criteria {
    public void check() throws ApplicationDenied;
}

采用这种方式的好处是不需要再LoanApplication极其子类中实现算法,分配功能是有了更大的灵活性。比如,我们想让Company类负责所有的检查,那么Company类就会多出一系列方法。

使用Lambda表达式的领域专用语言

领域专用语言(DSL)是针对软件系统中某 特定部分的编程语言。DSL高度专用:不求面面俱到,但求有所专长。

人们通常将DSL分为两类:内部DSL和外部DSL。外部DSL脱离程序源码编写,然后单独解析和实现。比如级联样式表(CSS)和正则表达式,就是常用的外部DSL。内部DSL嵌入编写他们的编程语言中。如果读者使用过JMock和Mockito等模拟类库,或用过SQL构建API,如JOOQ或Querydsl,那么就知道什么是内部DSL。

BDD:行为驱动开发,DBB DSL:LambdaBehave。

BDD是测试驱动开发(TDD)的一个变种,它的重点是描述程序的行为,而非一组需要通过的单元测试。
灵感来源Jasmine(JavaScript BDD)。

概念:
- 每一个规则描述了程序的一种行为;
- 期望是描述行为的一种方式,在规则中定义;
- 对个规则合在一起,形成一个套件。

这些概念在传统的测试框架,比如Junit,规则对应一个测试方法,期望对应断言,套件对应一个测试类。

使用Java编写DSL

// 描述Stack的案例
public class StackSpec {{

    describe("a stack", it -> {

        it.should("be empty when created", expect -> {
            expect.that(new Stack()).isEmpty();
        });

        it.should("push new elements onto the top of the stack", expect -> {
            Stack<Integer> stack = new Stack<>();
            stack.push(1);

            expect.that(stack.get(0)).isEqualTo(1);
        });

        it.should("pop the last element pushed onto the stack", expect -> {
            Stack<Integer> stack = new Stack<>();
            stack.push(2);
            stack.push(1);

            expect.that(stack.pop()).isEqualTo(2);
        });

    });

}}

描述行为首先看到的是describe这个动词,简单导入一个静态方法就够了。Description类就是我们定义的DSL中的it。

// 从describe方法开始定义规则
public static void describe(String name, Suite behavior) {
    Description description = new Description(name);
    behavior.specifySuite(description);
}

每个套件的规则描述由用户使用一个Lambda表达式实现,因此我们需要一个Suite函数接口表示规则组成的套件。

public interface Suite {
    public void specifySuite(Description description);
}

// 每条规则都是一个实现该接口的Lambda表达式
public interface Specification {
    public void specifyBehaviour(Expect expect);
}

我们希望用户可以使用it.should命名他们的规则,这就是说Description类需要有一个should方法。

public void should(String description, Specification specification) {
    try {
        Except except = new Except();
        specification.specifyBehaviour(except);
        Runner.current.recordSuccess(suite, description);
    } catch (AssertionError cause) {
        Runner.current.recordFailure(suite, description, cause);
    } catch (Throwable cause) {
        Runner.current.recordFailure(suite, description, cause);
    }
}

规则通过expect.that描述期望的行为,也就是说Expect类需要一个that方法供用户调用。

// 期望链的开始
public final class Expect {
    public BoundException that(Object value) {
        return new BoundException(value);
    }
}
// 匿名构造函数,可以执行任意的Java代码块
public class StackSpec {
    {
        ...
    }
}

使用Lambda表达式的SOLID原则

  • Single responsibility
  • Open/closed
  • Liskov substitution
  • Interface segregation
  • Dependency inversion

单一功能原则

程序中的类或方法只能有一个改变的理由。这是强内聚性设计的一部分,Lambda表达式在方法级别更容易实现单一功能原则。

线程模型也是代码的职责之一

// 利用高阶函数,实现功能单一原则
public long countPrimes(int upTo) {
    return IntStream.range(1, upTo)
                    .parallel()
                    .filter(this::isPrime)
                    .count();
}

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

开闭原则

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

首要目标与单一功能原则类似,让软件易于修改。

不改变实现如何扩展一个类的功能?答案是借助于抽象,可插入新的功能。

ThreadLocal有一个特殊的变量,每个线程都有一个该变量的副本并与之交互。该类的静态方法withInitial是一个高阶函数,传入一个负责生成初始值的Lambda表达式。
这符合开闭原则,因为不用修改ThreadLocal类,就能得到新的行为。给withInitial方法传入不同的工厂方法,就能得到永远不同行为的ThreadLocal实例。

// ThreadLocal日期格式化器,线程安全
ThreadLocal localFormatter = ThreadLocal.withInitial(() -> new SimpleDataFormat());
// 使用
int idForThisThread = localId.get();

开闭原则有另外一种和传统思维不同,那就是使用不可变对象实现开闭原则。不可变是指一经创建就不能改变的对象。

不可变性
- 观测不可变性:在其他对象看来,该类是不可变的
- 实现不可变性:是指对象本身不可变。实现不可变性意味着观测不可变性,反之则不一定成立。

java.lang.String, 只是观测不可变,因为他在第一次调用hashCode方法是缓存了生成的散列值。在其他类看来,这是完全安全的,他们看不出散列值是在每次构造函数中计算出来的,还是从缓存中返回的。

我们说不可变对象实现了开闭原则,是因为他们的内部状态无法改变,可以安全地为其增加新的方法。新增的方法无法改变对象的内部状态,因此对修改是闭合的;但他们又增加了新的行为,因此对扩展是开放的。当然,你还需要留意不要改变程序其他部分的状态。

因其天生线程安全的特性,不可变对象引起了人们的格外注意,他们没有内部状态可变,因此可以安全的在不同线程之间共享。

本质可以看做多态来实现开闭原则。

依赖反转原则

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

让程序变得死板、脆弱,难于改变的方法之一是将上层业务逻辑和底层粘合模块的代码混在一起,因为这两样东西都会随着时间发生变化。

依赖反转原则的目的是让程序员脱离底层粘合代码,编写上层业务逻辑代码。这样就让上层代码依赖于底层细节的抽象。

// 剥离文件处理功能后的业务逻辑
public List findHeadings(Reader input) {
    return withLinesOf(input, 
                        lines -> lines.filter(line -> line.endWith(":"))
                        .map(line -> line.substring(0, line.length() - 1))
                        .collect(toList)),
                        HeadingLookupException::new);
}

private  T withLineOf(Reader input, Function, T> handler, Fucntion error) {
    try (BufferedReader reader = new BufferedReader(input)) {
        return handler.apply(reader.lines());
    } catch (IOException e) {
        throw error.apply(e);
    }
}

总结下来,高阶函数提供了反转控制,这就是依赖反转的一种形式,可以很容易地和Lambda表达式一起使用。依赖反转原则另外值得注意的一点是待依赖的抽象不必是接口。这里我们使用Stream对原始的Reader和文件处理做抽象,这种方式也适用于函数式编程语言中的资源管理—通常使用高阶函数管理资源,接受一个回调函数使用打开的资源,然后再关闭资源。

事实上,如果Java7就要Lambda表达式,那么Java7中的try-with-resources功能可能只需要一个库函数就能实现。

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

在处理大量数据时,并行化处理并不是唯一可用的线层模型。

非阻塞式I/O,也叫异步I/O,可以处理大量并发网络连接,而且一个县城可以为多个连接服务。

在设计里不共享任何状态

为了确保不再verticle对象之间共享状态,我们对事件总线上传递的消息做了某些限制。String是天生不可变的,但是Buffer是可变的,如果使用不当,消息发送者和接受者都可以通过读写消息共享状态。

Vert.x通过在发送消息时复制消息的方式来避免这种问题。这保证了接受者得到了正确的结果,又不会共享状态。

不可变可以解决,复制也可以解决状态问题。

基于消息传递的系统让隔离错误变得简单,也便于编写可靠的代码。如果一个消息处理程序发生错误,可以选择重启本地verticle对象,而不用去重启整个JVM。

使用Lambda表达式表示行为,构建API来管理并发。

末日金字塔

Future

构建 复杂并行操作的另外一种方案是使用Future。Future像一张欠条,方法不是返回一个值,而是返回一个Future对象,该对象第一次创建时没有值,但以后能拿它“换回”一个值;

调用Future对象的get方法取值会阻塞当前线程直到返回一个值。

我们真正需要的是不必调用get方法阻塞当前线程,就能操作Future对象返回的结果。

CompletableFuture

结合了Future对象打欠条的主意和使用回调处理事件驱动的任务。其要点是可以组合不同的实例,而不用担心末日金字塔问题。

在其他语言中这被叫做延迟对象或约定。在Google Guava类库和Spring框架中,这被叫做ListenableFutures.

public Album lookupByName(String albumName) {
    CompletableFuture> artistLookup
        = loginTo("artist")
        .thenCompose(trackLogin -> lookupArtists(albumName, artistLogin));

    return loginTo("track")
        .thenCompose(trackLogin -> lookupTracks(albumName, trackLogin))
        .thenCombine(artistLookup, (tracks, artist) -> new Album(albumName, tracks, artists))
        .join();
}

创建CompletableFuture对象分两部分:创建对象和传给它欠客户代码的值。

// 异步创建CompletableFuture lookupTrack(String id) {
    return CompletableFuture.supplyAsync( () -> {
        // 这里会做一些繁重的工作
        return track;
    }, services)
}

supplyAsync方法接受一个Supplier对象作为参数,然后执行它,提供一个叫做service的Executor,告诉CompletableFuture对象在哪里执行任务。如果没有提供Executor,就会使用相同的fork/join线程池并行执行。

碰上异常,CompletableFuture提供了completeExceptionally,用于处理异常情况,该方法可以视为complete方法的备选项,但不能同时调用complete和completeExceptionally方法。

// 出现错误时完成Future
future.completeExceptionally(new AlbumLookupException("Unable to find " + name));

CompletableFuture接口有很多有用的方法,可以各种组合使用。

用例(Use Cases)

  • 在链的末端执行一些代码而不返回任何值,比如Consumer和Runnable,那就看看thenAccept和thenRun方法。
  • 可使用thenApply方法转换CompletableFuture对象的值,有点像使用Stream的map方法。
  • 在CompletableFuture对象出现异常时,可使用exceptionally方法修复,可以将一个函数注册到该方法,返回一个替代值。
  • 如果你想要一个map,包含异常情况和正常情况,请使用handle方法。
  • 要找出CompletableFuture对象到底出了什么问题,可使用isDone和isCompletedExceptionally方法辅助调查。

响应式编程

CompletableFuture 背后的概念可以从单一的返回值推广到数据流,这就是响应式编程。

RxJava类库引入一个叫做Observable的类,代表一组待相应的时间,可理解为一沓欠条。

和Stream接口很像,都需要使用Lambda表达式将行为和一般的操作关联、都需要将高阶函数链接起来定义完成任务的规则。相同的操作map、filter、reduce。

最大的不同在于用例。Stream是为构建内存中集合的计算流程而设计的,而RxJava则是为了组合异步和基于事件的系统流程而设计的。它没有取数据,而是把数据放进去。换个角度理解RxJava,它是处理一组值,而CompletableFuture来处理一个值。

Stream为了计算出最终的结果,RxJava在线程模型上像CompletableFuture。

使用CompletableFuture时,我们通过给complete方法一个值来偿还欠条。而Observable代表了一个事件流,我们需要有能力传入多个值。

Apache Camel已经加入了一个叫做Camel RX的模块,可以使用RxJava。
Vert.x项目也启动了一个Rxify他的API项目。

何时何地使用新技术

CompletableFuture和RxJava相对较新,使用它们仍然有一定的复杂度。
对很多问题来说,传统的阻塞式Web应用开发技术就足够了。
如果还能用,就别修理。

事件驱动和响应式应用正在变得越来越流行,而且经常会是为你的问题建模建模的最好方式之一。

两种情况适合:

业务逻辑本身就是有事件来描述,Twitter,订阅文字流信息,用户彼此之间推送消息。图形化展示股票价格,每次变价都可认为是一个事件。

另一种用例是同时处理大量I/O操作。阻塞式需要使用大量线程,导致大量锁之间的竞争和太多上下文切换。处理成千上万的连接,非阻塞更好。

Next Step

亲不尊,熟生蔑

你可能感兴趣的:(读书笔记)