lambda 语法
在看完上一篇的一大堆铺垫之后,我们终于要进入正题了。不过话说回来,我们还是要明确这一个概念:lambda 只是对应函数对象(再退一步说,匿名内部类)的语法糖而已。是的,仅此而已。
lambda 表达式
重新看一下我们之前写的分数类,可以看到其中代码的效率是非常低的(是代码的写作效率,不是运行效率),或者说,我们其实为了语法需要,废了很多的口舌在说废话。这时候就轮到我们的lambda表达式上场了。跟前几次一样,我们将之前分数类的代码用 lambda 重新写一下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Fraction {
int numerator, denominator;
public void plus(Fraction f) {
operate(f, (a, b) -> a+b);
}
public void minus(Fraction f) {
operate(f, (a, b) -> a-b);
}
public void operate(Fraction f, BiFunction<Integer, Integer, Integer> func) {
denominator = denominator * f.denominator;
numerator = func.apply(denominator * f.numerator, numerator * f.denominator);
// ... other operations
}
}
这里的代码完全和上一段等价。这里你就可以看到,函数式编程 + lambda 语法真正的提供了便捷。这里的 lambda 表达式 (a, b) -> a+b
完全等价于原先匿名内部类定义。我们简略的说一下它的语法。他有两部分,箭号左侧是函数参数,右侧是函数体。这里我们给出几个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
a -> a + 1; // 接收参数 a,返回 a+1
(a) -> a + 1; // 单参数可以加括号,也可以不加
(a, b) -> a + b; // 多个参数必须加括号,返回 a+b 的值
a -> {
System.out.println("I'm writing a lambda");
return a;
} // 多行函数体要用大括号表示,返回值要用 return 标明
(a, b) -> {
System.out.println("Multiple parameter and multiple lines");
return a + b;
}
可以看到函数参数有两种形式:单参数可以不带括号,多参数需要带括号。函数体也有两种形式:单行函数体和多行函数体。单行函数体的执行结果直接作为返回值返回,不需要写return。多行函数体和普通的函数无异,返回值用return标明。
变量捕获
lambda 还有另外一个非常有用的特性,就是变量捕获。他允许你使用当前语境下的局部变量以及当前对象的成员变量。这其实是一个继承自内部类的特性。通过这个操作我们可以轻易地操作表达式语境内的变量。下边给一个例子,我们通过 foreach,把 List<Integer> source
里边的所有内容添加到 List<Integer> dest
里,代码如下:
1
2
List<Integer> dest = new Arraylist<>();
source.foreach(i -> dest.add(i));
这一操作实际上相当于在内部类里引用了外部语境的变量,相关的操作方式应该在学习 java 基础的时候就已经接触过了,我这里就不多说了。唯一的一个关注点 effective final,我会在附言部分说到。
附言——各种细节与小技巧
final 相关
出于安全考虑,如果你要在 lambda 表达式里边捕获局部变量,有一个问题是你务必要注意的:被捕获的变量需要是 effective final。这是 java 制造出的说法(其实有点纠结),也就是说他可以不是 final,但是加上 final 之后应当仍然能编译通过。这个特性在各篇文章里已经被轮流批判过了,认为他极大的阻碍了 lambda 的拓展性。其实不然,我这里就从另一个角度说一下他的设计意图,以及应对方法。
我们已经知道了 lambda 其实就是局部匿名内部类的实例,java 出于统一性和兼容性的考虑,并没有为 lambda 添加新的结构,于是 lambda 表达式也自然继承了局部匿名内部类的这个特性。这个现象的根本来自于局部变量与内部类实例的生命周期的不同,具体内容可以参考 这篇。java 在编译时将捕获的变量隐式传入了内部类对象。这就意味着对于内部类而言,这实际上只是一个传入的对象,并不是一个外部变量的引用。所以说假如外部变量发生了改变,内部类所持有的这个对象并不会相应的变化,就会带来同步的问题。这里我们想到 foreach 的内部迭代在相当程度上就是为了解决多线程遍历问题,这就意味着 lambda 对象应该在多线程环境下正确运行,而 lambda 内部持有的对象不可以同步改变,所以结果显而易见:外部变量需要是一个定值,才能保证我们这个 “捕获” 的语法糖能正确使用。同时,为了代码的美观,java允许你在这种情况下省略被捕获变量的 final 关键字,但是他本身应该是 final 的,这就是所谓的 effective final。
说了这么多,你大概可以明白,下面这段代码是编译不过的:
1
2
3
4
5
int i = 0;
List<Integer> l = new ArrayList<>();
l.forEach(a -> {
i++; // 捕获的变量应为final,不允许赋值
});
这样虽然因为 final 的问题不能编译,但是我们其实可以通过一个简单的包装来曲线救国,这里因为有多个类,我把代码完整的写一下:
1
2
3
4
5
6
7
8
9
10
11
12
13
public class Demo{
public void main(String[] args) {
IntegerWrapper i = new IntegerWrapper();
List<Integer> l = new ArrayList<>();
l.forEach(a -> {
i.value++; // 这里捕获的i是final,但是我们可以修改它的成员变量
});
}
static class IntegerWrapper{
public int value = 0;
}
}
注意这里用原生的 Integer 类是不可以的,下面代码同样无法编译通过:
1
2
3
4
5
final Integer i = 0; // 这里的final可加可不加,没有实质影响
List<Integer> l = new ArrayList<>();
l.forEach(a -> {
i++; // java原生的包装类Integer的值是final,同样不允许赋值
});
通过这一层包装,内部类得到的其实是一个包装类的对象,或者说就是一个对象的引用。如果你熟悉 c 的话,可以发现这和常用的值传递/引用传递非常相似,只是 java 对于基础类型全部使用值传递,对于引用类型全部使用引用传递,我们通过这一层包装,就可以获得一种更灵活的操作方法。通用的解决方案,就是一个单独的泛型包装类,这也是我目前比较常用的方法。上面一段代码可以这样改写:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Demo{
public void main(String[] args) {
Wrapper<Integer> i = new Wrapper<>(0);
List<Integer> l = new ArrayList<>();
l.forEach(a -> {
i.value++; // 这里捕获的i是final,但是我们可以修改它的成员变量
});
}
static class Wrapper<T>{
public T value = 0;
public Wrapper<T>(T value) {
this.value = value;
}
}
}
这样几乎就可以解决 effective final 带来的所有问题了。至于包装类的效率,考虑到这里需要包装的对象基本上只有需要共用的几个,应该不会有可见的区别。
lambda 效率
这里我只粗略的说,不给出具体的数据。我之前读过一篇文章,有一些相关的数据,结论是 for 循环和 foreach 循环效率基本相当,foreach() 遍历稍慢(不是数量级上的慢)。在了解了整个结构之后你可能就会明白,for 和 foreach 只是流程上的循环,不牵涉到 function call,而从最好的情况来看 foreach 函数每次迭代都要多进行一次 function call。而对于一些简单的操作,牵涉到参数的入栈和出栈,现场的还原,高频率的 function call 的耗时在数据层面上是可见的,但是对容量很大的容器,foreach 在并行处理上的优化潜力仍然不可小觑。另外,我原本以为 lambda 表达式造成的函数对象的新建和销毁也会有性能消耗,实际上并不是这样,对于 lambda 表达式,函数对象的构造只进行一次,也就是在调用 foreach 函数的时候,所以并不会带来对象构造和销毁的负担。
函数引用
这也是 java8 带来的新的语法糖,通过函数引用,我们可以把一个普通的函数转化成函数对象:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Demo {
public void main(String[] args) {
Runnable r1 = Demo::funcStatic; // 静态函数
Runnable r2 = new Demo()::funcNonStatic; // 非静态函数
}
public static void funcStatic() {
System.out.println("This is a static function");
}
public void funcNonStatic() {
System.out.println("This is a non-static function");
}
}
语法上很简单,把调用函数的点号换成双冒号,并且省略掉参数的括号,就是函数引用的表达式了。相同的,静态方法可以通过对象或者类名访问,动态方法必须通过对象访问。很多人会把函数应用作为 lambda 的另一种语法,这样说也是不无道理。因为函数引用在本质上也是函数对象的语法糖:在上面这一段里,以下两个表达式是等价的:
1
2
Runnable r3 = Demo::funcStatic;
Runnable r4 = () -> Demo.funcStatic();
内部迭代与外部迭代
从面向对象的角度来说,一个 Collection 理应提供一个用于迭代的方法,就像我们之前提到的 Collection 的 foreach。但是这个方法的特殊之处在于,参数是一个 “操作” 而不是一个值,但是很多早期的语言对于函数式编程的应用并不是很普及,于是,我们实际上很广泛的在使用一种替代的方法,也就是外部迭代。在外部迭代的环境下,程序员需要构建整个迭代的环境,简单的如迭代器+for循环,对于很大的集合可能就需要使用多线程进行,这些所有迭代方式都是使用者确定的,Collection 本身只负责提供一个迭代器。这时你可能就发现问题了,在这个问题上,类库的封装程度比较低,相当一部分操作是暴露给开发者的。这就是外部迭代的问题所在,而且一个类库完全可以提供一种通用而高效的迭代方案(当然迭代器已经很不错了,但是内部迭代的优化空间会更大)。于是,随着编程语言的发展,我们就有了内部迭代。类库接受一个操作(或者说是操作者的对象,lambda 只是一种特殊的写法),应用于 Collection 内部的所有对象,所有迭代操作都在类库的内部完成。
正如前文所提到的,如果我们只是需要对于一个 Collection 内部的所有对象进行同一种操作,这显然应该是最方便,也理应是效率最高的方式。但是问题在于整个迭代过程现在不归开发者控制了,所以对于一些复杂的操作可能就并没有那么方便了。至于这种情况,我们会在下一段 “流” 里说到,但是使用迭代器带来的灵活性几乎是无可撼动的。
所以我们对内部迭代和外部迭代做一个归纳:
- 内部迭代优势:
- 语法简洁,符合面向对象思路
- 类库有很大的优化空间
- 外部迭代优势:
- 语法灵活,可控性强
- 作为不同 Collection 无可替代的交互接口
所以我们预见到,随着函数式编程的深入人心,内部迭代在大部分情景下将会成为主流,但是迭代器作为重要的算法基础,在某些特殊场景下仍然无可替代。
流
这一部分也是 java8 新增的重要特性。实际上 java8 为了使原生类库能够更好的适应函数式编程的思路,对标准库进行了大规模的扩容,更多内容参见 这个站点 的函数式编程相关文章,我这里只是简介一下,点到为止。
我们可以看到 java 标准库在 Collection 这一部分对于函数式遍历,只提供了 foreach 函数,这在很多情况下并不能满足我们的需要,所以 java 给我们提供了 Stream 这个类,主要就是用于通过函数式思路控制复杂的集合遍历,而所有的 Collection 都可以和 Stream 无缝转化(而且这一步几乎没有耗时)。这里必须要指出的一点是,Stream 本质上是一个用于迭代的临时结构,有点像一个函数式加强版的迭代器,勿当作容器使用。Stream 提供了相当多的方法,我这里只给出一个应用,从小到大打印一个容器里大于5的数:
1
2
list.stream().filter(i -> i > 5).sorted((a, b) -> a - b)
.forEachOrdered(System.out::println)
这里 filter 方法对流进行过滤,sorted 方法进行排序,forEachOrdered 进行遍历。(这段代码我没跑过,不过应该是这么写没错)唯一要注意的是我们之前说了 foreach 方法本身是不考虑顺序的,所以如果要按顺序遍历的话,需要用 forEachOrdered。通过流进行遍历使得我们代码的可读性明显增强了,而且我们可以连续调用,非常方便。
写在最后
这篇文章我原本打算很轻描淡写的装个逼走人,后来想到我之前读相关的文章完全是一脸懵逼,于是打算稍微把相关的东西讲清楚,争取让这方面零基础的小白也能明白我在说什么,于是就有点一发不可收拾的意思了,以至于我不得不分p来控制篇幅。结果就是我这里说的内容给人一种事无巨细的感觉,这其实就是我写这种入门指引的风格。我尽量把相关的点都讲到,内容上仅仅是点到为止。如果你真的有兴趣,我提供的内容足够帮助你在百度上找到一些更细节的文章了,我觉得这样就很好。
不得不承认码字这种事是十分辛苦的。我之前看知乎上有人分享日常心得,说他坚持 “日写千言” 超过一年,深感其中的好处,这句话我同意一半。写一些简单的技术文章对于我这种小白来说确实有很大的帮助,一知半解是没法把事情说清楚的,所以经常是我原本只是想分享一下近期获得的小技巧,结果是研究了半天,读了一大通文档和源码。这篇文章的一大半内容是我坐火车的路上写的,剩下填充例程,整理文本结构,核实内容几乎花了我三个晚上。所以对我来说,重要的不是写了多少东西,而是写的这个过程能帮助我理清思路。“吾尝终日而思矣,不如须臾之所学也”,我对码字并没有什么特别的兴趣,所以 “日写千言” 这种事我是肯定不会做的,我最大的希望就是把我要说的东西说清楚又不要过于累赘。如果能帮到各位自然是好的,同时我也很担心我里边是不是有一些欠考究的会误导到各位(笑)。所以呢,如果你发现有什么错误或者不严谨的地方,欢迎跟我提出来,我会尽早订正。
若非特殊注明,文章均为博主原创,通过 CC 4.0 BY License 授权转载