从用缓存优化函数性能说说第三方框架的使用

导读

前面两篇文章分别从计算机及领域知识编程语言本身的角度聊了聊写代码要考虑的事儿。但是理解了计算机系统、领悟了领域知识、精通了某门编程语言就够了么?我在编程到底难在哪里的回答里说,软件开发是搭积木式的,如果你想搭得好又有价值,一个合理的办法是在已经搭好的基础上继续搭,而不是把所有东西自己重新搭一遍。JDK和.NET Framework都是这种已经搭好了的最基本的架子。想想软件发展了几十年,各式框架已经一应俱全,基本想做个任何事情都已经有现成的框架可用。当然,如果你觉得现在已经搭好的东西就是坨屎,当然可以也应该自己搭个更好的。但是现实的情况大都是:人家明明已经搭好那么多各式各样的建筑并欢迎你去添砖加瓦,结果只是因为自己不知道、不了解那些东西的存在,自己重头搭了个双层火柴盒还觉得不错。软件开发显然比在minecraft里搭积木要复杂且抽象些。随之而来的一个问题就是:可应用框架的地方并不显而易见,尤其是当你觉得自己写一个更快的时候。这也是本文将要讨论的重点。(在校生请不要误会,在校生请夯实基础并从基础搭起,能搭多少搭多少。)

引子

假设我们发现这个下面开平方函数很慢,比如要一秒,我们Profile之后发现函数慢在Math.sqrt这个第三方函数上,遗憾的是下层Math库的作者声称他那边儿没有进一步提速的空间了,而且他已经不再维护了。然而我们死活就是想要提高这个函数的性能,怎么办?

public double sqrt(final double n) {
    return Math.sqrt(n);
}

代码 1

解决方法倒是有不少的,比如升级硬件,比如换一个更快的库,比如开多线程,或者把Math的作者高薪挖过来。只是这些方案的成本都不低。考虑到这个函数是个纯函数,又考虑到内存并不是很贵,如果又能和用户确认一下他们的输入并不是随机的,有比较高的重复度和局域性的话。把这个函数的运算结果保存下来或许是最多快好省的方案了。实现起来也很简单(注:请无视代码2的拆装箱损耗,那不是问题重点)。

private final Map<Double, Double> rootMap = new ConcurrentHashMap<>();

public double sqrt(final double n) {
    return rootMap.computeIfAbsent(n, Math::sqrt);
}

代码 2

这个实现的一个问题就是它只加不删。如果用户真能保证他们的输入有比较高的重复性的话这个问题倒不大。但是保不齐存在一个2B用户没按约定使用,系统最后就OOM了,然后常见的后果是这个软件员会被拉出来背内存泄漏导致宕机的锅。所以保险起见应该给这个Map设定一个上限。到了就把老化的结果删除。(注:请无视代码中try..finally及iterator的用法,那也不是重点)

private final Map<Double, Double> rootMap = new LinkedHashMap<>();
private final int maximumSize = 1000;

public double sqrt(final double n) {
    try {
        return rootMap.computeIfAbsent(n, Math::sqrt);
    } finally {
        if (rootMap.size() > maximumSize) {
            rootMap.remove(rootMap.keySet().iterator().next());
        }
    }
}

代码 3

如果用户只要求精确到小数点后两位就够了。那么一个简单的提高命中率的改进就是把参数先进行舍入再执行运算。

private final Map<Double, Double> rootMap = new LinkedHashMap<>();
private final int maximumSize = 1000;

public double sqrt(final double n) {
    final double r = round(n, 2);
    try {
        return rootMap.computeIfAbsent(r, Math::sqrt);
    } finally {
        if (rootMap.size() > maximumSize) {
            rootMap.remove(rootMap.keySet().iterator().next());
        }
    }
}

public double round(final double n, final int scale) {
    return new BigDecimal(Double.toString(n))
              .setScale(scale, BigDecimal.ROUND_HALF_UP))
              .doubleValue();
}

代码 4

好了,到目前为止,在不考虑线程安全性的前提下,我对这个函数结果留存的实现方案算是比较满意了。然后才是本文的正题:代码4的问题是什么?如何解决?

问题在哪儿

业务逻辑不明确

想象一下,如果你第一次看到这个代码4,你能否一眼看出哪些代码是业务逻辑的需要,哪些不是?

这还仅仅是为保存一个方法的执行结果,就多出了十几行代码。这还没打Log呢,还没处理异常呢,还没加调用信息统计呢,还没做运行时Profiling呢,还没保证多线程安全呢。

请想象一下,你把上面的这些附加功能都按从代码1到代码4的实现方式加上去,这代码还能看么?

解决思路很简单:业务逻辑应该与非业务逻辑分离

代码冗余、维护成本高

软件开发的一个基本的原则就是写的代码要测试一下。你自己写了个round方法,你就得写相应的测试覆盖这个函数吧。你代码多,测试也会多。这都是实现上的成本。

另一方面,这回的情况是这样:你在实现这个功能的时候,发现需要这个round方法,于是自己写了一个。另一个程序员在界面显示的时候为了做截断可能也会自己写个round方法。大家在同一个Team、同一个代码库上工作,最好是不要出现这种很多人做重复性的工作。怎么解决呢?可以写个utils包,每个人想到什么感觉可能对别人也有用的东西就放在里面。

但是这其实是个悖论,如果一个程序员在实际项目上连类似这种round的方法都去自已写,有两种可能:一、公司什么库都不让用或是用之前要走半年流程什么的,这个可以通过跳槽来解决。二、意味他并没有事先查找可用utils的意识,没有这种意识的话,就算有别的成员放在项目内utils包里他大概也是不会找的。当然,这个情况可以这样解决:谁写了之后,大家开个会广告一下。最终结果大概会是:程序员们为了不开会,非常自觉地都不再写任何utils了。

更好的办法就是,只要有哪个著名的开源库里有的,大家就都不要自己写这种常见的功能。当然这个办法对于一小部分人来说也会有问题:谁知道我要的东西在哪个开源库里有?这个问题只有Google和Stackoverflow能回答。然后Google之后发现了一堆库可以用的,那么用哪个呢?

如果就是不乐意用别人的东西,非想要自己写,也行。在Team里找一两个最NB的人专门负责这些低层框架上的东西。并给所有人做好Training。

最要不得的就是想到什么就写什么。就像代码4那样。它看上去代码质量也挺高,Style也挺不错,目测也没Bug,但是成本高,没法维护、不好拓展。

可重用性差

考虑一下,如果你需要把Math类里的所有函数都做这样的处理呢?我猜会不会有人想用Map<String, Map<String, Object>>什么的?而且只改一个文件就行了呢!

再考虑一下,如果这个函数的调用会抛出异常呢?你是想把异常也保存一下呢?还是想下次重试呢?

上面的Map使用的是最简单的FIFO策略,如果你需要LRU呢?

还有,你这里把round之后的结果当Key,如果有的需要用toString()的结果当Key呢?

你每种情况具体分析分别实现还是为之写个通用框架呢?

在别人的肩膀上搭积木

善用第三方库

这个世界上的肩膀很多,如果想搭好自己的积木就要找到合适的肩膀,最重要的一步是找到那个重复出现的问题是什么,找到那个模式(不限于设计模式,也包括规范、代码模式等)。

限定大小的Map就是个会重复出现的问题。round一个double值也是个会重复出现的问题。显然这些问题Apache和Google都遇到过,并把他们的库公开了出来给大家用。用上之后代码就可以简化很多。(注:Guava也有类似的Map)

import org.apache.commons.math3.util.Precision;
import org.apache.commons.collections4.map.LRUMap;

private final Map<Double, Double> rootMap = new LRUMap<>(1000);

public double sqrt(final double n) {
    return rootMap.computeIfAbsent(Precision.round(n, 2), Math::sqrt);
}

代码 5

这个例子很容易,但是实现中的情况会比这个复杂得多。复杂的不是要实现的功能本身,最难是你能不能找到模式,找到可以重用的现成的东西,然后最重要的:用对。不要拿着锤子看什么都像钉子。比如,如果你觉得Java 8的Stream不错。但是把Stream这样用就不对了。(注:代码来自Stackoverflow的某问题的回答)

// BEING: Wrong usage example
public Double round(final Number src, final int scale) {
    return Optional.ofNullable(src)
                   .map(Number::doubleValue)
                   .map(BigDecimal::new)
                   .map(dbl -> dbl.setScale(scale))
                   .map(BigDecimal::doubleValue)
                   .orElse(null);
}
// END: Wrong usage example

代码 6

这个代码就是在风格或某一特性上走极端了。

剥离非业务逻辑

代码5仅仅是用上了些第三方的库,代码相对简洁了些。但是比代码1还是复杂了3倍。而且你一眼看去,很容易就产生误解,把rootMap当成了核心。而其实Math::sqrt才是。

现在我们目标很明确,就是要让我们的代码只做我们需要做的事情,让库和框架去解决别的和业务本身没关系的问题。代码5做到了一部分,但是不彻底,而且还有侵入性(你要用一个框架就要对现有代码大动干戈的性质)。有没有办法让代码回到代码1的程度呢?

有人可能想到了。AspectJ可以做到。只要在项目里放一个这样的Aspect,然后就直接用代码1就是了。(注:此为示意代码)

@Aspect
@Component
public class StoreMethodResult {
    private final Map<Double, Double> map = new LRUMap<>(10000);

    @Around("execution(* *.Calculator.*(..)) && args(value,..)")
    private Object round(ProceedingJoinPoint pjp, double value) 
        throws Throwable {
        return map.computeIfAbsent(Precision.round(value, 2), v -> {
            return (Double) pjp.proceed(new Object[]{v});
        });
    }
}

public class Calculator {
    public double sqrt(final double n) {
        return Math.Sqrt(n);
    }
}

代码 7

我们解决了代码4的问题,成功把它简化回到了代码1的程度。但是这样搞会引入两个新问题:

  1. StoreMethodResult和Calculator是两个类。单看Calculator你是无法知道Calcuator在执行的时候它的结果会被保存下来。所以你给代码1加一个能力、功能、机制的时候,还是最好在Calculator本身上留下点儿线索。一个代码是注释。更好的办法是用Annotation。按这个思路做下去,就会需要一个Annotation Driven的Aspect。
  2. 代码7本身也存在类似代码4的问题:它也是在重新造轮子。这个轮子叫缓存(Cache)。

通用化方案

这个函数调用结果保存下来的需求实在是太广泛了,肯定已经有现成的通用解决方案了啊。Spring Cache就是其中的一个方法。使用Spring Cache和Spring Boot的实现方式就是这样:

@SpringBootApplication(scanBasePackages = {"your.package.name.*"})
@EnableCaching
public class Application {
    @Bean
    public CacheManager getCachemanager() {
        final SimpleCacheManager simpleCache = new SimpleCacheManager();
        simpleCache.setCaches(Lists.newArrayList(
      new ConcurrentMapCache("math")));

        return simpleCache;
    }

    @Cacheable(value = "math", 
        key = "T(org.apache.commons.math3.util.Precision).round(#n, 2)")
    public double sqrt(final double n) {
        return Math.sqrt(n);
    }
}

代码 8

代码看着好多,但是前面的都是初始化Cache的部分。对于sqrt函数而言,只需要放个@Cachable就行了。

用Spring Cache不是目的,少写代码也不是重点。请注意在这个实现方案下,函数与其附加能力是放在一起的,而与主要业务逻辑又是分离的。这才是重点。库和框架的使用,应该是为了让你更高效地写出更可读、更少Bug的代码。如果你用一个框架之后,发现代码比之前更不好读,你可能得想想你有没有用对,或是你选择的这个框架本身的设计理念是不是合理。但是代码好不好读这个事儿有些主观,有人反而会觉得用了一堆他不会正确使用的框架的代码才是不好读的。我就呵呵了。相关讨论请参考本系列第一篇《从开平方说说写代码应该考虑的事儿》。

JSR-107 JCache

在程序中Cache结果的能力是如此基本,人们早在2001年就开始了把它放在Java框架里的讨论,即JSR-107。但是不知道是不是因为用的人太多,争论过于激烈,这个JSR在2012年才发布了早期预览草案。直到2014年3月才发布了最终版。

Spring Cache是在2011年引入到Spring 3.1 M1的。Spring Cache本身不是缓存框架,它是各种缓存框架与Spring的胶水(虽然它也自带了个实现,但是功能性要差很多)。Spring Cache的实现和JSR中推荐的做法非常相近,再从Spring Cache发布和JSR的时间线看来,或许Spring Cache在推动JSR-107的进程上也发挥了不小的作用。

在2014年4月,JSR-107最终发布的一个月之后,Spring就在4.1版中提供了对JSR-107的支持

然而JCache毕竟和Spring Cache还不是完全一致的。所以使用JCache实现的版本看上去会有些不一样。(注:CacheManager的创建还是需要的,只是略去了。)

@CacheResult(
        cacheName = "math", 
        cachedExceptions = { IllegalArgumentException.class })
public double sqrt4(@CacheKey final double n) {
    return Math.sqrt(n);
}

public double roundedSqrt(final double n) {
    return sqrt3(Precision.round(n, 2));
}

代码 9

不难发现JCache的Annotation和Spring Cache的用法略有不同。于是产生了下面几个不同:

  1. 由于自定义key表达示的缺失不得不引入一个新的函数来实现Round的行为。
  2. cachedExceptions的出现又让我们相对比较容易地处理异常。
  3. 独立的@CacheKey的用法相对直观,但是灵活度不足。

各有各的优劣。没有哪一个是完美的。在实际使用中应该根据实际需要选择最合适的框架。需要注意的是,这种使用方式上的不同,对于框架的选择一般是次要性因素。其使用方式上的不同,往往是其设计理念与目标的不同的外在体现而已。而设计理念与目标,才是选择框架的主要指标与标准。

 综述

像本文这样举一个例子,说明正确使用库与框架带来的诸多好处,是件很容易的事儿。然而实际这样做起来绝不会像说起来这么简单。因为你不仅仅要完成需求,还要识别出其中的模式与通用部分,查找可用的库或框架,然后再学习这些库的正确使用方式并集成到你的项目中。从代码4到代码8,代码没多,但是其实是比自己写一个更难,所花的时间也更多。(尤其是你还并不知道也还不会用那些框架的时候)。然而这些多出来的步骤都不是障碍,最大的障碍是心魔。

当你千辛万苦调了几天Bug把代码跑通大功告成之后,是觉得这是结束?还是刚刚开始?愿意不愿意想一想有没有别的方法做同样的事儿?愿意不愿意去把各个方案比较一下?愿意不愿意承认自己一开始拍脑袋想到的办法可能不是最合适的?如果是项目工期十分紧张的情况下呢?如果你的同事、你的领导甚至整个公司都并不赏识你的做法,只求一个个的项目能按时上线呢?如果你现在已经处于天天加班,天天调试老代码又不敢大改的窘境了呢?

那些都不是真的困境,那些都是自己的心魔。

从一个小函数说说编程语言的学习

PHP是世界上最好的语言[1]。

——有人说

编程语言层面上的东西根本不是重点,把基本功打好,学一门新的语言就是信手拈来。

——又有人说

这俩梗好老。

——我说

引言

上一篇文章《从开平方说说写代码应该考虑的事儿》讨论了如何熟悉并利用领域知识和计算机知识编写更高质量代码。这一篇也是从一个非常简单的编程问题开始,通过分析针对这个问题的不同实现方式,阐释学习一门语言都要学些什么以及怎么样才算熟悉了一门语言。

注:本文无意嘲讽《21天精通XXXX》系列,毕竟或许还是有人可以做到的,尤其是在现在这种有人只要用过就敢妄称精通的大环境下。本文只是想说明一些显而易见却又被人熟视无睹的事实。

一个简单的问题

不少人可能毕业之后就从来没写过哪怕排序之类的基本算法,这很正常,但是没人用不到各类Containers(Collections)。而且,但凡是代码写得足够多又不勤于复制粘贴的懒人,就少不得写些工具类把一些常用操作独立出来。比如这样一个函数:

public static <T> List<T> except(List<T> list, List<Integer> indexes) {
}

我常常拿这个简单的函数当面试题,基本没有人猜不出来这个函数是要干什么,倒是会有个别人表示不知道前面的<T>是干什么用的。有性子急的可能很迅速地就构思出了如下实现,然后下一秒就发现坑在哪儿了。(简洁起见,本文所有代码略去参数为null之类的边界检查):

public static <T> List<T> except(List<T> list, List<Integer> indexes) {
    for (int index : indexes) {
        if (index < list.size())
            list.remove(index);
    }

    return list;
}

代码 1

显然上面的代码结果是不正确的。我们下面就来一步步地分析,看这个函数在Java里应该如何实现,都有哪些坑。主要意图是在这个过程体会,怎样才是充分地并合理地利用一个语言本身的理念与机制去解决问题。我也建议各位看官也先想一想自己心中的答案是什么再往下看。

先说需求到底是什么?

一说到“正确”,一说到“方案”,不少有经验的程序员的标准回答就是“看情况(It depends)”。看什么情况?看到底是要解决什么问题。我曾经也很喜欢这样回答问题,听上去多特么严谨。但是有时候这么说就不合适了,比如面试的时候这么回答就是犯傻,把到手的按自己能力方向展开并引导面试节奏的机会拱手还给了面试官。知道什么情况就说什么。没有具体上下文,要么自己假设一个;要么说自己的实现方式为了通用起见,所以牺牲了这些、这些、这些。即使不是面试,也不要说“看情况”,除非你真打算把情况一一列举出来并分情况讨论,你乐意说可能听的人也没耐心听,因为问问题的人一般只需要其中一个情况,你不嫌累,听者也会嫌烦。

回到问题上来。所以这个函数的需求是什么?需求就是函数签名所描述的那些。就像Array.Sort,它需要知道具体被使用的环境吗?它只要做到最好就是了。

函数签名能告诉你的,并不仅仅是行为,而且可以有实现细节。比如:

  1. 它是一个泛型函数:这大概是一个Util方法。
  2. 它返回一List<T>:所以它应该生成一个新的List<T>,意思应该是不要动参数List。
  3. 下标集合也是List:所以可能有重复,要处理。

等等。

正确性

代码1的问题是,它没有意识到删除了一个元素之后,后面的元素的下标就都变了,而且没有处理参数下标重复的问题。(还有别的问题后面再讨论)

于是一个简单的改进就可以同时解决上面的两个问题。

public static <T> List<T> except(List<T> list, List<Integer> indexes) {
    Collections.sort(indexes);
    for (int i = indexes.size() - 1; i >= 0; i++) {
        final int index = indexes.get(i);
        if (index < list.size())
            list.remove(index);
    }

    return list;
}

代码 2

这个没什么难的,所有人都可以做到这一步。遗憾的是不少人走到这一步就不再走了。因为它已经是正确的了(如果先不考虑对参数的改动)。我不知道是不是因为喝了Bert Lance那句“If it ain’t broke, don’t fix it.”毒鸡汤,并成功解释成了“如果它没坏,就不要改进它,不要重构它,不要动它!而且我也只能做到这里了!

正常情况下,如果并不需要复杂设计与编码就可以做到O(n)的函数,还是不要一开始就写成O(n^2)的好吧?代码写成这样,好意思在文件头上留下自己的名字么?(要不要加是另一回事儿。)

线性时间复杂度

在上面的代码基上,一个可以提高性能的改进是这样的:把要排除的元素标记成null。然后找出不是null的元素。

public static <T> List<T> except(List<T> list, List<Integer> indexes) {
    for (final int index : indexes) {
        list.set(index, null);
    }
    final List<T> result = new ArrayList<>();
    for (final T value  : list) {
        if (value != null)
            result.add(value);
    }

    return result;
}

代码 3

这个代码有两个问题:

  1. 性能还不是O(n)。
  2. 显然如果参数list里本来就有null就不行了。

问题1后面会展开讨论。对于问题2,理论上,可以用一个自己私有的对象实例代替null。

public static <T> List<T> except(List<T> list, List<Integer> indexes) {
    final T DELETE_MARKER = new T();    // Compile Error
    for (final int index : indexes) {
        list.set(index, DELETE_MARKER);
    }
    final List<T> result = new ArrayList<>(list.size());
    for (final T value  : list) {
        if (value != DELETE_MARKER)
            result.add(value);
    }

    return result;
}

代码 4

但是很可惜的是,由于Java的类型擦除机制,类似上面的方式在Java里是不可行的,因为T在运行时不存在。想知道T是什么?在Java里你得多传一个Class参数进来。而C#里上面的代码是可行的(当然需要额外泛型约束才能编译通过,但那不是重点,重点是C#可以知道T具体是什么)。

既然这种方式遇到了些障碍。我们就换一个思路:这个函数一共就俩参数,不for这个就只能for那个。(如果你解决问题真是这么个思路还是不要当程序员了。)

public static <T> List<T> except(List<T> list, List<Integer> indexes) {
    final List <T> result = new LinkedList<>();
    for (int i = 0; i < list.size(); i++) {
        if (!indexes.contains(i)) {
            result.add(list.get(i));
        }
    }

    return result;
}

代码 5

代码5的结果正确,但是性能又退化成O(log(n))或O(n^2)了。想想,为什么是有“或”?为什么有两种情况?

通用性(独立于参数实现)

请思考如下几个问题:

  1. list.contains的复杂度是多少?
  2. list.get的复杂度是多少?

List的某些操作的时间复杂度取决于其实现方式,甚至还会依赖于调用者的使用模式。对于JDK中常见的几个List实现,在本文的使用方式下,他们的理论上的时间复杂度会是这个样子的(有没有哪个跟算法书上说的不一样?请想想为什么):

ListOperations

图1

也就是说,代码5的时间复杂度会依赖于传入参数的类型。这显然不够理想。有没有办法让这个函数的运行效率无关参数类型呢?

问题1出在List的contains方法上,无论在哪种实现方式下,它都是O(n)复杂度的。好像无解啊?那有没有什么Container的contains方法是O(1)的?当然有啊:

public static <T> List<T> except(List<T> list, List<Integer> indexes) {
    final List <T> result = new LinkedList<>();
    final Set<Integer> indexesSet = new HashSet<>(indexes);
    for (int i = 0; i < list.size(); i++) {
        if (!indexesSet.contains(i)) {
            result.add(list.get(i));
        }
    }

    return result;
}

代码 6

问题2出在get操作上,LinkedList的get慢,但是ArrayList的get是O(1)的啊。所以心够宽的话,大不了可以先把整个list复制到一个新的ArrayList里不就结了?没错,但是那代码太美我不敢贴上来。那有没有别的方法呢?想一想现在的情形:我们无论如何是要遍历整个list,而且应该只需要遍历一遍。

这明明就是Iterator的地盘啊。所以用iterator就好了啊。

public static <T> List<T> except(List<T> list, List<Integer> indexes) {
    int index = -1;
    final Iterator<T> iterator = list.iterator();
    final Set<Integer> indexesSet = new HashSet<>(indexes);
    final List<T> result = new LinkedList<>();
    while (iterator.hasNext()) {
        final T item = iterator.next();
        index++;
        if (!indexesSet.contains(index))
            result.add(item);
    }

    return result;
}

代码 7

有经验的程序员其实一开始想的就是这个方法。这就是有经验的程序员和菜鸟的差距。到这一步,这个函数在功能上性能上都达到了预期的要求,而且做到了不依赖具体参数类型。面试的时候,如果有人能在15分钟内做到这一步,我就已经很高兴了。

但是,本文的重点要讲的,还没有开始。上面只是热身:熟悉问题本身。

正题才刚刚开始:那都是Java代码吗?

上面的确都是Java代码,因为它们都符合Java 8语言规范啊,能编译,能运行。但是不觉得有什么问题么?就这么简单的一个问题,怎么需要这么多代码呢?感觉什么地方不太对啊。

感觉不对是因为,这种代码,也不是Java,这是几乎所有OO编程语言及其框架的公共子集。它没有充分利用Java Collections Framework所提供的各类机制,上面的那些代码,放在C#里,放在Python里,放在C++里,仅仅需要修改一下语法和类名就可以同样地正确工作起来。如果学一种语言只是学到这门语言的这样的公共语言部分,其实就可以解决所有编程问题,不再学习任何新的语言点也能写出所有需要写的代码来,只是写起来就像上面一样——繁复。学一门语言,如果只是学到这种“公共子集”程度,的确是可以“一通百通”,信手拈来,几个小时学完。但是这离学会那门语言,还差得很远。

本文要实现的这个函数,大概算是最普通的编程问题了,它所涉及的,是几乎每个Java程序员天天都要使用到的东西。也就是JCF的那些接口,那些类。我说上面的代码没有充分利用Java,所以我们看看JCF的三个主要接口里都有些什么?请仔细看一遍下面两张图,有没有哪个函数名让你眼前一亮或是一头雾水的感觉呢?各位看官看这些方法的时候请思考下面几个问题:

  1. 了解这些API的用法,是否需要依赖工作上的项目提供特别的机会?
  2. 你觉得需要几年的工作经验才能让一个人知道并能在合适的时候正确地使用这些函数?
  3. 知道这些函数的使用,算熟悉Java?精通Java?还是没有直接关系?

JCFInterfaces

图 2

图中,绿色高亮的是Java 8新引入的接口。黄色高亮部分是辅助性函数,就是那种你完全不知道、不使用,也能无障碍地写出Java代码的函数。没高亮的函数,是必须要有的,也必然会用到的,也是那种每个人都不得不知道的,所谓的语言公共子集部分。当然,如果有人说他连remove方法都用不着,或是说从来只用iterator,我也并不觉得奇怪。

充分利用Java

我们看到List接口里有个比较特别的ListIterator或许不是所有人都知道。我们也来看看这个ListIterator和Iterator比有什么特别的地方。注意:ListIterator继承Iterator。

ListIterator

图3

所以稍微对Java多熟悉一些,就可以知道,从ListIterator是可以在遍历一个List的过程中知道当前index而不需要自己维护一个index变量的。于是我们的代码可以简化成:

public static <T> List<T> except(List<T> list, List<Integer> indexes) {
    final ListIterator<T> iterator = list.listIterator();
    final Set<Integer> indexesSet = new HashSet<>(indexes);
    final List<T> result = new LinkedList<>();
    while(iterator.hasNext()) {
        if (!indexesSet.contains(iterator.nextIndex())) {
            result.add(iterator.next());
        }
    }

    return result;
}

代码 8

如果用Java 8,那也可以通过新的removeIf来实现(想想这个函数线程安全吗?):

private static int index = -1;
public static <T> List<T> except(List<T> list, List<Integer> indexes) {
    index = 0;
    final Set<Integer> indexesSet = new HashSet<>(indexes);
    final List<T> result = new LinkedList<>(list);
    result.removeIf(t -> !indexesSet.contains(index++));

    return result;
}

代码 9

如果喜欢用Stream呢,还可以写成这样:

public static <T> List<T> except(List<T> list, List<Integer> indexes) {
    final Set<Integer> indexesSet = new HashSet<>(indexes);

    return range(0, list.size())
            .filter(i -> !indexesSet.contains(i))
            .mapToObj(list::get)
            .collect(toList());
}

代码 10

然而不幸的是,这个代码又出性能问题了。因为它依赖了输入参数list的get方法。在前面的代码中,我们说过,总是可以把list先Copy到一个新的ArrayList中。但是如果传入参数本来就是ArrayList就是多余的了。所以要么就先检查一下参数类型是instanceof ArrayList不就结了?但是如果参数是个自定义的List呢?要不要先给参数做个Quick Perf测试来侦测其get方法是不是O(1)的再来决定要不要先Copy到ArrayList中呢?

在一个成熟的框架里写代码的一个好处是:你可以先假设你遇到的一切General的问题都已经有人遇到过了,而且很可能已经在框架层解决了。你只是得:

  1. 先意识到这个是General的问题。
  2. 形式化规范化这个问题。
  3. 然后用正确的姿势Google一下看看有没有人已经解决了。

比如代码10中的特定方法的性能问题,JDK已经意识到这个问题并且帮你解决了,有个专门的无方法接口叫RandomAccess用来标识一个List的get方法的时间复杂度是不是O(1)或比随机多次get快(详参文档)。那么代码中就可以用这个接口做为判断标准:

public static <T> List<T> except(List<T> list, List<Integer> indexes) {
    final Set<Integer> indexesSet = new HashSet<>(indexes);
    final IntStream indexesToSelect = range(0, list.size())
            .filter(i -> !indexesSet.contains(i));

    if (list instanceof RandomAccess) {
        return indexesToSelect.mapToObj(list::get)
                              .collect(toList());
    } else {
        final List<T> accessList = new ArrayList<>(list);
        return indexesToSelect.mapToObj(accessList::get)
                              .collect(toList());
    }
}

代码 11

当然,这个代码正确工作的前提是,那个自己实现了一个List接口的人,也知道正确地使用RandomAccess接口。

好了,代码8到代码11展示了如何充分使用Java框架来解决这个问题。但是不幸的是,有些代码变得更恶心了(脸好疼)。每种方式都有自己的优劣,应当在使用的时候根据实际需要选择合适的方案。

LinkedList vs ArrayList

以上我们分析过参数的具体类型会对函数性能产生的影响。但是还没有具体讨论过以上代码3至代码9中所创建的临时List对象的选择问题。有心的人可能已经发现,有的用的是ArrayList有的用的是LinkedList。那么用得对吗?更进一步,什么叫用对?

Java自带了这两个主要的List的实现。从图1中可以看出他们在性能上的差别。理论上,我们可以根据自己的使用方式,根据是get操作多还是add/remove操作多来决定是用LinkedList还是用ArrayList。这是我们以上分析的主要依据,如果你Google网上的类似问题,这也是大家的建议。

但是问题是,这个依据对吗?add/remove多的时候,用LinkedList就一定好吗?还有什么其它会影响性能的因素呢?

CPU缓存命中率

CPU缓存命中率主要取决于:所需要访问的数据的大小,在内存中的分布及最重要的访问模式。那么具体到这个函数的CPU缓存命中率,主要受List本身(不包括各个List项T本身)在内存中的分布的影响。请试着想象一下他们两者在内存中的分布情况。大体会如下两图所示。

ArrayList内存分配示意图:

ArrayListMemory

图 4

LinkedList内存分配示意图:

LinkedListMemory

图 5

所以最糟糕的情况会是什么样的呢?你每次访问的LinkedList中的一个Node都Miss CPU的所有一、二、三级缓存。当然实际一般不会这么糟糕,但是绝对比ArrayList要糟糕。缓存不命中的后果就是从内存中读取数据。从内存读又怎么了呢?我们得先看看CPU缓存的访问速度与内存访问速度的差距才能回答这个问题。

从下图可以看出各级缓存结构的访问延迟(Core i7):

AccessLatency

图 6

不难看出,根据问题规模的不同,内存访问会比CPU缓存慢3至50倍。那么这会对LinkedList的性能产生多大的影响呢?看访问延迟,估计有最大50倍性能差吧。不过性能上的事儿,必须要测试一下才知道。

在当前所实现的except函数的环境中,使用List的方式是创建一个默认大小的List,然后只增加不删除。我们就来模拟这个使用方式的下,不同List实现随着问题规模增长时性能的变化。结果如下图所示:

AppendBenchmark

图 7

从图中不难看出,在问题规模比较小(小于一两百万数据)的时候,LinkedList有些许优势,而当问题规模大于五百万数据之后,由于数据局部性不足及CPU缓存容量的限制,LinkedList的性能下降得非常快。(据图可以估计出测试平台的CPU L3缓存大小,不是Core i7)

所以简单起见,你可以在所有的情况下都使用ArrayList,而不用太担心其插入、删除的理论性能比较低的事儿,其数据的局部性可以把大O的系数拉到很低。如果你的所预见的使用情况都只有不到几万数据量的话,用LinkedList也是不错的。当然其实这么少的数据,随便你用什么都可以。

上面这个结论对所有语言都是成立的。如果想深入了解,可以参考《Number crunching: Why you should never, ever, EVER use linked-list in your code again》。Bjarne Stroustrup也在其讲座中表达过同样的观点

有人可能会问,那根据输入的大小选择合适的List实现不就结了吗?也行,就是代码会变得貌似一坨屎,而且还得根据CPU型号选取临界值。如果不想自己的母亲时不时被后来看代码的人问候,还得写注释解释清为什么这么做。另外,调用的人也会很不爽,他会面临这么个问题:这个函数有时候返回LinkedList有时候返回ArrayList。我想多数人会希望API的行为是下面几者之一:
  1. 一直返回一种实现。
  2. 参数是什么类型,也返回什么类型。
  3. 不依赖任何List实现,让调用者根据结果自行构建List。

这样这个API的行为才是可控的。

Java有两个List实现!太漂亮了!

这里想说些题外话,我们如果退一步想想,上面代码复杂度问题的根源,其实是Java本身造成的。它自带了两个List的实现,于是有心的Java程序员们在写代码的时候,无时无刻不在想着:我这回应该用哪个实现呢?但是写C#、写Python什么的,你完全不用考虑这个问题。因为它们都只自带了一种List。因为LinkedList实在是没什么存在的必要。大概唯一的好处就是强迫一部分过于懒惰Java程序员们动动脑子。在某人不小心用了LinkedList,搞出个大大的性能问题,经过简单的分析就可以知道他写的那个List处理函数是多么的糟糕:一个不小心就把一个O(n)的函数用出了O(n^2)的效果。自此LinkedList臭名昭著:性能杀手,效果拔群。于是JDK又引入了RandomAccess接口给这俩List擦屁股。说你们应该在写函数时候多多考虑一下非RandomAccess的List的感受嘛,你看我连接口都设计给你了,你不用?怪我喽?

如果对此有话题有兴趣,可以参看这个知乎问题下面的前五个回答。为什么那么多公司不用 .NET,而选择 PHP、JSP,是 .NET 有什么缺点吗?(这个问题虽然都没问Java,下面一群回Java的,呵呵)有个回答说的好:“.NET的问题就在于不能有效提高使用者的智商。”。Java不仅能,而且擅长。

想我写了快十年C#代码,其间几乎没有细想过List的实现是用数组更好还是用链表更好这个问题(毕竟也很少直接用List)。然而写Java的第一天我就意识到了。这个问题在Stackoverflow上很火。其中有一个排名不高的的回答有些碍眼。他说,LinkedList的作者Joshua Bloch(此人也是Effective JavaJava Concurrency in Practice两本书的作者),发过这样一条Twitter

Does anyone actually use LinkedList? I wrote it, and I never use it.

看这语气,说像是在说:“我写了个用于IQ测试的Java类,你们通过了吗?”

通过以上二节的分析,主要并不是想解释如何在LinkedList和ArrayList之间做取舍,而是想说明一个事实:这些分析里有计算机本身的约束,也有不少是Java语言本身的一些细节,而不同的语言里,针对同一个问题会有不同的设计,不同的细节。如果不了解这些细节,并不能用合理的方式使用这门语言。而这些才是学习语言的难点。(PHP充斥着各种细节,所以成了最好的语言。)

掌握一门语言的法门,只能靠深学活用。能通过一个下午熟悉的,只有语法而已。而语法本身,常常并不是一门语言最重要的部分。甚至连重要都算不上。

接口隔离原则

前面讨论了Java里两个List的现实之间的取舍问题。一个终级解决办法就是不做取舍,让调用方自己去从结果集构建List,如果他需要一个List的话。意思就是说,我们不要求参数是List,也不返回一个List。而只使用完成这个函数所必须的操作(同时不损失性能)。这就是最小接口依赖原则的含义。哪么哪些接口是我们为了实现这个函数所必须要使用的呢?其实就一个:遍历。于是我们的except函数可以被简化成这样。

public Iterable<T> except(Collection<T> list, Set<Integer> indexes) {
}

indexes的参数变成了Set,也更符合这个操作本身的要求。直接返回一个Iterable,可以让使用者按自己的要求和具体环境选择下一步所需要的具体数据类型。我们来考虑如何实现这个函数,虽然从语法上,直接返回一个List是合法的,但是显然已经不符合我们的目标。所以,在这个版本的实现中,真的需要返回一个纯粹的Iterable才能达到效果。(思考题:如果有个子类重写了这个函数并返回一个具体的List,是否算违反了里氏代换原则?)

public static <T> Iterable<T> except(Collection<T> list, Set<Integer> indexes) {
	final Iterator<T> iterator = list.iterator();
	final Set<Integer> validIndexes = indexes.stream().filter(i -> i < list.size() && i >= 0).collect(toSet());

	return () -> new Iterator<T>() {
		private int cursor = 0;

		@Override
		public boolean hasNext() {
			return cursor < list.size() - validIndexes.size();
		}

		@Override
		public T next() {
			while (validIndexes.remove(cursor))
				move();

			return move();
		}

		private T move() {
			cursor++;
			return iterator.next();
		}
	};
}

代码12

这还是用Java 8写的,都写了这么长,如果用老Java,代码将更长。解决长代码的方式也很简单,就是看看这个代码是不是承担了太多的责任。但是这么简单的操作,你说它承担了太多的责任,这不是搞笑么?但是它的确是的。它做的事情,至少可以分成这两步:

  1. 求indexes的补集。
  2. 按indexes选取元素。

于是这个操作可以被分解成这样:

public static <T> Iterable<T> except(Collection<T> list, Set<Integer> indexes) {
    return select(list.iterator(), 
              range(0, list.size()).filter(i ->
                      !indexes.contains(i)).iterator());
}

public static <T> Iterable<T> select(Iterator<T> list, Iterator<Integer> orderedIndexes) {
    return () -> new Iterator<T>() {
        private int cursor = 0;

        @Override
        public boolean hasNext() {
            assert list.hasNext();
            return orderedIndexes.hasNext();
        }

        @Override
        public T next() {
            while (cursor < orderedIndexes.next()) {
                cursor++;
                list.next();
            }

            return list.next();
        }
    };
}

代码 13

情况看上去更糟糕了-_-,但是这个新的select函数在通用性上显然比except要高得多,它也可以被except之外的其它的集合操作函数所使用。其它的集合操作函数,只是需要先通过纯粹的数学运算计算出要提取的元素的位置,然后最后调用select函数。

但是,即使这样,这个3行的except,跟它所需要解决的问题相比,也还是没必要地复杂了。

Iterable vs List

使用Iterable相比使用List,除了缩小了接口依赖之外,其实还有几个的不小的好处。

  1. 延迟求值。意思是这个函数本身运行时间变成了O(1)。
  2. 内存占用从O(n)减小到了O(1)。因为只返回了一个Iterable,而没有返回任何数据。

但是这些好处不是没有代价的。使用Iterable也会带来几个不小的问题。

  1. 隐式依赖参数列表。
  2. 不可重复使用。
  3. 非线程安全。
  4. 由于3,所以不可并行化。

第一点很明显,就当成思考题吧。第二点给个使用环境当引子。

private static <T> void printTwice(Iterable<T> items) {
    items.forEach(s -> System.out.println(s));
    items.forEach(s -> System.out.println(s));
}

代码12和代码13与之前的代码相比,在上面的使用方式下会具有不同的结果。而这个结果是编码者不想看到的。(C#里近似的接口IEnumerable,由于合理的接口设计,没有这个问题。)

可并行性

对于性能要求比较高的环境,可代码本身的并行性也会一个重要的代码可用性及质量的考量。如果用Java 8的流实现,并且实现成纯函数,好处之一就是可以比较简单地支持多线程并行计算。基于代码10,可以通过一个函数完成并行化。如代码14所示:

public static <T> Collection<T> except(List<T> list, Set<Integer> indexes) {
    return range(0, list.size())
                .parallel()
                .filter(i -> !indexes.contains(i))
                .mapToObj(list::get)
                .collect(toList());
}

代码 14

然而如果想把使用Iterator的代码并行化就没有那么轻松写意了。如果你有兴趣的话,可以写一个可并行化的Iterator试试。

Java vs .NET

写了12个版本的Java代码了,断断续续地花了好几天。代码越来越长,却始终找不到一个简洁、高效、灵活、可扩展的实现方式。想想就觉得火大。C#程序员可能早就憋不住了,这个问题的C#版实现方式不要太简洁。

public static IEnumerable<T> except<T>(IEnumerable<T> items, ISet<int> indexes)
{
    return items.Where((t, i) => !indexes.Contains(i));
}

看着这代码,顿时觉得写代码还是那么的美好,而且它具有一切好处:线性时间、延迟求值、参数无关、最小依赖等。写法也简单到我都不屑于提取出来一个函数。想想真心疼Java程序员们。不过或许这就是Java提高使用者智商的方式呢!再说了,我们都知道语言层面的东西都是不重要的。对吧?(这么想心里会舒服些,毕竟我也在写Java。)

如果你在写C#,可能不自觉地就免费地获得了不少的好处,这是一种幸福。但是这并不表示应该在写代码的时候可以无视上述的那些潜在问题。否则就成了Java程序员所鄙视的那种只会拖拖控件的码农界食物链末端生物。

.NET自动地为程序员们做了很多事情,而且做得很出色。但是做得太好,好到程序员代码写烂了它都能工作得还不错,结果不小心把不少.NET程序员都给惯傻了,傻到出点儿问题都不知道怎么下手处理,然后还反过来骂.NET是垃圾。而Java程序员从一上手写代码开始,就要开始解决这样那样的问题,水平慢慢地就被迫堆高了。其后果就是.NET始终被Java压制,这大概是微软始料未及的吧。

那些事儿

在引言里说过,这篇文章只是想说说那些显而易见的事儿。然而一千个人眼中有一千个哈姆雷特。所以我担心不说得清楚些,还是会有人不知道我在说在什么。本文的目的不是解释这个except函数在Java里怎么写也不是为了展示Java里多样的写法。本文要说的那些事是:

  1. 编程没有简单问题,再简单的问题都有多种解法适用于不同的环境。
  2. 学习一门语言从来不是一个简单的事儿。但是学习到这个语言的公共部分是个很简单的事儿(就像大牛们宣扬的,你基本功扎实的话,一两天的事儿)。
  3. 学习一门语言到公共部分就可以解决你工作上的所有问题,而且在这个层次,的确可以说用什么语言写代码都是无所谓的。(因为你总是可以自己搞定剩下的语言特定部分——大凡都通过重新造轮子的方式。)
  4. 深入学习一个语言,从来不是一个简单的事儿。(那怕简单如Java都是这样,我也没有深入学习过Java,我只是简单了解了一下JCF,然后看了点Best Practice而已。)
  5. 在你把一个语言学到一定深度之前,你可能会看到一些代码看不太懂。这个时候正确的积极的心态是承认自己的水平不够,而不是抱怨这个代码的可读性太差、搞得别人都看不懂。
  6. 最好等真精通了一门或几门语言之后再谈论什么一通百通,否则对于个人成长而言遗患无穷。(以Java为类,感觉对java.lang,java.util,java.io包里的所有最新的类的所有方法都了解并能合理使用才能算熟悉级别的吧。)
  7. 选择一门表达力强并且适用你个人理解力极限的编程语言,可以提高效率、节省时间、延长生命、早下班、赚更多的钱。(没最后两点大概没有人关心吧。)
  8. 公司不幸用了Java你也得适应。

参考书目

Cay S. Horstmann, Gary Cornell. Core Java 2, Volume II – Advanced Features (《Java2核心技术 卷II:高级特性》)

Richard Warburton. Java 8 Lambdas: Functional Programming for the Masses (《Java 8 函数式编程》)

Joshua Bloch. Effective Java Second Edition (《Effective Java中文版》)

Randal E. Bryant, David R. O’Hallaron. Computer Systems – A Programmer’s Perspective Third Edition (《深入理解计算机系统》)

Thomas H. Cormen, Charles E.Leiserson, Ronald L. Rivest, Clifford Stein. Introduction to Algorithms Second Edition(《算法导论》)

Donald E. Knuth. The Art of Computer Programming Vol 1: Fundamental Algorithms Third Edition (《计算机程序设计艺术 卷1:基本算法》)

从开平方说说写代码应该考虑的事儿

引言

很少有程序员需要自己写开平方函数,因为基本所有的语言都自带开平方的函数。更不用说1999年发布的SSE指令集,从CPU指令的级别上支持了开平方运算。当然这依然阻挡不住程序员们探索各种开平方方法的热情。其中最著名的当属John Carmack在开发Quake III时写的那段用精度换速度的Magic Code。如果你更关心如何提高开方函数的速度,你可以参考这篇比较各种方法速度文章,其中有些比单纯地使用CPU指令sqrtss开平方更快。

本文的关注点并不是速度,你甚至可以在本文中看到或许是业界最慢的一些方法。本文专注从软件工程领域的各各视角考量几种风格迥异的开平方函数,通过它们展示如何让代码的可重用度更高、使用上的灵活性更强、复杂度更低,以便于理解及维护。你总是可以在需要的时候,比较容易地提高一个具有高可维护性、设计良好的系统的性能。而一个系统一但由于代码质量上的问题,进入了一种难于理解、难于维护、难以变动、不可重用、无法预测运行结果的状态,如果再想提高其性能什么的,基本上就只能重写了。

导读

本文代码使用的语言是F#。但是你不必担心自己是否学过这个语言,当英语起来应该不会有什么问题。 本文中的示例与求解思路主要来自《Structure and Interpretation of Computer Programs 2nd Edition》,书中使用的是Scheme语言;类似地,那本书是某大学的计算机专门的入门教材,甚至不要求读者有编程经验。但是会需要微积分的基本知识,否则你应该只能搞懂第一个函数。

如果你很熟悉F#,那太好了,本文中的代码都是我一边看F#编程参考一边写的。如果有什么更好的实现方式,也希望你能指出来。

如果你根本不是计算机专业的,本文也值得一读,它或许能解释为什么你的那些计算机专业的同学们既不会修电脑修手机,也无法帮你找回被你遗忘的某网站的密码。因为下面这些才是他们所能解决的问题。

问题引出

求平方根运算的方法有很多,我们初中的时候就学习过手算开平方,还发过一个小册子,上面有各种数据表,用来直接查询一些数的平方根等。但是那些方面都不能方便才用于计算机:手算开平方规则较多,数据结构也复杂,代码量会很多;查表法需要过多的数据存储空间,也不切实际。

非计算机专业的人,很容易错估计算机领域问题的复杂度,一方面是因为人们总是喜欢这样做:先把问题在自己熟悉的领域重新定义,然后用自己在这个领域所熟悉的知识解决这个新问题,并声称原来的那个问题应该也具有同等的复杂度,并可以类似地解决掉。逻辑不好的人会很吃这套。

事实上,很多计算机专业人士在这方面也不惶多让。通常而言,在计算机领域,针对一个问题,找到一个可行的方案通常都是很简单的,并不需要什么高深的知识和精巧的构思。比如,如果你并没有高等数学的知识,仅仅用初、高中数学知识,你或许可以自己想出折半查找法求x的根,在[0,x)区间内以二分法搜索解,并得出像下面这样的代码:

open LanguagePrimitives

let inline abs x = if x > GenericZero then x else -x
let sqrt_v0 x = 
  let rec search f a b = 
    let close_enough target tolerance value =
      (abs (target - value)) < tolerance
    let avg = (a + b) / 2.0
    if a - b |> abs |> close_enough 0.0 0.001 then
      avg
    else
      match f avg with
      | v when v > 0.0 -> search f a avg
      | v when v < 0.0 -> search f avg b
      | v -> avg
  let half_interval_method f a b =
    let valueA = f a
    let valueB = f b
    match (valueA, valueB) with
    | (x, y) when x < 0.0 && y > 0.0 -> search f a b
    | (x, y) when x > 0.0 && y < 0.0 -> search f b a
    | (x, y) ->
      invalidArg "a" "f a must be of different sign with f b."
  half_interval_method (fun v -> v * v - x) 0.0 x

代码1

这个代码可行,结果也正确,只是有些小问题,比如:代码量多、速度慢、精度低。通常人们不把代码量当问题,因为它不能当作任何标准的度量;如果同时又对性能也没有什么过高要求的话,那么这段代码堪称完美。只是如果哪天需要改进其精度,会发现性能会随精度要求的提高而急剧下降。如果又想同时改进其性能,基本就只能整个重写一遍。这种半调子代码,几乎是所有软件项目的潘多拉魔盒,一但被打开,就会变得无法收拾。

问题的根源

写出高质量的代码,是每个软件工程师的职责,一个软件工程师的水平也可以通过其代码质量而得到体现。所以其实没有人会刻意地去写出像上面那样的半调子代码。出现这样的代码的原因,更多是非主观因素。比如相关知识储备不足、比如项目本身风格约束、比如工程进度的压迫等等。他们专注于问题本身,并尽自己所能写出了自己最满意的代码,却可能意识不到它其实是有多糟糕。这也是软件工程和土木工程的最显著不同:土木危楼,一目了然;软件危机,至死方现。

除了对计算机本身和软件工程本身的研究,多数软件工程项目都不仅仅需要软件方面的知识,更需要把软件工程知识与相关领域知识结合起来才能发挥其价值,这也是软件开发工作相比其它工作最特殊的地方之一。所以人们常说搞软件的要一直学习,这是没错的,因为在软件业,任何已经解决过的问题都不是问题,一方面是要学习如何解决这些问题,另一方面,如果想创造出些新的价值出来的话,势必要做一些别人没有想过、没有做过的事情。这些是题外话。

问题分析

求平方根函数,就是这种跨领域问题,解决起来总有两方面的基本工作要做:

  1. 熟悉领域知识。
  2. 找到这个问题在计算机领域的合适的解决方式。

开平方是一个数学问题,在着手设计并编写代码之前,从数学分析的角度深入而全面地了解一下这个问题是必不可少的。在这方面的工作都没有做充分的情况下,就会产生出类似上面的代码。

问题的解决大体如是:对问题本身有一个大局观,才能从中找到更合适的解决方案,同时也可以避免陷入自己的先入为主的知识体系的牛角尖而无法自拔。在这个阶段,有这样几个核心问题要回答:

  1. 问题所在领域的知识有哪些?
  2. 哪些知识可以更好地解决这个问题?
  3. 问题的环境(上下文)是怎么样的(问题没有普适的解法,所以需要针对具体环境)?

这些问题或许有些抽象而空洞,我们下面就开始一步步地求解开平方问题。最后这个函数写成什么样都不重要,重要的是我们如何走到那一步的,以及为什么要走那一步。

问题的求解

基本需求

回到问题上来。我们的目标是希望有一个函数,可以用来计算一个数的平方根,并需要满足以下的要求:

  • 时间复杂度在特定精度下为常量并稳定。
  • 可以灵活地调整所需要的精度。
  • 其他程序员能看得懂。

折半查找法并不符合前两个基本要求。所以它不是一个好的方法。

古巴比伦算法

我们只要简单地搜索一下就可以知道常见的开平方解法有哪些。而且不难发现有些方法在计算机系统上实现起来比折半法还要简单。比如公元前2000年,美索不达米亚人所使用的古巴比伦算法。而且不需要什么高深的数学知识,只是我们初高中没有学习过,不广为人知而已。

其计算过程很简单,如果我们猜g是x的一个根,那么\frac{g+\frac{x}{g}}{2}会是一个更好的根。用代码实现起来也很简单:

let square x = x * x
let sqrt_v1 x = 
  let rec sqrt_it guess =
    if (abs (square guess - x) / x) < 0.001 then
      guess
    else
      sqrt_it ((guess + x / guess) / 2.0)
  sqrt_it 1.0

代码2

代码2中我们把1.0当作所有数的平方根的初始猜测,然后迭代上面的算法直到误差小于0.001。这个函数基本满足前两个需求。只是没有背景知识的话,有些不太好看懂。对于一些对代码质量没有要求的环境,这个函数也已经完美了。

但是我们希望代码好读一些。而有心的人也会问,巴比伦人的算法的原理是什么。这些问题都还没有解决。

函数抽象

一个非常简单的提高代码可读性的手段是给你读不懂的部分起一个名字。

open LanguagePrimitives

let average x y = (x + y) / (GenericOne + GenericOne)
let close_enough target tolerance root = 
    (abs (target - root) / target) < tolerance 
let avg_damping f = fun x -> average x (f x) 
let sqrt_v2 x = 
  let getNext = avg_damping (fun v -> x / v)
  let rec sqrt_it guess =
    if square guess |> close_enough x 0.001 then
      guess
    else
      sqrt_it (getNext guess)
  sqrt_it 1.0

代码3

与前一个版本相比,这个函数其像仅仅是提取出了几个独立的小函数,给他们起了更明白的名字而已。有没有独立的函数,有没有名字都不是重点。每个语句都可以被提取成一个单独的函数,但是那样做一点意义都没有。重点是:哪些需要被提取出来,哪些不需要。引导你做出那些决定的策略才是重点。很多人不屑于做这种小函数的提取,尤其是在这些函数只有自己用的时候。但是有些有价值的东西,只有你这样做了之后才会发现。

比如上面的代码,经过整理之后,你就会发整个代码段中,与开平方运行有关的只有加粗的那个函数。这是一个非常强的信号,这个sqrt_v2函数的求解过程应该有着某种通用性。这个感觉没有错,那通用的部分就是求解方程的不动点。合理的函数提取就是过程抽象的手段,而提取之后又可以发现新的模式可以进一步抽象。过程抽象本身可以推进过程抽象。

于是那个通用的部分就又可以被提取出来了。于是开平方的代码本身就只剩一行了。它的含义就是:函数y=\frac{y+\frac{x}{y}}{2}的不动点,就是x的根。

let rec find_fix_point f guess = 
  let next = f guess
  if close_enough next guess 0.001 then
    next
  else
    find_fix_point f next

let sqrt_v3 x = 
  find_fix_point (fun v -> average v  (x / v)) 1.0

代码4

比较代码2、3、4,这三个版本的代码都源自古巴比伦算法。只是代码4具有更高的:

  • 可重用性:find_fix_point, avg_damping, close_enough都是通用方法。
  • 稳定性:基本可以在3次迭代内得到的一个很近似的解。
  • 可读性:代码量少,含义明确。

剩下的两个问题

根据我们之前提出的需求,代码4还存在两个主要的问题。

  • 可维护性比代码1低:因为依赖更多的领域知识,如果不知道古巴比伦算法,基本看不懂这个代码是怎么写出来的。
  • 其精度不可灵活控制:其内部使用了0.001来判断一个解是不是够好。但是显然对于计算\sqrt{0.0001}就不能正常工作了。

这两个问题是在我们选择古巴比伦法之前没有遇见到的。在软件开发的过程时常会出现类似这种状况:有些问题只有做出来甚至上线之后才会出现

这两个问题不是数学问题,而是纯粹编程实现上的问题。这也正是我们在分析领域问题时所需要注意的,如何把领域问题与计算机系统结合起来,并找到可能存在的问题及适合的解决方案的地方。

类似的,常见的计算机系统在数学领域的约束还有:

  1. 数值表示的精度。
  2. 数值表示的范围。
  3. 数值运算的截断误差。
  4. 不同数值运算间的速度差异。
  5. 数值求值策略对数学公式的敏感性。(等价的数学公示可能会有不同的运算结果。有些是数学本身的问题,有些有计算机系统约束的问题。)

我们下面继续分别解决这两个问题。但是不要忘了基于上面的话,它告诉我们:我们为解决这个两问题而引入的技术,很可能会带来新的问题。这是不可控的。只有做了才能发现。这是软件工程复杂性的另一个体现。《Principles of Computer System Design》一书对此进行了更详尽透彻的讨论。在此就不展开了。

牛顿法

当把一个函数化简到代码4的样子,我们不难又可以发现:如果我们不是要开平方,是要开立方。唯一需要改的地方很可能就是那个不动点方程。但是古巴比伦人没有给出开立方的迭代求解方法。即使有立方的,那任意次方的呢?更进一步地,有没有一个不动点方程可以求解任意一元多项式方程呢?牛顿说有:

假设a是对方程 f(x)=0的一个估算解,且f(x)可导,令:

b=a-\frac{f(a)}{f

那么b是个比a更好的近似解,其中f'(a)是 f(x) 的导函数 f'(x)在a点的值。

很少有编程语言能在语言层面支持求导运算。但是任何语言都可以根据导数的如下定义:

Dg(x)=\frac{g(x + dx) - g(x)}{dx}

实现出一个求取函数f导数值的函数:

let deriv f dx = fun x -> (f (x + dx) - f x) / dx

并得到一个使用牛顿法的开平方函数:

let newton_method f = 
  let Df = deriv f 0.001
  find_fix_point (fun x -> x - f x / Df x) 1.0

let sqrt_v4 x =
  newton_method (fun y -> y * y - x)

代码5

我们看到这个版本书使用的函数y=y * y - x就是解为x的方程本身。那么即使你不明白牛顿法的工作方式是什么,你大概也可以猜到求立方根的方法可以写成这样:

let cube_root x =
  newton_method (fun y -> y * y * y - x)

代码6

看上去,基本所有的可导函数的解都可以用牛顿法解。但是事情其实不会这么简单。但是对牛顿法的深入讨论不在本文的讨论范围内。如果有兴趣可以自行参看微积分相关书籍。

另外需要注意的是,在代码5中的Df函数中,又引入了一个新的精度假设值0.001。我们提高了代码的可读性和可扩展性,但是进一步损害了其精度(4次迭代之后在小数点第9位会体现出来)。之后会介绍可以解决这个问题的一个方案。

无限延迟求值流

对于精度问题,有一个看似十分简单且可行但是实际没有太大价值的解决方案:把精度当参数之一。它只是把同样的问题抛给了调用者而已。而对调用者而言,我相信他们更希望拿到一个解之后才能真正确定这个解够不够好,他们也无法预知对所有定义域都合理的精度是什么。当然,他们可以简单地选择一个绝对足够好的精度,比如7位。但是这样做的后果就在是一些并不需要这么好的精度的情况下产生了额外的无用运算。

考虑到开平方运算是迭代进行的,一个思路就是把迭代过程中的每个值都返回给调用者,并在调用者需要下一个更好的解的时候才进行下一步的计算,直到调用者满意为至。

当一个思路产生之后,无论你脑子里是否能立马闪现出具体实现方案,一个好的习惯就是在先计算机领域看看,有没有合适的技术与理念可以用来实现这个思路的,并和你自己的方案进行比较、甄选。然后再着手实现。思路的寻找往往是最难也是最有价值的部分。如果你总是想到什么就做什么,可能所有问题倒是也都可以解决,但是在这个过程中很难会有思路上、水平上的提升。把一年的工作经验用十年,就是这个意思。

了解到计算机系统中可以用来实现上面所述思路的概念至少有:

  1. 流(Stream – a view of system structure):具有的延时求值特性。
  2. 按名调用(Call by name – an evaluation strategy)
  3. 协程(Coroutine – a language feature)

Stream会更适合实现我们所需要的功能。它不依赖于语言,而且应用广泛。LINQ, Reactive Extensions都是基于Stream View设计的。

参考《SICP》上的流实现,不难构建出如下的Stream类和用之实现的Stream化的Sqrt函数:

type Stream<'a>(empty:bool, head:'a, tail:unit -> Stream<'a>) = 
  new(head:'a, tail:unit -> Stream<'a>) = Stream(false, head, tail)
  member this.Head = match empty with 
                      | false -> head
                      | true -> invalidOp "An empty stream has no head."
  member this.Tail = tail()
  member this.IsEmpty = empty
  member this.iterate action = 
    if not empty then 
      action this.Head; this.Tail.iterate action
  member this.iterateWhen action condition = 
    if not empty then
      action this.Head;
      if condition this.Head then
        this.Tail.iterateWhen action condition
  static member Empty = 
    Stream<'a>(true, Unchecked.defaultof<'a>, 
               fun unit -> invalidOp "An empty stream has no tail.")
  static member Map func (s:Stream<'a>) = 
    if not s.IsEmpty then 
      Stream(func(s.Head), fun () -> Stream.Map func s.Tail)
    else
      Stream.Empty
  static member Infinit producer head = 
    let rec stream = 
      fun () -> Stream(head, fun () -> stream() |> Stream.Map producer)
    stream()

let sqrt_stream x =
  let inline avg_damping f = fun x -> average x (f x)
  let getNext = avg_damping (fun v -> x * 1.0 / v)
  Stream.Infinit getNext 1.0

代码7

在这个版本的开平方函数中,没有任何精度设定,也就没有实现上的精确度损失。但是限于我目前的F#水平,我相信这个Stream的实现并不简洁,欢迎大家给出改进建议。(这遍文章里的F#代码大概占我写过的全部F#的50%,选F#只是因为我觉得照搬书上的Scheme代码和用我熟悉的语言解决这么简单的问题都很无聊。)

符号求导

在上面的牛顿法一节中,我们为了求解函数的导数而引入了一个新的精度假设值并导致了一定的精度损失。其根本原因是我们其实根本没有求解出导函数,而是利用了计算机强大的运算能力直接计算出指定点的导数值而已。

如果我们能把导函数方程先求解出来,并用这个导函数计算导数,就不会再有那个精度误差了。也就是说。我们希望能把方程f(x)=ax^2+bx+c当参数,并求解出f(x)=2ax+b这个导函数。

高级编程语言基本都自带表达式解析库。F#也不例外。所以符号化求导实现起来也并不复杂,至少不用自己写词法分析器。你当然可以自己写一个彰显实力,只是不太好读的代码多了也就不好管理了。但是需要避免的状况是:你自己写的一个,仅仅是因为你不知道语言里自带了个库,或是不屑于用。这种情况很常见,比如一个例子是:

let abs a = if a > 0 then a else -a

你看不出来问题在哪?那说的就是你。

在F#中实现符号求导,我们只需要把所需要用的如下求导法则

  • \frac{dc}{dx}=0
  • \frac{dx}{dx}=1
  • \frac{d(u+v)}{dx}=\frac{du}{dx}+\frac{dv}{dx}
  • \frac{d(uv)}{dx}=u(\frac{dv}{dx})+v(\frac{du}{dx})

代码化成一组模式匹配:

let inline sum     e1 e2 = <@@ (%%e1: float) + (%%e2: float) @@>
let inline sub     e1 e2 = <@@ (%%e1: float) - (%%e2: float) @@>
let inline product e1 e2 = <@@ (%%e1: float) * (%%e2: float) @@>

let rec deriv (body:Expr) (p:Var) =
    match body with 
    | Var(x) ->
        if x = p then <@@ 1.0 @@> else <@@ 0.0 @@>
    | ValueWithName(x) ->
        <@@ 0.0 @@>
    | SpecificCall <@ (+) @> (_, _, [lArg;rArg]) ->
        sum (deriv lArg p) (deriv rArg p)
    | SpecificCall <@ (-) @> (_, _, [lArg;rArg]) ->
        sub (deriv lArg p) (deriv rArg p)
    | SpecificCall <@ (*) @> (_, _, [lArg;rArg]) -> 
        let pl = product lArg (deriv rArg p)
        let pr = product rArg (deriv lArg p)
        sum pl pr
    | _ -> invalidArg "body" ("Invalid body: " + body.ToString())

let derivE (exp:Expr) = 
    let (var, body) =
        match exp with 
        | Lambda(var, body) -> (var, body)
        | _ -> invalidArg "exp" ("Invalid exp: " + exp.ToString())
    let derivBody = deriv body var
    Expr.Lambda(var, derivBody)

let sqrt_v5 (y: float) = 
 let exp = <@ fun x -> x * x - y @>
 let f  = exp |>           EvaluateQuotation :?> ( float -> float )
 let Df = exp |> derivE |> EvaluateQuotation :?> ( float -> float )
 find_fix_point (fun x -> x - f x / Df x) 1.0

代码8

现实的项目中没有人会用符号求导技术来写个开平方函数。因为它比前面的版本要慢上1000倍。但是符号求导所带来的灵活性使得这个框架可以用来解决比开平方复杂得多的问题。

这个版本的代码没有使用流,如果需要,可以把它也实现成一个流。在软件工程领域,不同技术方案基本都可以合并而生成一个新的方式。而在非软件工程领域,这种合并常常会由于物理法则的约束而无法达成。这是软件工程复杂性的又一个体现。

综述

本文的主要意图是阐释,充实自身业务领域知识与计算机领域知识对于编写高质量代码的必要性,使用合适的方案解决一个问题能带来多方面的好处。这似乎是一句废话,但是其实这只是一个认识陷阱:人们,尤其是经验越丰富的人,一但认为自己理解了什么,或是利用起了他们的既往经验,他们就常常停止了探索,停止了学习,停止了思考。这对于软件开发是非常有害的。

但是另一方面,如上的发现问题、解决问题、发现新问题的循环是没有尽头的。具有钻研精神的程序员们总能在代码中发现问题并用各种方式尝试解决它,同时引入新的问题。专业的软件工程师能够根据最终目标和环境的不同知道应该往哪个方向走,并且到站了也知道停下来。在一个仅仅需要开平方的地方引入符号求导机制同样有害。

本文中的例子和解决问题的过程纯粹为阐释上述意图而刻意设定,请勿作编程指导,如果你能从中发现自己坠入的认识陷阱就再好不过了。

参考书目

Jerome H.Saltzer, M. Frans Kaashoek. Priciples of Computer System Design (《计算机系统设计原理》)

Harold Abelson, Gerald Jay Sussman, Julie Sussman. Structure and Interpretation of Computer Programs 2nd Edition (《计算机程序的构造和解释》)

Tomas Petricek, Jon Skeet. Real-world Functional Programming – With Examples in F# and C# (《C#与F#编程实践》)

Adrian Banner. The Calculus Lifesaver – All the tools you need to excel at Calculus (普林斯顿微积分读本)

Gerald M.Weinberg. The Psychology of Computer Programming (《程序开发心理学》)

谷强强 版权所有

《那人、那山、那狗》

之前看到片名的时候,以为和《篱笆、女人和狗》有什么亲缘关系,就一直没有看过。于是这部99年的好电影就这样被我错过了十多年。

这部影片所讲述的故事非常平凡,甚至平淡。就是邮递员老爸把工作交给儿子,陪儿子走最后一次邮路的过程。但是也正因为这种平凡而让人感觉真实而亲切,也正是因为这种平凡而更能体现出演员的实力。

然而情节和表演并不是本片最打动我的地方。平心而论这部电影的部分情节转承稍显突兀,刘烨的表演也有些生涩。论艺术品质算不得上乘。但是在这父子感情这件主线背后,还有关于对人生、社会、责任的思考。面上是在讲述这个发生在深山里的小故事,却影射着外面大社会的一些变化和冲突。

我不想赘述拙见,毕竟仁者见仁,智者见智。只是好电影值得更多人去品鉴。

咽炎

本来自己一直也没感觉嗓子有什么不舒服,但是自从上周六体检的时候医生跟我说有慢性咽炎开始,就觉得嗓子的确不舒服了。不疼不痒,就是毛毛的,想咳又没什么东西好咳的感觉。忘了从哪年开始,好像每年都这样,有时严重些,会咳个几个月才会好转。不过用屁股分析一下也能知道这大概是从我大中国冬天的大面积烧煤供暧开始的。虽然我一点热乎气儿都没沾着,这烟霾倒是没少吸。人们把由于工作需要而沾染的疾病叫职业病,不知道咽炎算不算得雾都生活病。我想,大概这就是生活在这样一片蒸蒸日上的沃土上所需要付出的代价。可谁都不想拿健康当代价,所以有了病,无论责任在谁,去医院看病的都得是本人。

上海的三甲医院不少,可也架不住全上海人得个头痛脑热的都去三甲医院看。所以本着不给三甲医院添乱,不让小医院的医生闲得蛋痛,也不纵容自己以排队为由无所事事地看一两个小时手机的原则,我还是去了某医院。我已经去过几次了,也看得出来这医院又贵又没水平,但是我相信看个咽炎应该还是足够了的。

早晨送了老婆到公司,花十分钟绕过罗山高架过来就是这家医院。把车在楼门口停下,拿着医保病例本就进去了。门口问询台没人,我就径直走向了总服务台。

“请问是在这儿挂号吗?”

“你什么不舒服?”

“我嗓子疼。”

“前边左转上二楼找护士。”

“哦,好。”

我正要转身,看见她瞥见了我手上的社保病例本,叫住我说:

“哎,我们这儿社保卡不好刷的,只能用现金或是刷卡,挂号费480。”

“哦,我知道的,中间带的卡可以用吧?”

“保险卡是吧?你上楼问护士吧。”

看来她也不知道。我就按她说的上了楼。记得我上次来的时候还是有人带我去的二楼护士台,现在连门口司仪都省掉了。不知道是不是入不敷出了。

到了护士台,有个看着像是医生的人走过来问我。

“你是什么不好啊?”

“啊,我就是嗓子痛。”

“啊,感冒了吧。”

她转过去又跟护士台的人说。

“我跟你们说过的吧?咱们人可以匀一匀。”

她又转过来打量了我一番,说。

“穿太少啦,感冒了应该多穿一些的。”

“啊,我就是咽炎,应该没有感冒。”

护士问我之前来过没有,又问了出生年月。然后问医生挂哪个科。我始终没说什么话,之前去公立医院看感冒也有把我安排在耳鼻喉科的。人比呼吸科少很多,医生看起来也更有耐心一些。

“就挂王主任的吧。”

“呼吸科好像也没有人啊。”

“不是吧,刚才不是来了一个吗?这个就挂王主任吧。”

然后护士就拿出来了个单子让我签字。

“我来带他过去吧。”

我签好了单子,她跟护士们说。

我就跟着她走了十几步来到一间诊室坐了下来。

然后她笑着跟我说,“这是我们从瑞金医院请来的专家,一个星期才来一次呢。”好像这是莫大的荣幸。

我没说什么,她就走了。

这个老大夫梳了个大背头,看上去很体面。

我说嗓子发毛,他就拿出舌板和手电叫我张嘴,看了一下说。

“是咽炎,扁桃体也有些肿。”

“扁桃体也发炎了?”

“有些红肿,你有扁桃体的是吧?”

“咽炎是急性的还是慢性的?上次体检的时候,他们说我是慢性的。”

“这次是急性的,我给你开些药……”

我有些诧异他这么快就要给我开药。于是问他。

“但是扁桃体发炎不是应该发烧的吗?可我不发烧啊。”

“有些人是不发烧的。”

“那是发烧好还是不发烧好呢?”

“这个看个人体质的。这个细菌和你的人体啊,就像两边对抗。比如如果你白血球高,但是不发烧,说明你体质好的。那,如果白血球不高,也不发烧,也可能说明你免疫系统太弱了。”

他看我好像意犹未尽,便说:

“我给你开个单子看下血项吧。”

然后递给我一个单子,指着旁边的房间说:

“就在隔壁。”

我前面就两个人,我就在门口等着,等的时候我看见我出来的诊室的门口的牌子上写的是“内分泌科”,这还是比较出乎我意料的。看来这个科真是人太少了,都开始出来拉客了。想想别的医院所有的科都人满为患的时候,这边儿一个主任医师在里头无所事事地坐一天。

没想几分钟就到我了,到我的时候换了个老一些的护士,我还挺庆幸。

我坐下,把单子放在桌子上,拉了一下衣袖没有拉上去,正准备把毛衣脱掉。护士说话了。

“要不扎手背吧。”

“手背好扎吗?”

“你这拉不上去我也没有什么办法啦。”

“那就扎手背吧。”

我觉得她有信心那就好。反而扎什么地方我是无所谓的。

她扎的时候我一直看着,刚扎进去的时候我就觉得没有扎正。果然她顿了一下。

“哎呀,血管滑掉了。”

我想着之前抽血没扎好之后的一大片淤青,心里就一群草泥马奔腾。

看着她挪动着针头,一阵阵疼痛从手背传来,我还得保持手一动不动,以防针头在里头把血管撕开个口子。

她调整了一会儿,血才慢慢地流了出来,接上真空试管血出的速度还是很慢。看来扎得真是很不好。本来只要几秒的采血,用了二十几秒才采到一小管底的量。

“血管太细了,又滑。”她解释到。

“应该够了吧。”她自己嘀咕着。然后把针头抽了出来。

“按压两分钟。”

她把一团棉花按在我手背上说。

“血管又细,还滑了,可能有些疼啊。”

“嗯。”

我使劲按着手背,找到个沙发上坐着等结果,想她说的是“滑”还是“划”。

旁边也有一个人在等结果,他旁边有一个女医生在等他讲一些手术上的事情,我没刻意去听,只是听见她在说在这里也可以做,就是费用会贵一些,医保卡也可以用什么的。恍惚间我有一种看见安利的感觉。

等了没十分钟,那个老医生就跑过来叫我。

看了一下血项没有任何有意义的异常。他就接着给我开药。

“我给你多开几种药,有喷的,有冲的,还有消炎的。”

他们医生一说消炎我就就敏感,我瞥了一眼屏幕,看见有头孢二字,我就问

“没有细菌感染也要吃抗生素吗?”

“你的扁桃体有些发炎,而且细菌感染也不一定血项高的。所以还是吃一些的好。”

我没有说什么。

“你看着感觉好了就可以了。慢性的药我也给你开了一些,那些可以接着吃。你听懂我的意思了吗?”

“嗯。”

我想反正我也是看得懂说明书的。

致谢之后我就下楼取药了,看着满满一包计四种共十二盒的各式中西药,我觉得真吃完没病也得吃出点什么病来。仔细看了一下药名,其中有一个是盐酸头孢他美酯,是三代头孢广谱抗生素。我觉得还是不要吃的好。

出门的时候嗓子毛毛的。

手背上还不时还会传来一阵阵的疼痛。

理头

家门口有家小理发店,开了大概有一年多了。已经去理过大概有七、八次了吧。但是我依然没有搞清楚他们家早晨是几点开门。

那天早晨起来没什么别的事儿做,九点就去了,门还没有开。压了半个小时的马路回来,远远地看见门口黑白两色的转柱还是没有什么动静,心里就有些不安,心想这么晚还没开门,不会是歇业了吧。走近一看,门倒的确是开了,只是三个伙计都还在准备,要么低着头打理自己的头发,要么低着头在扫地。我使劲把门拉开,想着弄出点什么声音好引起他们的注意,但是声音没有盖过他们手里的吹风机。我走进去站着,看着其中一个吹着头的伙计。

他抬起了头看着我,脸上有些莫名其妙的表情,像是不太理解我为什么会出现在这里。我想或许是太早了。

我伸出剪刀手朝着自己的脑袋比划着说:“剪头。”

他才恍然大悟一样,跟我说:“先坐会儿吧。”

于是我就找了离我最近的椅子坐了下来看手机,他继续吹他的头。

“先洗个头吧。”过了几分钟,他打理完了自己的头,朝我说。

我把手机揣到裤子兜里朝里面的洗头台走去。里面有两个洗头用的躺椅,我正在考虑应该用哪个的时候,他伸手指着其中一个。我走过去,偏着头往椅子上扫了一遍,确认椅子上没有水珠什么的才坐了上去。洗头很快,冲水,打洗发水,揉一遍,再冲,擦,吹干。前后两分钟的事儿。不推销充值卡,也没有家长里短的客套铺垫。虽然洗的时间短了些,但是是全然属于自己的,能安静地享受。

“怎么剪?”,洗完坐定之后,理发的小伙照例问到。

新人来理就是这点很麻烦,你要跟他解释清楚到底要剪成什么样。

“毛寸儿,周围都剃上去 ,上面稍微短一点儿。”

“都剪短是吧?”

这不像是真的在问我,而只是确认一下,于是我没有再说什么,况且我觉得我已经说得很清楚了。而且毛寸这种发型,哪怕你什么都不说,光是看现在的样子,也大概能知道一个月前每个部分是多长。问一下更多的是确认这次是不是要换个新的发型。

他也没什么迟疑地上手开剪了。我想他是明白的。

他把围布给我围上的时候,一股汗臭扑面而来。

“你们这个该洗了吧?”

“什么东西?”

“这个。”我的手被盖在围布下面,于是就把整个围布撑了起来。

“这个洗不掉的,做头发的时候滴上去的。”

他说的应该是围布上被染发液染上的颜色。

“我是说汗臭味。”

“我们有洗的,一个星期洗一次,只是那些块是洗不掉的。”

“我没有说那些块,我说的是汗臭味!”

他没有再说什么,大概是觉得自己已经回答了我的问题,没有必要再多说什么。我也不想坐着说话不腰疼地问他为什么不是每天洗?也不想让他再换一块,因为下一块说不定会更糟。我只是在考虑以后是不是应该找个好一些的地方。但是我这种近似于光头的毛寸,好像不至于这么矫情。而且如果谁都不在这种小店理了的话,想必是会倒掉的,家门口的水果店、饭店、咖啡店、理发店一家接一家地换也不是什么好事儿。好在不是所有人都像我这么矫情。我就这么没什么边际地想着,他手上活也一直没有停下来。

整个过程中,一句话都没有再讲。我挺喜欢这样的,理头时候说的那些话大都是不疼不痒的,而且还有吃进自己头发的风险。只是后来他一剪子把头上的一块剪去三分之二的时候我就知道他根本没有理解我说的发型是什么。但是我依然没有说话,我总不能让他把剩下的部分留长些。那这块就变成了一个坑。一个地方按一个型剪短了,剩下的自然也得照着剪下去。

他收了推子、剪刀,又用吹风机把头发渣滓吹干净了。我都以为他要让我起来去洗头了,他拿了面脑袋大小的镜子过来放在我后面问我怎么样。

我看着镜子里的圆圆的头型问:“你还记着一开始,我是怎么说的吗?”

他没说话。

“我说‘毛寸儿,边儿上都剃上去,上面稍微短一点儿。’,我想知道在你们的语言里”——我是北方上,理发的小伙是南方人——“‘稍微’是个什么意思?”

他对我怎么说的没有表示异议,但是对于结果有。

“这就是稍微短了一点儿啊。剪掉了大概三分之二,哦,不,三分之一。”

“ 是,三分之二。我理解的‘稍微短一点儿’应该是这样的。”

我把手从围布里面伸出来,比划出一指长的长度。一指比我原来的头发长些,是为了确保他能看清距离上的变化。然后把这个长度缩短了一骨节的距离。我是看我的手说的。所以我并不知道他是不是也在看我的手。

“看来我下次是不是应该找张纸把我想要的发型画下来才能讲清楚?”

“那不用的,你原来的头发没有那么长的。我就是剪掉了三分之一的。而且你上面留太长了的话是会翘起来的。”

他说的没有错,我头发硬,起来之后也没有洗过头,所以来的时候的确是翘翘着的。我也没再说什么,理头不像买东西,不满意可以换。我现在就算想换也得等上一个月才行。我只能先去洗头。

洗头的时候小伙还在跟我继续解释。

“毛寸儿其实都是这样的。”

“看来我下次得换个词儿了,不能说毛寸儿。”

“你周围剃短了,上面也得短一点才好看,要不然头发就不齐。”

我心想,不觉得宫城良田的发型难看,更不觉得赤木刚宪的发型比他的看好。

“我想要的,是我觉得看的发型,不是你觉得好看发型,你明白我的意思吗?”

“明白。”

“你觉得什么样的发型好看,我不关心。”

洗完头,照例帮我擦干。

“吹一下吧。”

“不用了。”

“还是二十块钱是吧?”

“是的,支付宝是吧?支付宝扫这面。谢谢你啊。”

我走出去的时候,都没有说再见。

再读《一九八四》

上一次读《一九八四》大概是两年多以年,读了半本就感觉难以读下去了。听过了老一辈人讲文革,讲上山下乡,了解过那段疯狂的历史,再来看这种想象出来的专权统治下的生活状态就感觉一点儿味道也没有。唯一的新鲜东西就是那个连周围人的心跳都可以监测到的“电屏”,却又因为这种比其它东西超前太多而感觉格格不入。于是中途放弃了。

然而看书看一半这种事儿,一但想起来,就像撒尿撒一半一样让人十分不爽。这个十一没有回家,也没有别的事情,就自己一个人在家,于是就又把这本捡了起来。我看书一般比较慢,这次花了一个晚上和一个上午完了就已经是快的了,快是因为我这次的确被吸引和震慑到了。

这本书,不是一个寓言,也不是科幻,也不是政治。它是启示录,给人以一种令人窒息的绝望感的启示录。这绝望,不是指那无时无刻无处不在的监控,监控可以避开;也不是每况日下的生活环境,恶劣的环境还可以忍耐。那绝望,来自面对人性的泯灭和精神的摧残时的无力感;那绝望,就像是被一群没有思想、没有情感的丧尸所包围的感觉。就像书里所阐释的那样(括号里是我加的潜台词):

党所做的最坏之事,是说服人们仅靠(自己的)冲动或感情解决不了任何问题(所以你什么事儿都要依赖党),而同时让你在现实世界上变得彻底软弱无力(因为你自己一个人什么都不是)。

一开始,他以自己仅能做的方式——写日记——抗争着,并寻求着莫须有的兄弟会的同盟,却又不得不提防着身体所有的人,从一个个眼神、细小的动作甚至说话的方式上来分辨周围的人。他找到了奥布兰,因为奥布兰的肢体动作给他以一种安全感,奥布兰也找上了他,他也好像找到了组织,第一次会面就坦白了。然而现实轻易地将他的希望击碎了,他的判断全是错的。他觉得正统的,不正统的,都被抓进了仁爱部,或者是直接蒸发掉了。他时常造访的杂货店掌柜却是他时刻提防的思想警察。他自己还什么事儿都没有为组织做,就被抓进了仁爱部。事实上,他是掉入了别人的圈套,一个精心策划的陷阱,在他内心最有希望的时刻,把他打入无间地狱。更糟糕的是,哪怕身陷囹圄,他依然无法确认对方是敌是友,他没有头脑,没有思想,没有判断的基准。他只有接受,按照对方想要的那种方式去接受。他放弃了二加二等于四的自由,还发自内心地认为二加二等于五。他没有死,他被净化了,他感觉“像是走在阳光下。但是原来的那个他,便同时被看守射杀了。他所有曾经有过的真实的快乐的童年回忆,都变成了“虚假的记忆”。而他的爱人,朱莉娅,原本一个活泼、机警看透了党的无聊手段的软妹子,从仁爱部出来也变得如同死尸般僵硬。

因为从精神层面上,他们都被摧毁了。

“他们进入不了你的内心。”她曾经说过,然而他们能够进入你的内心。“在这里发生在你身上的事将永远抹不掉。”奥布兰曾经说过,那是实话。你无法恢复某些事情,还有自己的行为,你内心的某些东西被损毁、焚烧并且被烙上了别的东西。

这是一悲剧。温斯顿曾经试图寻找过解决的办法,所谓的兄弟会给他了他“那本书”其实并没有讲任何他所不知道的东西。只是把老大哥的革命理念从现实的角度解释了一遍。然而所讲述的本质内容却是:社会就是这个样子的,人是分三六九等的,这是无法改变的。就像奥布兰所说:

群众永远不会造反,再过成千上万年也不会,他们没能力。

为什么没能力?温斯顿也知道:

他们从未学会思考,……   P182

“那本书”里也解释道:

完全不用害怕群众,由其放任自流,他们就会一代接一代、一个世纪接一个世纪地工作、生养、死去。他们不仅没有造反的冲动,而且不会明白世界可以变成另外一个样子……对他们(指处于战区的人)来说,哪一方取得胜利完全无所谓,他们明白统治者变化无非意味着他们仍然要继续干同样的活,因为新主人会跟旧主人以同样的方式对待他们。

这不仅仅是剧中人的悲剧,也是作者无奈的控诉。他在他的杂文中《我为何写作》中提到:

一九三六年以来,我所写的每一行严肃作品都是直接或间接反对极权主义,支持我所理解的民主社会主义。

这不是一本挑战想象力的小说,它在引导人们观察与思考这个社会,以防跳入掩盖在鲜花与掌声中的陷阱。他不是哲学家,也不是政治家,在他书中通过反讽等手法提出的关于社会制度和阶级的见解势必是浅薄甚至幼稚的。但是这并不妨碍它引导的作用。 也并不妨碍读者去作出自己的判断。它不告诉你答案,它也不应该告诉你答案,因为答案要靠你自己去探求。就像书里说的:

为什么一定需要一些年代久远的记忆,让人记着以前并非如此时,才会觉得这些是不可忍受的?

有些事儿,本是是非自明,无需佐引,无需类比,无需判例。但是前提是,你得有颗通事明理的心。

这本书细细读来着实发人深醒。4星推荐。

 

《挪威的森林》

高一的时候就听的过这本书,听说有些黄,于是也就没有好意思从学校的图书馆里借出来过,一则我当时的脑子根本不走这经,去图书馆借来的小说之类大都是原样的还了回去,翻都没有翻过,二则省得被老师叫去叮嘱一翻,毕竟在那时,学业才是头等大事,所有其它的可能会影响学习的,都会被老师当成潜在的威胁加以约束。三则同等的书实在太多,都想去看,却又不知道哪本适合自己看,于是就一本也不看。

十几年间试着读过几回,但是基本都没有读完一章过。这几天赋闲在家,手头上又没有其它的书好读,于是又捡起来读,这一读便不可收拾,一气读完。现在想来,之后没有读下去,大体是因为没有读懂;而现在读出点眉目自然就了读下去的兴致。早些年好读些修仙小说,现在读来却味同嚼蜡。这样说起来的,我的阅读品味比我同学们落后了十多年,他们在十七、八岁的年纪读着十七、八岁的爱情故事,而我到了三十岁才读得来。这个想法直到我卒读全书都耿耿于怀,懊恼没有早点硬着头皮把书看完,因为这真的是一本好书。

这本书大概算是青春爱情小说,不过看完我更觉得这是本讲人生的小说。小说中的每个主人公都有自己十分独特的对于人生和对于爱情的看法。只是渡边自己直到最后才对自己的爱情明了起来。其他人的结局都很直接,要么自杀,要么找到了自己的人生、爱情的目的并走了下去。这好像是在对几种不同的人生观、爱情观做一个比较。有的长久,有的自我毁灭。渡边作为一个普通人,放进人堆里都找不出来的那种,其实一开并没有十分明确的人生观,被敢死队引导着爱干净,被木月引导着接触女孩,自己甘做一根链条,大学的朋友永泽也是被动地被找上门,被引导着去解决自己的生理问题。他自然也有着自己的独特的思想,但是与其它人相比,他就像一张白纸一样。所有人都会喜欢白纸,坦诚的白纸。没有一丝别人的痕迹。所以渡边也是一直夹在缝隙间游走,直到直子死去,玲子给他做了心理疏导,他才缓过来,开始属于自己的人生和爱情。所以有人说这是一部讲述渡边的成长的小说,这也不错,只是这不是重点。重点是人与人之间的关系的多样,和每个个体的取舍。有的人无法取舍,就走向了灭亡。人生是有选择的,而且是有很多的选择的,找到自己的本心和位置,而不去纠结于旁人的理解。十七、八岁乃至二十三、四的楞头青,一如当年的我,思想单纯而没有目标,只知道傻傻地对自己脑子里臆想出来的东西信誓旦旦 ,却不见眼前的其它的路。

书有好坏,不以内容论。好的书,给你打开好几道门;坏的书,一叶蔽目。

三十而立

孔子有言,三十而立。我出生之后,地球绕着太阳转了三十圈,我便算是三十岁了,可是如何衡量“立”呢?这里的立和“成家立业”、“安身立命”中的立并不是一个意思,因为孔子又说自己四十不惑。当一个人还迷茫着的时候,业是无所立的。

《论语 泰伯》中说,“不知礼,无以立也。”。所以孔子所说的立,是知礼明义、能辨是非,三观确立的意思。这事儿听上去并不难,谁不辨是非了?谁又没个三观呢?你去问任何任何一个十八岁以上的人,你有三观吗?你有是非观吗?八成人会觉得这是个侮辱性问题,心情不好的还可能把你揍一顿。你也可以问些隔靴搔痒的具体问题,人们都可以说得头头是道。毕竟我们这代人从小耳濡目染的政治正确性和满世界的心灵鸡汤文不是白灌的。但是如果你去看看百度社会新闻,你却会觉得这个社会上奇葩怎么还是这么多。好像人们都已经习惯了说一套做一套,说得只是让别人舒心,做得只让自己舒心。而且这部分人并不占少数。搞到现在,“政治正确”俨然成了“现实不可行”和“空口说白话”的代名词。这些人,能说是知礼明义吗?但是如果你去质问他们行为,他会觉得你才是傻逼,甚至可能又把你揍一顿。为了避免出现刑事案件,大家还是处于一种:“互相尊重、互不干涉、和平共处、相互视为二逼”的状态安全一些。

有些人就这样心口不一地过一辈子,一辈子也没立得起来。至于他这辈子这样过得好不好,开不开心,只有他自己知道,别人没有资格评判。只是大凡年过半百之人,即使意识到自己之前的做法不好,也多半不会再改变自己的做法,而是竭尽其所能地让自己的做法合理化。改变本身也许不难,难的是解开心结,改变即是否定自己的过去的大半辈子。这也是为什么人越老就越顽固,越因循传统。因为没有人愿意否定自己的过去。所以三四十当立,三四十不立,就很难再立起来了。

但是立没立,这事儿不是别人说的,是看自己,毕竟明事理、确三观这种事本就是个人的事儿。无需了解孔子的立是什么意思,每个人都可以有自己的理解。不过我倒是觉得从几个方面,大概可以看出一个人还没有立。

  • 感到迷茫,没有方向
  • 人云亦云,没有主见
  • 消极厌世,没有热情

关于最后一条,并不是说不能厌世,一个人完全可以去过遗世独立、闲云野鹤的生活,那也是一种热情,一个没有丝毫热情的人,是连自己都会放弃掉的。如果一个人三者皆无,那绝无可能说这个人立了。

然而在现实中,刚毕业的学生容易迷茫;自己知识不足的很难产生主见;在公司上班领工资的容易失去热情。于是乎,其实所有人都可以堕入这个深渊。而当这个社会产生了这样的环境的时候,在这个社会中生活,你看到的所有的一切都不如人意。

  • 我们的垃圾分类推行数年毫无进展,因为民众普遍没有这方面的知识和热情。
  • 我们的老人倒了没有人敢扶,因为很多人被那些没有立的人坑怕了。
  • 我们饭店的食品质量普遍不好,因为从业人员多只是当成个营生,而不是事业去做。他们没有尽力做到最好的动力。
  • 我们的年轻人最喜欢比较哪个公司好,哪个工资高,学什么又轻松又有前途。因为他们普遍没有找到自己的方向、自己的兴趣,基本对这个世界一无所知,更无法对这个世界产生自己的主见。
  • 我们几乎所有的市民可以参与的市政公开讨论决议,都最终流于形式,比如环评。因为种种原因。
  • 我们的公司里,每一个新加的规定,每一个新封的网站,都是那些占便宜、钻空子的人留下的丰功伟绩。所以我们的报销越来越难,可以访问的网站越来越少。这群害群之马倒也立了三观:唯个人利益是从。这没什么不对,个人利益放什么位置个人说了算。但是他们只是社会常识的储备都不够,为了一两次的眼前的好处,损害的所有人,包括自己的长期利益。然后他们还会抱怨公司,而从不问自己的做法是不是合适。简直就是“猪队友”。(有多少公司因为受不了小部分员工把内部免费的东西随意拿取而最终取消这些福利的?我数都数不过来。)

有时我们明知道自己知道什么是对的,什么的是错的,自己也认同,却依然做不到。因为多数时候,不去这样做,反而对自己当下的生活会有更大的好处。只是这样的最终结果会是,生活在一起的所有人,最终,都无法从中得到他们本可以得到的更大的好处。

在有些国家,“立”的人占多数,于是人们之间,至少在表面上,是友善的;对于生活,至少貌似是有热情的。人有爱心有热情了,才能更好地创造东西。比如我们知道日本没有假货,质量也好,有人从尿布到电饭锅都从日本海淘,倒不说别的国家的东西一定会比中国的好,但是至少不会被坑,一分钱一分货。在国内或许的确有同等品质的产品,甚至可能还更便宜。但是他们都已经淹没在一片假货的海洋中。大概没很多人会愿意花一天时间多方求证只是为了确认一件产品的品质和商家声称的一样。去外国海淘既省时又放心。而在国内真心做良心产品的企业,在这种环境下根本支撑不下去。于是也转而降低品质。劣币驱逐良币的现象,就这样产生了。在这样的环境下,每天的生活中就要天天提心吊胆地,在外面吃饭会怕垃肚子,在淘宝买个东西会是假货或是质量差。甚至与陌生人打交道,都会默认对方是坏人,是有所企图才找上门来,要么是推销东西的,要么是劫钱劫色。于是生活于其中就非常累,人累了就这容易失去热情,失去了热情这人也就废了,然后就变成了一个恶性循环。更糟糕的是,在这种环境下生活久了,人们甚至会觉得这就是生活的常态,甚至都不想去改变,只想着去适应这个环境。那就真是没得救了。

国家之前大力扩大教育,无论结果如何,绝对是正确的方向,只有当人们都明事理了,这个社会才会变得更好。之前我说过,我是反感并支持着国家建立GFW的,原因也是类似的,在人们明事理之前,人们普遍对互联网上的信息没有良好的分辨能力,听风就是雨。听见日本核电站事故,超市里的盐就没有了;听见长江飘着死猪,就半个月不吃猪肉,还把超市的水买光了。这都绝对不是什么好事儿。人们常常抱怨政府封锁消息,这的确是政府的不对,但是如果民众总是毫无头脑地解读这些消息,甚至造成更大的灾难,这些消息还是封锁起来的好。当然政府更好的做法是,不封锁消息,但是在发布消息的时候同时公布所有的相关信息、解释、建议等。避免民众恐慌。但是且不论政府有没有这个水平,即使政府都做到位了,就能避免民众那些不理智行为了吗?民众会听政府的解释吗?有人会想,政府做的对不对是政府的事儿,民众听不听、信不信是民众的事儿,政府不能假设民众都是傻子。但是请想想,如果政府真这么做了,那么就不是真正的民主政府。好了,不说了,这种讨论是没有头的。我只想说,如果投票要不要废除GFW,如果我在国内,我是会从我的个人利益出发,投赞同票,因为我要访问所有的网络。但是政府立法决定我们要有GFW,我也表示理解和支持。至于这么做对不对,好不好,我不在其位,不操闲心,不作评论。如是而已。

一言蔽之,希望我们早日过上没有贫困,没有GFW,没有假货的生活。敲键盘、烂笔头没有用。当每一个人都热爱生活,享受自己的人生的时候,那日才会到来。我尽力过好我的生活,也希望每个人都好。

我是Hugo,程序员。喜欢编程,喜欢思考。其间总有一些东西想要记下来,于是就有了这个博客。这并不是我的第一个博客网站,但是是第一个属于我的独立网站,而这是第一篇,我想应该先说下我自己。

我喜欢追根问底,从不轻信听到的任何信息,无论其来源多么权威,更喜欢挑战权威。喜欢思考各种问题,从技术到人生到社会。

我猎奇心重,对世界上各种事儿都有兴趣,小到看书、做饭、喝咖啡;大到天体物理学。

我容易感动,是个听音乐看电影都会哭的人;但是心肠却并不软,不会给乞讨之人一分钱。可怜之人自有可恨之处。

我认为人生的意义在于经历与体验,于是十分在意自己的感受,对影响自己的感受的人和事儿会很反感。

我认为幸福的来源在自身。幸福不是能被他人给予的。一个人必须能用心地生活,感受生活,才能得到真正的幸福。但是这个想法并不妨碍我在物质上的追求,因为那也是一种体验。

我理解力强,并对没相应的理解力与逻辑思考能力的人缺乏耐心。

我是个精英主义者,认为社会需要精英的管理才能得到最佳的发展,有些人就是得被人管。但是我并不是一个功利主义者,所以即使社会不是由精英管理我也无所谓。对社会的各个阶层没有偏见或歧视,但是对某些让我产生不适的生物个体会有反感。

我对肤浅地针砭时事的社论没有兴趣。用我的一个想法来说明一下:我反感局域网带来的不便,但是我支持当局对局域网的建设和对不良信息的监管和封锁。懂的人自然懂。

我会固执地做我自己觉得正确的选择,哪怕这事儿其实我已经看透,并知道我做的事儿对全局一点意义都没有也照做无误。做我自己,追求本心,是对我自己有意义的事儿。

我不会囷于任何无谓的风俗习惯,也不在意思旁人惊诧的目光。因为我的生活是我自己的,只要没有妨碍到他人,他们的惊诧与误解与我无关。

我喜怒形于色,虽然不很明显,不熟悉的人一般看不出来。

我对人一般很友好,但是对自己看不惯的人会有干架的冲动。可惜从来没有实施过。

我做事儿会考虑时间价值。因为我觉得一生很短。反感一切浪费时间的事儿,如有可能,就花钱买时间。但是我会玩游戏,会发呆,这并不矛盾。

我喜欢Jazz,也喜欢许嵩的中国风。但是不追星。

我喜欢看书,范围广泛,从《一只特立独行的猪》到《少有人走的路》,从《斗破苍穹》到《道德情操论》,从《梦里花落知多少》到《黄金时代》。

我喜欢收藏,从钢笔到邮票到书到硬币到各类票据。

我喜欢看漫画,三大日漫都在追。

我喜欢小雨天,喜欢听雨声,喜欢开着窗户睡觉。

我喜欢各种小动物。

我喜欢书法。

我不抽烟,但是抽过烟。

我喜欢玩RPG游戏,走刺客流。也喜欢玩Minecraft,但是创造力并不怎么样。只是用来消磨时间而已。我不喜欢对战类游戏,因为我手比较残。

我胆小,恐怖游戏都不敢玩。但是现实中,我并不怕黑,也并不怕鬼,也并不怕死。我怕死得无谓。

我不挑食,独不爱吃苦瓜,但是有时依然会买来吃,因为如我所说,人生的意义在于经历与体验。我不会因为自己的好恶而放弃。

我喜欢做饭,而且手艺不错,但是只在心情好有时间的时候才自己做,因为有太多更喜欢、更重要的事儿要做。我不喜欢为了省钱自己做饭。

我记不住人名,连楼上楼下对门邻居的名字都记不住,更不要问我漫画、电影、演员之流的人名了。

我就事儿论事儿。比如对代码苛求完美,但是对发型,一直是板寸。那么我是个完美主义者吗?

我喜欢旅游,但是对于在国内旅游没有太大兴趣,以国内的服务品质,被服务就是被虐。这无关服务场所档次。整体而言,服务人员的素质没有到,也没有用心服务的心。

我这人不恋家,这里所说的家是指父母家。我已经有自己的家了,我只恋自己的家。

我做事儿专注,做自己喜欢的事儿,比如写代码,可以从早写到晚。

我不喜欢枯燥无味的工作。

我基本没有迟到过。

下面是一些个人观念上的事儿,可以用来判断我们是不是一类人。不是一类人一般会相互嘲讽,但是你们放心,我从来不放在心上。

我铭记着历史,但是我依然喜欢日本。

我相信中医有可取之处,但是认为当代中医已经没落。像这个世界上多人都是庸人一样,认为多数医生都是庸医,所以不会严格地遵医嘱。

我还坚信有外星人。

我支持转基因,即使有证据证明有些转基因产品有害。

我不介意居住地附近的信号塔。

我从不随地吐痰。并厌恶在哪怕里小便池吐痰的人。

我吃不完或是不爱吃就会剩饭。

我是INTJ型的人格。