《Java8实战》学习笔记(二)——Optional、CompletableFuture以及相关重构方式

本文为学习笔记的第二篇,主要为书中内容的Chapter8-16,其中Java8新的时间类,Scala对比等章节笔记做了省略。

三、关于高效使用Java8

1.代码重构

java8重构代码,主要有三种重构的方式:

  1. 用lambda表达式替换单一抽象方法的匿名类。在涉及重载的上下文中,匿名类直接初始化就会确定类型,而lambda表达式可能需要类型推断,这种情况下可使用显式的类型推断解决。
  2. 用方法引用代替lambda表达式,方法可以有方法名,往往能更直接地表达出这段代码的意图,库里的一些自带的静态方法以及Collectors接口的很多内建方法应该优先使用。
  3. 所有的迭代器处理数据的代码尽量都转换成Stream,合适的流水线操作可读性很强,且有着并行化的优化空间。

2.可以立即重构的几个小trick

关于Lambda和设计模式的结合,下面小节才会讲。
这个小节主要是一些情况还不足以被设计模式解决,但这些情况出现时,就已经可以被改善用上。

  1. 有条件的延迟执行。如果在一个条件判断里需要查询一个对象的状态,然后具体会根据判断的结果去运行一些事情。那不如考虑创建一个新的方法,以Lambda或者方法表达式作为参数,新方法在检查完该对象的状态后才调用老方法。
  2. 环绕执行的重复代码可以被复用,这个上一个学习笔记讲过。

第一个比较绕,还是要细说一下,主要意思就是有些有些场景的代码执行后,结果不一定会被使用,从而造成性能浪费。而Lambda表达式是延迟执行的,这正好可以作为解决方案,提升性能。
比如下面一段代码,不管log方法如何,字符串肯定会被拼接,然后log方法内部却是有条件使用的,那拼接这个字符串就是浪费性能。扩展点看,这个可能是个更浪费性能的事情,应该想办法根据具体情况去延迟执行。

public class Demo01Logger {
    private static void log(int level, String msg) {
        if (level == 1) {
            System.out.println(msg);
        }
    }

    public static void main(String[] args) {
        String msgA = "Hello ";
        String msgB = "world ";
        String msgC = "Java ";
        log(1,msgA + msgB + msgC);
    }
}

备注:SLF4J是应用非常广泛的日志框架,它在记录日志时为了解决这种性能浪费的问题,并不推荐首先进行字符串的拼接,而是将字符串的若干部分作为可变参数传入方法中,仅在日志级别满足要求的情况下才会进 行字符串拼接。例如:LOGGER.debug("变量{}的取值为{}。", "os", "macOS") ,其中的大括号 {} 为占位符。如果满足日志级别要求,则会将“os”和“macOS”两个字符串依次拼接到大括号的位置;否则不会进行字符串拼接。这也是一种可行解决方案,但Lambda可以做到更好

按照上面的说法应该改成下面这样:

import java.util.function.Supplier;

public class Demo04LoggerSupply {
    private static void log(int level, String msg) {
        System.out.println(level + ":" + msg);
    }

    public static void log(int level, Supplier<String> msg) {
        if (level == 1) {
            log(level, msg.get());
        }
    }

    public static void main(String[] args) {
        String msgA = "Hello ";
        String msgB = "world ";
        String msgC = "Java ";
        log(1, () -> {
            System.out.println("Lambda执行!");
            return msgA + msgB + msgC;
        });
    }
}

3.结合lambda和设计模式进行代码重构

设计模式在这本书中比喻成建筑工程师心里的一些固有的蓝图,而Lambda表达式可以看作是为这些蓝图提供了一件利器,使得蓝图用起来更简单高效。
原来设计模式的学习笔记都在

a.策略模式

这是当时学的第一个设计模式。主要就是针对接口编程,将策略先抽象成一个接口,将这个接口嵌入某个真实使用客户代码中使用,然后接口的多个实现就是具体的多个策略被实际调用。
假如这个接口只包含一个抽象方法,这就是一个函数式接口,使用起来理所应当变得简单清晰,不用声明新的类,确定好函数描述符直接传递lambda表达式即可。

b.模版方法模式

这是当时的第八个设计模式,和策略模式有点像,方法的代码里里弄了些抽象的钩子方法,然后结合好莱坞原则,去让各种实现类实现这些钩子方法被他们调用。
对于Lambda来说,这根本不用设计成抽象类了,因为可以将每个抽象方法改成函数式接口,直接传递Lambda实现。

对比下面两个类很直接来看,前者需要继承父类实现之后才能运行,而下面的OnlineBankingLambda类则直接传递之后即可运行,方便很多。

public abstract class OnlineBanking {
    public void processCustomer(int id){
        Customer c = Database.getCustomerWithId(id);
        makeCustomerHappy(c);
        makeCustomerAgain(c);
    }
    abstract void makeCustomerHappy(Customer c);

    abstract String makeCustomerAgain(Customer c);


    // dummy Customer class
    static private class Customer {}
    // dummy Datbase class
    static private class Database{
        static Customer getCustomerWithId(int id){ return new Customer();}
    }
}

public class OnlineBankingLambda {

    public static void main(String[] args) {
        new OnlineBankingLambda().processCustomer(1337, (Customer c) -> System.out.println("Hello!"), c -> "ssss");
    }

    public void processCustomer(int id, Consumer<Customer> makeCustomerHappy, Function<Customer, String> makeCustomerAgain) {
        Customer c = Database.getCustomerWithId(id);
        makeCustomerHappy.accept(c);
        makeCustomerAgain.apply(c);
    }

    // dummy Customer class
    static private class Customer {
    }

    // dummy Database class
    static private class Database {
        static Customer getCustomerWithId(int id) {
            return new Customer();
        }
    }
}

c.观察者模式

这是当时学的第二个设计模式,观察者和主题之间松耦合的进行交互,java中有相关的内置观察者类Observer,只有一个单抽象方法update,天生可以用lambda表达式去实例化一个Observer。
但是有可能真正的观察者需要继承多个接口拥有多个抽象方法,或者观察者要持有状态,此时还是应该使用类实现的方式。

d.责任链模式

这是当时在后传里写的设计模式,一个处理对象可能需要完成一些工作以后,传递给下一个让他完成,以此直到结束。一般定义一个抽象类,抽象类中定一个字段记录后续的对象。
听起来就像是函数进行链接传递,使用andThen函数即可,像下面代码一样。

import java.util.function.Function;
import java.util.function.UnaryOperator;


public class ChainOfResponsibilityMain {

    public static void main(String[] args) {
        ProcessingObject<String> p1 = new HeaderTextProcessing();
        ProcessingObject<String> p2 = new SpellCheckerProcessing();
        p1.setSuccessor(p2);
        String result1 = p1.handle("Aren't labdas really sexy?!!");
        System.out.println(result1);


        UnaryOperator<String> headerProcessing =
                (String text) -> "From Raoul, Mario and Alan: " + text;
        UnaryOperator<String> spellCheckerProcessing =
                (String text) -> text.replaceAll("labda", "lambda");
        Function<String, String> pipeline = headerProcessing.andThen(spellCheckerProcessing);
        String result2 = pipeline.apply("Aren't labdas really sexy?!!");
        System.out.println(result2);
    }

    static private abstract class ProcessingObject<T> {
        protected ProcessingObject<T> successor;

        public void setSuccessor(ProcessingObject<T> successor) {
            this.successor = successor;
        }

        public T handle(T input) {
            T r = handleWork(input);
            if (successor != null) {
                return successor.handle(r);
            }
            return r;
        }

        abstract protected T handleWork(T input);
    }

    static private class HeaderTextProcessing
            extends ProcessingObject<String> {
        public String handleWork(String text) {
            return "From Raoul, Mario and Alan: " + text;
        }
    }

    static private class SpellCheckerProcessing
            extends ProcessingObject<String> {
        public String handleWork(String text) {
            return text.replaceAll("labda", "lambda");
        }
    }
}

e.工厂模式

这是当时学的第四种设计模式,使用一种方式进行判断创建出同一接口不同的实现。使用Supplier函数即可完成替代,注意如果不是无参构造函数,就可能要自己编写函数接口进行实现。

        public static Product createProduct(String name){
            switch(name){
                case "loan": return new Loan();
                case "stock": return new Stock();
                case "bond": return new Bond();
                default: throw new RuntimeException("No such product " + name);
            }
        }

        public static Product createProductLambda(String name){
            Supplier<Product> p = map.get(name);
            if(p != null) return p.get();
            throw new RuntimeException("No such product " + name);
        }

总结下来,这五种其实都是在设计模式的某一方面使代码更简洁,函数式接口的单抽象方法的限制使得其并不能完全替代某种设计模式,而只是有锦上添花的作用。

4.Lambda表达式的测试

程序开发不是编写出优美的代码,而是正确的代码。
单元测试,能保证程序的行为与预期一致。

  1. 由于Lambda表达式并无函数名,有时候你可以将可见的Lambda表达式封装为某个字段,利用字段进行测试。
  2. 直接测试使用Lambda表达式的方法,验证方法的最终结果,忽略具体过程细节。
  3. 很复杂的Lambda表达式,可以拆分成多个不同的方法里面

5.Lambda表达式的调试

调试传统的两大武器式:查看栈跟踪信息,输出日志

  1. 查看栈跟踪信息。通常方法的每次调用都会产生相应的调用信息,包括方法调用的位置,使用的参数,相关变量,这些调用信息放在栈帧上。栈跟踪就是通过一个另一个的栈帧得到相关信息。但是Lambda表达式的栈跟踪信息没有方法名式比较难查看的。
  2. 使用日志调试。流水线的peek操作可以帮助我们清除的理解流水线每一步的操作。

四、默认方法

Java8的一个新特点是:

  1. 允许在接口内声明静态方法
  2. 引入默认方法,可以指定接口方法的默认实现

注意:默认方法的引进是为了以兼容的方式解决类库的演进问题。

由于一旦接口添加方法,所有以前的实现类都要重新更新提供实现以适配这个接口的变化,这真的是罪恶之源。
而默认方法这提供了一种平滑的方式使得改动不会导致已有代码的修改,让实现类的修改变得更加灵活。

1.使用默认方法的两种情况

a.可选方法 一个接口中定义的某些方法并不是所有实现类都需要,原来的情况是实现类把该方法进行留白,使用默认方法以后可以减少这样的留白空方法。
b.行为的多继承 Java中只能继承一个类,但可以实现多个接口。但由于Java8中可以在接口中实现代码,所以可以使用这个能力去继承接口中的方法,组合各种行为达到代码复用的目的。

2.默认方法的冲突解决

一个类实现多个接口的时候,可能会出现继承的多个接口中的默认方法的函数签名是一样的。这种情况极少发生,但是又可能发生就要制定规则去解决这样的不确定性问题。

  • 类中的方法优先级最高。类中方法>父类方法>默认方法
  • 子接口的优先级更高。子接口的默认方法>父接口的默认方法
  • 如果接口之间也是平行关系,编译器会直接报错无法编译,需要显式的消解歧义。X.super.m()。X是父接口的名字。
  • 菱形继承问题,同样需要向上一条一样显式的添加实现。

五、Optional

实习的时候,写的代码天天NPE,因为抛出一个异常对JVM来说代价是很高的,一定要注意。

1.传统的解决方式

解决NPE问题,常用的解决方案是:

  1. 采用防御式的检查,进行大量的if(XX != null)的判断在语句中内嵌新的判断。
  2. 大量的退出语句,进行if(XX == null)的并列判断。

两者皆代码可读性很差,且很难维护。

2.Optional类的灵感

Groovy是通过引入安全导航操作符去安全访问可能为null的变量。def name = person?.car?.insurance?.name
Scala则是使用Option[T]进行封装,它既可以包含类型为T的变量,也可以不包含该变量。
Haskell使用Maybe类型,既可以包含值,也可以什么都不是。
Java8从Scala汲取的灵感,引入了Optional类。

3.Optional类的几个重要特点

a.Optional类是对类进行简单的封装,变量不存在时,缺失的值会被建模成一个空的Optional对象,由Optional.empty()方法返回。
b.Optional.empty()是Optional的一个有效对象,不会像null一样直接出现NPE。
c.Optional并不是要消除每一个null引用,目标是为了更好的设计出普适的API。

4.使用Optional

创建Optional对象

  1. 使用Optional.empty()方法创建一个空的Optional对象。
  2. 依据一个非空值使用of方法创建Optional对象。
  3. 薛定谔的对象使用ofNullable进行创建。

Optional对象的常用操作

  1. map 和Stream的map方法很像,只是变成了一个只包含一个元素的氮元素集合数据,输入Optional,map转换的结果仍然是Optional对象
  2. flatMap 和Stream的flatMap方法很像,map会导致出现嵌套的Optional对象,flatMap方法将生成的多层单元素流抚平成单层的Optional流。
  3. get 最简单但并不安全,基本不直接使用,变量不存在会抛出NoSuchElementException
  4. orElse(T other) 允许你在Optional不存在值时提供一个默认值
  5. orElseGet(Supplier<? extend T> other) orElse的延迟调用版,Supplier将会在值不存在时进行调用。默认值创建影响性能时应该考虑。
  6. orElseThrow(Supplier<? extend X> exception) 相当于get方法使用值为空时,可定制某种专门的异常类型
  7. ifPresent(Consumer<? extend T> consumer) 假如变量值存在,执行consumer,不存在则不进行操作
  8. isPresent 值存在true,不存在false
  9. filter 剔除不符合谓词条件的元素,使其变为空的Optional对象

基础类型的Optional

与Stream类似,Optional对象也提供了OptionalInt、OptionalDouble和OptionalLong的原始类型的特化类型。
Stream是推荐使用原始类型的,但Optional并不推荐使用OptionalInt这些特化类型,因为Stream包含了大量的元素,装箱是性能浪费的,但是Optional只有单个元素,就没有太多所谓的性能浪费,从另一方面讲,这些特化类型很多有用的方法(map、flatMap、filter)都不支持,因此总体得不偿失。

5.《Effective Java》中的Optional使用意见

《Effective Java》中的第55条:谨慎使用Optional

其理由可以归结为以下几点:

  1. 使用Optional就是为了尽可能的不返回null导致异常,但是有些方法例如ofNullable会在使用Optional还返回null,这是万万不想看到的。
  2. 关于默认缺省值的创建性能可能很大,不要忽略。
  3. 很多对象不应该使用Optional进行嵌套,比如Collection、Map、Stream、数组以及Optional对象,这些本就不应该为null,不必也不该为此去做Optional的封装处理
  4. 当无法返回结果,并且当没有返回结果时客户端必须执行特殊的策略,那么应该用Optional封装返回该对象。
  5. 对于int、long和double,应该使用基本类型,因为Optional需要做两层的封装,这个和《Java8 实战》讲的有点矛盾,捂脸,看情况而定吧
  6. 几乎永远都不适合用Optional作为key、value或者集合和数组中的元素,画蛇添足,应该用的时候用Option判断,而不是直接放进去。

六、CompletableFuture

一方面,就像安迪-比尔定律说的那样,"Andy gives, Bill takes away"。硬件提供的性能,很快会被软件给消耗。反过来其实也表达了,软件设计者经常会思考如何让软件充分利用发挥出硬件能力的提升,比如ForkJoinPool、并行流就是像利用多核处理器的能力。

另一方面,软件领域的架构也在不断升级,一个功能经常mash-up混聚着不同来源的内容,然后将这些内容聚合在一起。

比如,手机淘宝一打开,上面可能需要热点图片,中间的10icon可能也是个性化设计推荐的,再下面的一些新闻推荐和商品推荐更是个性化的,这是一个典型的混聚式应用。

1.关于并行和并发

其实,这就是多任务的并行Parallelism和并发Concurrency两个很重要的概念。
“并行”概念算是“并发”概念的一个子集。并发的反义词是顺序Sequential,而并行的反义词则是串行Serial。

从时空概念上讲,并发是同一时间点,许多任务一起出发需要占用空间去做处理。
而并行是解决并发的一种方案,即同时分配多个空间,让任务去不同的空间去处理。

但解决并发,并非只能并行,一起出发没必要一起同行,可以你先用一会,我用一会,来回切换这点空间,让你我都完成自己的任务。

下面两个常用词:

Parallel Computing:并行计算
Concurrent programming:并发编程

2.Future

Future接口在java5中被引入,设计初衷是对将来某个时刻会发生的结果进行建模。它建模了一种异步计算,返回一个执行计算结果的引用,当计算结束时,这个引用返回给调用方。

Future比Thread更为好用,使用Future,通常只需要和Callable配合,线程池submit的结果就是Future。
Future有get方法,但是会一直阻塞等待返回结果,建议是使用重载版本的get方法,接受一个超时的参数,如果阻塞等待超时时间,就退出。

但是Future接口很难表达出多个Future结果之间的依赖性,这个局限性使得很多问题变得很难完成,例如下面这些:

  1. 两个异步计算结果的合并
  2. Future集合中的所有任务完成
  3. 找到Future集合最快完成任务的结果
  4. 针对Future的完成结果做更多事情
  5. Future任务的完成过程进行定制

3.CompletableFuture

CompletableFuture的设计遵循了Stream流水线的思想。
CompletableFuture之于Future,如Stream之于Collection

同步和异步

同步API就是传统的方法调用,你调用了某个方法,调用方在被调用方运行的过程中会等待,被调用方运行结束后返回,调用方取得被调用方的返回值并继续运行。如果调用方和非调用方时不同的线程,调用方需要等待被调用方结束运行,这就是阻塞式调用。
而异步API会立即返回,或者至少是被调用方在完成计算任务之前,将被调用方的计算任务交给另外一个线程去做,这个线程和调用方是异步的,这也就是非阻塞式调用。执行剩余计算任务的线程会将他的计算结果返回给调用方。返回的方式一般有两种:一种是通过回调函数——Callable的形式,一种是Future的形式——用调用方在执行一个“等待,直到计算完成”的方法调用。

使用CompletableFuture

首先看一段常见的使用代码,先定义futurePrice代表异步计算的对象实例,然后显式fork出来一个线程去执行一些计算,直接将futurePrice最后返回出来。而对于真正的计算结果用futurePrice.complete(price);去进行设置。
此外异步处理可能会导致异常,可以使用futurePrice.completeExceptionally(ex);将异常给抛出来

    public Future<Double> getPrice(String product) {
        CompletableFuture<Double> futurePrice = new CompletableFuture<>();
        new Thread( () -> {
                    try {
                        double price = calculatePrice(product);
                        futurePrice.complete(price);
                    } catch (Exception ex) {
                        futurePrice.completeExceptionally(ex);
                    }
        }).start();
        return futurePrice;
    }

CompletableFuture中提供了大量的工厂方法,使得上面的代码能变得非常简洁,下面的代码会看起来非常的简洁。

    public Future<Double> getPrice(String product) {
        return CompletableFuture.supplyAsync(() -> calculatePrice(product));
    }

4.Stream中的阻塞操作优化

对一个Stream来说,可能某个映射操作用了非常耗时的方法,如果正常的Stream映射,将会每一个元素都顺序的执行这个耗时的方法,整个时间是元素数量*方法耗时=nt。
我们当然可以用并行流进行改善,但是对于每一个数据块来说仍然是顺序执行,只是多了一个分母,处理器的核数=nt/core,n要是远远大于core的数量,仍然是非常耗时的,core=Runtime.getRuntime().availableProcessors()。
最好的方法是将同步转换为异步执行。每一次操作转换为CompeletableFuture去异步执行,然后使用join方法获取运行结果最后进行收集。
注意,下面这段代码用了两个流,因为join操作是阻塞的,那么上面对不同的异步操作构建也会变的去顺序的构建,并没有做到真正的并行。

    public List<String> findPricesFuture(String product) {
        List<CompletableFuture<String>> priceFutures = findPricesStream(product)
                .collect(Collectors.<CompletableFuture<String>>toList());

        return priceFutures.stream()
                .map(CompletableFuture::join)
                .collect(Collectors.toList());
    }

5.定制线程池执行器

并行流API是使用默认的通用线程池,线程池中的线程数量一般是处理器CPU的核数目,是Runtime.getRuntime().availableProcessors()决定的,这是无法进行修改的。
而CompletableFuture默认也是这么多线程数,但是是可以根据计算内容的实际情况进行定制的。
《Java并发编程实战》那本书讲过一个公式,充分利用处理器资源的线程池数量
Nthreads = Ncpu * Ucpu * (1+W/C)
Ncpu: Runtime.getRuntime().availableProcessors()
Ucpu: 期望的CPU利用率,值介于0和1之间
W/C: 等待时间与计算时间比率

假如I/O操作大部分时间在等待,Nthreads可能会创建出一个很大的数值,例如400,但是另一方面可能只同时访问100个对象,所以400并没有意义,可以设置成100更为合理,最终把定制的线程池放入supplyAsync的第二个参数。

CompletableFuture.supplyAsync(() -> calculatePrice(product), executor);

6.并行——使用流还是CompletableFuture?

对集合进行并行计算可以使用并行流和CompletableFuture两种方式。后者由于能对线程池做更多的定制,因此在遇见I/O等待时会发生阻塞的情况下效果更佳。
总体来说:
1.如果是计算密集型的操作,推荐使用Stream接口,实现简单,甚至如果全是计算,理论的最佳线程池数就是处理器核数。
2.如果是并行操作涉及I/O处理,那么使用CompletableFuture的灵活性更好,就像定制执行器说的那样,会更加合适。

7.多个异步任务的流水线操作以及主要方法

  1. get:该方法会一直阻塞直到 Future 完成返回结果。
  2. join:和get类似,唯一不同的地方是如果最顶层的CompletableFuture完成的时候发生了异常,它会抛出一个未经检查的异常,没有catch代码,适合流式处理。
  3. complete:填入参数即Future的结果,可以手工的完成一个 Future;
  4. runAsync:异步的运行一个后台任务并且不想改任务返回任务东西,这时候可以使用 CompletableFuture.runAsync()方法,它持有一个Runnable 对象,并返回 CompletableFuture< Void>。
  5. supplyAsync:当任务不需要返回任何东西的时候, CompletableFuture.runAsync() 非常有用。但是如果你的后台任务需要返回一些结果应该要怎么样?CompletableFuture.supplyAsync() 就是你的选择。它持有supplier 并且返回CompletableFuture,T 是通过调用 传入的supplier取得的值的类型。
  6. thenApply:在流水线上,如果一个异步任务操作下面的一个操作是很快的执行,那么下一个操作可以是同步执行,可使用thenApply去作为两者的桥梁。可以使用 thenApply() 处理和改变CompletableFuture的结果。持有一个Function< R,T>作为参数。Function < R,T> 是一个简单的函数式接口,接受一个T类型的参数,产出一个R类型的结果。可以通过附加一系列的thenApply()在回调方法 在CompletableFuture写一个连续的转换。这样的话,结果中的一个 thenApply方法就会传递给该系列的另外一个 thenApply方法。
  7. thenAccept:如果你不想从你的回调函数中返回任何东西,仅仅想在Future完成后运行一些代码片段,你可以使用thenAccept() 和 thenRun()方法,这些方法经常在调用链的最末端的最后一个回调函数中使用。CompletableFuture.thenAccept() 持有一个Consumer ,返回一个CompletableFuture。它可以访问CompletableFuture的结果
  8. thenRun:不能访Future的结果,它持有一个Runnable返回CompletableFuture
  9. thenCompose:多个异步的任务之间相互依赖,可以使用thenCompose或者thenComposeAsync,两者的区别在于async会让这个新任务重新提交到线程池(默认是ForkJoinPool.commonPool()),而thenCompose是用上一个异步任务的所用的线程,这两个方法主要就是对异步操作建立流水线,向其传入一个函数,当上一个异步操作完成时,其结果会作为这个传入函数的参数。
  10. thenCombine:另一种情况是,两个异步任务有没有依赖到无所谓,但是一个同步操作需要同时获得这两个异步操作的结果,这时候使用thenCombine或者thenCombineAsync,第二个参数,会把前一个异步的结果和当前异步的结果作为参数是一个Bifunction函数去做执行。
  11. allOf:组合任意数量的CompletableFuture对象,接收一个CompletableFuture对象组成的数组,返回CompletableFuture< Void>的对象,但是这只是个对象,可以配合使用join方法阻塞使所有CompletableFuture对象获得结果。
  12. anyOf:和allOf类似,接收一个CompletableFuture对象组成的数组,但是只返回第一个执行完毕的CompletableFuture对象的返回值所构成的CompletableFuture< Object>的对象
  13. exceptionally:回调给你一个从原始Future中生成的错误恢复的机会。你可以在这里记录这个异常并返回一个默认值。
  14. handle:从异常恢复,无论一个异常是否发生它都会被调用。前一个是结果参数,后一个是异常处理。

具体相关示例代码参见yexiaobai的一片天博客:https://segmentfault.com/a/1190000014479792

七、函数式的思考

代码是很难维护的,函数式编程提出的“无副作用”和“不变形”非常有利于解决这个问题。从长远来看,减少共享的可变数据结构能帮助你降低维护和调试程序的代价。

1.声明式编程vs命令式编程

编程实现一个系统,有两种思考方式。
一种专注是如何实现,先做什么,后做什么,然后..,这种“如何做”风格的编程非常适合经典的面向对象编程,有时被称为命令式编程,特点是怎么做都有对应的关键字和相关指令,比如赋值、条件、循环,
另一种更专注做什么,使用内部迭代,最终如何实现留给函数库,代码读起来就像是问题陈述,这种“要做什么”风格的编程被称为声明式的编程。

声明式编程(英语:Declarative programming)是一种编程范式,与命令式编程相对立。它描述目标的性质,让计算机明白目标,而非流程。声明式编程不用告诉计算机问题领域,从而避免随之而来的副作用。而命令式编程则需要用算法来明确的指出每一步该怎么做。
声明式编程通常被看做是形式逻辑的理论,把计算看做推导。声明式编程因大幅简化了并行计算的编写难度,自2009年起备受关注。
声明式语言包包括数据库查询语言(SQL,XQuery),正则表达式,逻辑编程,函数式编程和组态管理系统。

当讨论函数式时,要先明白副作用和引用透明。

  1. 比如一个方法,传一个对象进去,传的对象在方法中被更新了一些字段,就是有副作用的,当输入和输出经过函数这个黑盒时,两者并没有直接的更新关系,就是无副作用。
  2. 参数相同,方法的结果也相同,这是引用透明的方法。无副作用的限制通常都是引用透明的。一个引用透明的函数,可以被看作是函数式编程的基础。

函数式编程:具体实践了声明式编程和无副作用计算,更容易构建和维护系统。如果无副作用,就是纯粹的函数式编程,存在副作用但可以将这些副作用进行隐藏,最终并不会被调用者感知,达到所谓的“没有可感知的副作用”,就是通用意义上的函数式编程。

2.面向对象编程vs函数式编程

极端的面向对象认为:认识事物都是对象,程序要么通过更新字段完成,要么调用与他相关的对象进行更新的方法。
函数式编程推崇:引用透明的函数,方法不应该有(外部可感知的)对象更改。一个对象的值应该当作单纯的值,换句话说,不应该修改一个传进来的参数。当修改对象时,可clone一个新的对象副本,修改副本并返回副本作为结果。

3.高阶函数和科里化

函数值作为一等公民,前面只是想说我们可以利用其达到行为参数化的效果。
但是更有趣的是,如果输入是函数,结果返回也是一个函数,这个方法就可以看作一个高阶函数。

科里化让我联想到罗振宇今年跨年演讲提到的曾国藩的例子——直面挑战,躬身入局者,皆为我辈。

其中的躬身入局有点意思。

一个具有两个个参数的方法想要进行函数转换,只需要一个参数“躬身入局”即可,f(a,b)其实就可以编程只使用一个参数的g,例如a“躬身入局”,原有的f变成了g(b),把g的返回值参数和a结合在一起即f(a,b)=(g(b))(a)=h(g(b),a)。

由此可以推出:一个使用了6个参数的函数f,根据参数计算的独立性和优先级,可以通过科里化变成接受2,4,6三个参数的g,在返回一个接受五号参数的函数,这个函数又返回一个接受剩下的1和3号的参数的函数。

public class Curring {
    static double converter(double x, double y, double z, double a, double b, double c) {
        return x + y + z + a + b * c;
    }

    static DoubleUnaryOperator cu1(double y, double a, double c) {
        return (b) -> y + a + b * c;
    }

    static DoubleUnaryOperator cu2(double x, double z) {
        return (b) -> b + x + z;
    }

    public static void main(String[] args) {
        System.out.println(converter(1, 2, 3, 4, 5, 6));
        System.out.println(cu2(1, 3).applyAsDouble(cu1(2, 4, 6).applyAsDouble(5)));
    }
}

4.数据结构应该怎么更新

破坏式更新:直接修改现有的数据结构
函数式更新:不允许修改任何作为参数进来的结构,因为一旦允许修改,则两次对同一对象相同的调用可能产生不同的结果,违背了引用透明型原则,有很多的副作用。具体做法是创建一个副本,而不是直接修改。

总体感觉:函数式更新比较相信Java的内存分配和垃圾回收机制。

Java的未来

多核能力的充分使用以及声明式简洁的操作形式,是Java8发展的两大趋势。

书中的最后一段(以2014年的时间点去看):
Java8已经占据了一个非常好的位置,可以暂时歇口气,但绝不是终点。

发表评论

电子邮件地址不会被公开。