泛型

泛型(Generics)是JDK1.5版本增加的技术,他可以帮助我们建立类型安全的集合。在使用了泛型的集合中,不必进行强制类型转换。JDK提供了支持泛型的编译器,将运行时的类型检查提前到了编译时执行,使代码可读性和安全性更高。

不使用泛型:

public static void main(String[] args) {
        List myList = new ArrayList();
        //准备对象
        Cat c = new Cat();
        Bird b = new Bird();
        
        myList.add(c);
        myList.add(b);
        
        //遍历集合,调用特有方法
        Iterator iterator = myList.iterator();
        while (iterator.hasNext()){
            Object obj = iterator.next();
            if (obj instanceof Cat){
                ((Cat) obj).catchMouse();
            }else {
                ((Bird)obj).flying();
            }
        }
    }

在JDK1.5版本之前,我们通过Object类型来实现参数的“任意化”,但是“任意化”带来的代价是要做显式的强制类型转换,而这种转换是要求开发者对实际参数类型可以预知的情况下进行的。对于强制类型转换错误的情况,编译器可能不提示错误,在运行的时候才出现异常,这是一个安全隐患。

可以使用泛型 List<Animal> 指定集合中的数据类型 只允许存储Animal类型的数据

泛型使得集合中的数据更加统一了

在迭代器获取元素的时候也可以使用泛型:

因为在声明集合的时候使用了泛型:


而迭代器中的泛型和集合相同:

这样获取到的就是声明集合时指定的元素类型。

获取到的就是Animal类型的对象,因为我们想要求这个类只能对某些类型的数据进行操作,add/get方法都限制了只能对这些类型的数据进行操作

public static void main(String[] args) {
       	List<Animal> myList = new ArrayList<Animal>();
        myList.add(new Bird());
        myList.add(new Cat());

        Iterator<Animal> iterator = myList.iterator();

        while (iterator.hasNext()){
            Animal animal = iterator.next();
            animal.move();
        }
    }

泛型机制 只在程序编译阶段起作用,运行阶段不起作用

  1. 集合中存储的元素类型统一了
  2. 集合中取出的元素类型是泛型指定的类型,不需要进行大量的向下转型

缺点:导致集合中存储的元素缺乏多样性 ?

自动类型推断机制

JDK7.0 新特性,又称为钻石表达式

public static void main(String[] args) {
    //new ArrayList<>() 尖括号中内容可以省略
    ArrayList<Animal> myList = new ArrayList<>();
    
    //遍历
    Iterator<Animal> iterator = myList.iterator();
    while(iterator.hasNext()){
        Animal a = iterator.next();
        a.move();
    }
}

规定泛型使用 String ,方法的参数就只能是String

public class GenericTest03 <标识符随便写> {
    public static void main(String[] args) {
        GenericTest03<String> gt = new GenericTest03<>();
        
        
        gt.doSome(); //此处只能是String
    }

    public void doSome(标识符随便写 a){
        System.out.println(a);
    }
}

示例:

public interface Iterator<E>

假如规定ArrayList中存储的是Animal类型,其中Itr内部类的Iterator接口同时被指定该类型,next()返回的结果就是Animal类型:

E next();

有类定义如下:

class MyIterator<T>{
    public T get(){
        return null;
    }
}

如果写了泛型没有使用:

该类的泛型就被替换为Object

泛型擦除

Java中的泛型是伪泛型,例如定义:ArrayList<String>编译器只在添加元素时检查是不是String类型的数据,在集合中还是看作Object类型,并且在获取的时候会将Object类型的数据强转为String类型

Java源文件:

ArrayList<String> list = new ArrayList();

编译为class文件:

ArrayList list = new ArrayList();

这就叫做泛型的擦除

注意:泛型不能写基本数据类型,因为集合中存储的所有元素都看作Object,而基本数据类型是不能转换为Object的,只能写包装类

可以使用反射绕过编译器的检查:

public class GenericClassDemo {

    public static void main(String[] args) throws Exception {

        List<String> list = new ArrayList<>();
        list.add("aaa");
        list.add("bbb");
        // 编译器会阻止
        // list.add(333);

        // 但泛型约束只存在于编译期,底层仍是Object,所以运行期可以往List存入任何类型的元素
        Method addMethod = list.getClass().getDeclaredMethod("add", Object.class);
        addMethod.invoke(list, 333);

        // 打印输出观察是否成功存入Integer(注意用Object接收)
        for (Object obj : list) {
            System.out.println(obj);
        }
	    //但是get该元素就会ClassCastException
    }
}

泛型的擦除与补偿

泛型的出现提高了编译时的安全性,正因为编译时对添加的数据做了检查,则程序运行时才不会抛出类型转换异常。因此泛型本质上是编译时期的技术,是专门给编译器用的。

加载类的时候,会将泛型擦除掉(擦除之后的类型为Object类型),这个称为泛型擦除

为什么要有泛型擦除呢?其本质是为了让JDK1.4和JDK1.5能够兼容同一个类加载器。在JDK1.5版本中,程序编译时期会对集合添加的元素进行安全检查,如果检查完是安全的、没有错误的,那么就意味着添加的元素都属于同一种数据类型,则加载类时就可以把这个泛型擦除掉,将泛型擦除后的类型就是Object类,这样擦除之后的代码就与JDK1.4的代码一致。

因为加载类的时候,会默认将类中的泛型擦除为Object类型,所以添加的元素就被转化为Object类型,同时取出的元素也默认为Object类型。而我们获得集合中的元素时,按理说取出的元素应该是Object类型,为什么取出的元素却是实际添加的元素类型呢?

这里又做了一个默认的操作,我们称之为泛型补偿。在程序运行时,通过获取元素时指定的实际类型进行强转,这就叫做泛型补偿(不必手动实现强制转换)。获得集合中的元素时,虚拟机会根据获得元素的实际类型进行向下转型,也就是会恢复获得元素的实际类型,因此我们就无需手动执行向下转型操作,从本质上避免了抛出类型转换异常。

如果获取元素时指定的实际类型是Object,就不需要转型了

泛型类

当一个类中,某个变量的数据类型不确定时,就可以定义带有泛型的类。

public class Test<E>{
    E e;
}

实现ArrarList的add和get方法:

class MyArrayList<E>{  
    Object[] objs = new Object[10];  
    int size;  
  
    public boolean add(E e){  
        /* if (size == objs.length)  
            objs = grow(); */        objs[size++] = e;  
        return true;  
    }  
    public E get(int index){  
        return (E) objs[index];  
    }  
}

add方法要求只能存入指定泛型的元素,所以参数是E

ArrayList的get方法:

从集合中拿出元素的时候也要转换为泛型对应的类型

验证了:泛型只在向集合中存或从集合中取时有效,在集合内部还是将元素看作Object类型

泛型方法

方法中形参类型不确定时,可以使用类名后面定义的泛型。

如果一个类中只有一个方法的形参类型不确定,可以在方法申明上定义自己的泛型

格式:

public <T> void show(T t){}

[修饰符列表] <类型> 返回值类型 方法名(类型 变量名){}

在调用该方法时,T的类型才确定

练习:定义一个工具类:ListUtil,类中定义一个静态方法addAll,用来向指定集合中添加多个元素。

public class Generic_test {  
    public static void main(String[] args) {  
        ArrayList<Animal> animals = new ArrayList<>();  
        animals.add(new Cat());  
        animals.add(new Dog());  
        ListUtils.addAll(animals,new Cat(),new Dog(),new Object());  
        System.out.println(animals);  
        Animal animal = animals.get(animals.size() - 1);  
		System.out.println(animal); //ClassCastException
    }  
}  
class ListUtils{  
    public static void addAll(List list,Object ... objs){  
        for (int i = 0; i < objs.length; i++) {  
            list.add(objs[i]); //编译完成后,add方法的参数被替换为Animal,为什么obj可以赋值给Animal?
        }  
    }  
}

为什么能存入?

这样做是不行的,获取到在第8行获取animal时,编译器认为取出的是Animal类型,但是实际上取出的是Object类型,ListUtils中addAll方法未对泛型进行限制,应该根据List的泛型来要求只能存入对应类型的元素

class ListUtils{  
    public static <E> void addAll(List<E> list,E ... es){  
        for (int i = 0; i < es.length; i++) {  
            list.add(es[i]);  
        }  
    }  
}

方法调用时,泛型的类型,也就是参数的类型就被确定了

泛型接口

修饰符 interface 接口名<类型>{
    
}
public interface List<E>{
    
}

使用带有泛型的接口:

  1. 实现类给出具体类型

  1. 实现类延续泛型,创建对象时再确定具体的类型

泛型的继承和通配符

  • 泛型不具备继承性,但是数据具备继承性

method方法指明了参数是 ArrayList<Animal>,但是ArrayList<Cat>不是它的子类,只有ArrayList 的子类可以传入,并且泛型要和指定泛型相同

假设有ArrayList的子类SubArrayList:

class SubArrayList extends ArrayList{  
  
}

就可以传入这样的方法:

并不会报错:

练习:定义方法,形参是一个集合,集合中的数据类型不确定

    public static <E> void method(ArrayList<E> list){

    }

但是这样写有一个弊端,传递进去的可以是Animal、Car、Student等任意类型的数据

如果要求只能传递Animal及其子类,可以使用泛型的通配符

泛型的通配符:?表示不确定的类型,但是可以进行类型的限定

  • ? extends E:表示可以传递E或者E所有的子类类型
  • ? super E:表示可以传递E或者E所有的父类类型

注意:使用通配符不需要在方法的修饰符后再次声明

    public static void method(ArrayList<? extends Animal> list){

    }

表示现在的method方法中ArrayList的类型只能是Animal及其子类

    public static void method(ArrayList<? super Animal> list){

    }

表示现在的method方法中ArrayList的类型只能是Animal及其父类(Object)

应用场景:

  1. 如果在定义类、方法、接口时,如果类型不确定,就可以定义泛型类、泛型方法、泛型接口。
  2. 如果类型不确定,但知道只能传递某个继承体系中的类型,就可以使用泛型的通配符

关键点:可以限定类型的范围。

泛型思考

上:特定化与泛型

在JDK1.5引入泛型之前,ArrayList采取的方式是:在内部塞入一个Object[] array。

public class ArrayList {
    private Object[] array;
    private int size;
    public void add(Object e) {...}
    public void remove(int index) {...}
    public Object get(int index) {...}
}

如果用JDK1.5以前的ArrayList存储String类型,那么会有以下两个缺点(其实是问题的一体两面):

  • 需要强制转型
  • 强制转型容易出错

例如,代码必须这么写:

ArrayList list = new ArrayList();
list.add("Hello");
// 获取到Object,必须强制转型为String:
String first = (String) list.get(0);

为什么要强制转型?因为String是真正的类型,转型后才能使用String特有的方法,比如replace()。

OK,在确认必须强转的前提下,我们继续讨论。

强转会带来一个问题:很容易出现ClassCastException。

// JDK1.4可以这样做
list.add(new Integer(123));
// ERROR: ClassCastException:
String second = (String) list.get(1);

作为一种解决方法,JDK1.5之前的程序员可以为String类型单独编写一种ArrayList

public class StringArrayList {
    // 因为这种ArrayList只存String,所以不需要用Object[]兼容所有类型,只要String[]即可
    private String[] array;
    private int size;
    public void add(String e) {...}
    public void remove(int index) {...}
    public String get(int index) {...}
}

这样一来,存入和取出都被限定为String,且不需要强制转型,因为编译器会强制检查放入的类型:

StringArrayList list = new StringArrayList();
list.add("Hello");
String first = list.get(0);
// 编译错误: 不允许放入非String类型:
list.add(new Integer(123));

问题暂时解决。

然而,新的问题是,如果要存储Integer,还需要为Integer单独编写一种ArrayList

public class IntegerArrayList {
    private Integer[] array;
    private int size;
    public void add(Integer e) {...}
    public void remove(int index) {...}
    public Integer get(int index) {...}
}

如果还有其他类型,就要编写各种各样特定类型ArrayList

  • LongArrayList
  • DoubleArrayList
  • PersonArrayList

这是不可能的,光JDK的class就有成千上万个,而且还不算普通Java用户编写的类。

为了解决新的问题,我们必须把ArrayList变成一种模板。

什么是模板呢?以设计模式中的模板方法模式为例:

/**
 * 验证码发送器
 *
 * @author qiyu
 * @date 2020-09-08 19:38
 */
public abstract class AbstractValidateCodeSender {

    /**
     * 生成并发送验证码
     */
    public void sendValidateCode() {
        // 1.生成验证码
        String code = generateValidateCode();

        // 2.把验证码存入Session
        // ....

        // 3.发送验证码
        sendCode();
    }

    /**
     * 具体发送逻辑,留给子类实现:发送邮件、或发送短信都行
     */
    protected abstract void sendCode();

    /**
     * 生成验证码
     *
     * @return
     */
    public String generateValidateCode() {
        return "123456";
    }

}

对于上面的模板,我们可以有多种实现方式:

/**
 * 短信验证码发送
 *
 * @author qiyu
 * @date 2020-09-08 19:44
 */
public class SmsValidateCodeSender extends AbstractValidateCodeSender {

    @Override
    protected void sendCode() {
        // 通过阿里云短信发送
    }
}

/**
 * QQ邮箱验证码发送
 *
 * @author qiyu
 * @date 2020-09-08 19:45
 */
public class EmailValidateCodeSender extends AbstractValidateCodeSender {

    @Override
    protected void sendCode() {
        // 通过QQ邮箱发送
    }
}

所谓模板,就是“我能做的都给你做了,少量易变动的东西我留出来,你自己DIY去”。

同理,ArrayList<T>也是一种模板,能写的方法都给你写了,但变量类型我定不了,于是抽成类型参数:

public class ArrayList<T> {
    private T[] array;
    private int size;
    public void add(T e) {...}
    public void remove(int index) {...}
    public T get(int index) {...}
}

T可以是任何class类型,反正我已经帮你参数化了,你自己定。

这样一来,我们就实现了:只需编写一次模版,可以创建任意类型的ArrayList

// 创建可以存储String的ArrayList:
ArrayList<String> strList = new ArrayList<>();
// 创建可以存储Float的ArrayList:
ArrayList<Float> floatList = new ArrayList<>();
// 创建可以存储Person的ArrayList:
ArrayList<Person> personList = new ArrayList<>();

因此,泛型类就是一种模板类,例如ArrayList<T>,然后使用者可以自己选择将模板填充为什么类型:

// 嘿嘿,我想把ArrayList<T>填充为ArrayList<String>,专门收纳String类型
ArrayList<String> strList = new ArrayList<>();

你可以理解为此时ArrayList内部自动被赋值成这样(编译器层面):

public class StringArrayList {
    private String[] array;
    private int size;
    public void add(String e) {...}
    public void remove(int index) {...}
    public String get(int index) {...}
}

编译器针对类型作检查:

strList.add("hello"); // OK
String s = strList.get(0); // OK,因为上面add()保证了只能添加String类型,所以无需强制转型
strList.add(new Integer(123)); // compile error!
Integer n = strList.get(0); // compile error!

这样一来,既实现了编写一次万能匹配,又能通过编译器保证类型安全:这就是泛型。

形式类型参数、实际类型参数

泛型是一种技术,而不是单指ArrayList<T>中的T。像ArrayList<T>的T,Map<K, V>中K和V,统称类型参数(Type Parameter),也叫形式类型参数,它只是泛型这个技术的组成部分。

使用泛型时,比如ArrayList<String>,T被替换为String,可以看做是对T的“赋值”,这里的String称为实际类型参数(actual type parameter)。

实际类型参数用来为形式类型参数赋值,把 ArrayList<T> 由泛化通用的模板变为特定类型的类。可以把泛型理解为:变量是对数据的抽取,泛型是对变量类型的抽取,抽取成类型参数,抽象层次更高。

举个例子,当你看到同事A写了以下代码:

// 获取教师列表
public List<User> listTeachers() {
    return jdbcTemplate.execute("select * from t_user where user_type=1");
}

// 获取学生列表
public List<User> listStudents() {
    return jdbcTemplate.execute("select * from t_user where user_type=2");
}

你肯定会下意识地建议他:哦,我的天哪,你应该把userType提取为方法参数:

public List<User> listUser(Integer userType) {
    return jdbcTemplate.execute("select * from t_user where user_type=" + userType);
}

从某种程度来说,把SQL中的user_type=1、user_type=2提升为方法入参,就是为了通用性,解决了硬编码(hard code)问题。但在绝大部分初学者的认知里,对于:

  • 1
  • 2
  • “a”
  • “b”

他们往往只能达到以下层次:

  • private Integer value
  • private String value

对于Java这种语言来说,一个变量其实应该至少包含两部分(访问权限暂不讨论):

  • 变量类型
  • 变量值

大部分人只能想到抽取变量,无法达到抽取变量类型的层次。那怎么才能达到抽取变量类型的层次呢?或者说,什么场景下需要抽取变量类型呢?

假设你有一天你发现同事A又在写bug:

public final class MapUtil {
    // 私有构造
    private MapUtil() { }

    // 把userList转为userMap
    public static Map<Long, User> listToMap(List<User> list) {
        if (CollectionUtils.isEmpty(list)) {
            return Collections.emptyMap();
        }
        Map<Long, User> userMap = Maps.newHashMap();
        for (User user : list) {
            userMap.put(user.getId, user);
        }
        return userMap;
    }

    // 把departmentList转为departmentMap
    public static Map<Long, Department> listToMap(List<Department> list) {
        if (CollectionUtils.isEmpty(list)) {
            return Collections.emptyMap();
        }
        Map<Long, Department> departmentMap = Maps.newHashMap();
        for (Department department : list) {
            departmentMap.put(department.getId, department);
        }
        return departmentMap;
    }
}

你看到上面的代码,又开始阴阳怪气地说:哦,我的天哪,你应该…

“闭上你的嘴,我TMD知道要用泛型!”,同事A愤怒地骂道。只见他在你面前飞快地重构MapUtil:

public final class MapUtil {
    private MapUtil() { }

    public static <V, K> Map<K, V> listToMap(List<V> list, Function<V, K> keyExtractor) {
        if (CollectionUtils.isEmpty(list)) {
            return Collections.emptyMap();
        }
        Map<K, V> res = Maps.newHashMap();
        for (V v : list) {
            K k = keyExtractor.apply(v);
            if (k == null) {
                continue;
            }
            res.put(k, v);
        }
        return res;
    }
}

重构后的代码,和原先的两个方法在结构上几乎一模一样(忽略keyExractor这个函数式接口),只是变量类型换成了类型参数,即“对变量类型进行抽取”(所以在泛型里,List<T>中的T叫类型参数),而代码也更加通用了。

把变量类型抽取成类型参数T构造出模板代码,再通过实际类型参数赋值(比如ArrayList<T>变成ArrayList<User>),把类型特定化,最后配合编译器在编译期对相关操作的变量类型进行约束,这就是泛型。

抽取变量,我们早就习以为常,但抽取变量类型,却从未听说。这也是初学者觉得泛型抽象的根本原因

泛型是对引用类型的抽取,基本类型是无法抽取的

ArrayList与泛型

泛型是实现模板代码的一种手段

文章中经过一次次演化,我们的ArrayList最终变成了这样:

public class ArrayList<T> {
    private T[] array; // 我们以为ArrayList<T>内部会有个T[] array
    private int size;
    public void add(T e) {...}
    public void remove(int index) {...}
    public T get(int index) {...}
}

但泛型数组其实是非常特殊的,Java并不能直接实现泛型数组。ArrayList内部实际上仍然沿用了之前的Object[]。

那么,ArrayList为什么还能进行类型约束和自动类型转换呢?

下:泛型实现机制

要回答这个问题,我们必须了解Java的两个阶段:编译期、运行期。你可以理解为Java代码运行有4个要素:

  • 源代码
  • 编译器
  • 字节码
  • 虚拟机

也就是说,Java有两台很重要的机器,编译器和虚拟机。

在代码编写阶段,我们确实引入了泛型对变量类型进行泛化抽取,让类型是不特定的(不特定的即通用的),从而创造了通用的代码模板,比如ArrayList<T>

public class ArrayList<T> {
    private T[] array;
    private int size;
    public void add(T e) {...}
    public void remove(int index) {...}
    public T get(int index) {...}
}

模板定好后,如果我们希望这个ArrayList只处理String类型,就传入类型参数,把T“赋值为”String,比如ArrayList<String>,此时你可以理解为代码变成了这样:

public class ArrayList<String> {
    private String[] array;
    private int size;
    public void add(String e) {...}
    public void remove(int index) {...}
    public String get(int index) {...}
}

所以add(1)会编译报错。

但事实真的如此吗?

我们必须去了解泛型的底层机制。

泛型擦除与自动类型转换

我们来研究以下代码:

public class GenericDemo {

    public static void main(String[] args) {
        UserDao userDao = new UserDao();
        User user = userDao.get(new User());
        List<User> list = userDao.getList(new User());
    }

}

class BaseDao<T> {

    public T get(T t){
        return t;
    }

    public List<T> getList(T t){
        return new ArrayList<>();
    }
}


class UserDao extends BaseDao<User> {

}

class User{

}

编译得到字节码:

通过反编译工具,反编译字节码:

public class GenericDemo {
	// 编译器会为我们自动加上无参构造器
    public GenericDemo() {}

    public static void main(String args[]) {
        UserDao userDao = new UserDao();
        /**
         * 1.原先代码是 User user = userDao.get(new User());
         *   编译器加上了(User),做了类型强转
         */
        User user = (User)userDao.get(new User());
        /**
         * 2.List<User>的泛型被擦除了,只剩下List
         */
        java.util.List list = userDao.getList(new User());
    }
}

class BaseDao {

    BaseDao() {}
    
	// 编译器编译后的字节码中,其实是没有泛型的,泛型T底层其实是Object
    public Object get(Object t) {
        return t;
    }

    public List getList(Object t) {
        return new ArrayList();
    }
}

// BaseDao<User>泛型也没了
class UserDao extends BaseDao {
    UserDao(){}
}

class User {
    User() {}
}

反编译后我们很容易发现,其实所谓的泛型T在编译后就会消失,底层其实还是Object。既然泛型底层用Object接收,那么:

  • 对于ArrayList<String>,为什么add(Integer i)会编译报错?
  • 对于ArrayList<String>,list.get(0)为什么不需要强制转型?

因为泛型本身是一种编译时的机制,是Java程序员和编译器之间的协议。

ArrayList<T>是已经编写好的代码模板,底层还是Object[]接收元素,但我们可以通过ArrayList<String>的语法形式,告诉编译器:“我希望你把这个ArrayList看做StringArrayList”。

换句话说,编译器会根据我们指定的实际类型参数(ArrayList<String>中的String),自动地在编译器做好语法约束:

  • ArrayList<String>的add(E e)只能传String类型
  • ArrayList<String>的get(i)返回值一定是String(转换为和泛型相同的类型时编译后自动强转,无需我们操心)

在使用泛型作为参数的方法中,编译器强制要求该方法只能传入该类型的实现类;在使用泛型作为返回值的方法中,编译器会自动的转型

此时我们就可以解释前文中的问题:

练习:定义一个工具类:ListUtil,类中定义一个静态方法addAll,用来向指定集合中添加多个元素。

public class Generic_test {  
    public static void main(String[] args) {  
        ArrayList<Animal> animals = new ArrayList<>();  
        animals.add(new Cat());  
        animals.add(new Dog());  
        ListUtils.addAll(animals,new Cat(),new Dog(),new Object());  
        System.out.println(animals);  
        Animal animal = animals.get(animals.size() - 1);  ////ClassCastException
		System.out.println(animal); 
    }  
}  
class ListUtils{  
    public static void addAll(List list,Object ... objs){  
        for (int i = 0; i < objs.length; i++) {  
            list.add(objs[i]); //编译完成后,add方法的参数被替换为Animal,为什么obj可以赋值给Animal?
        }  
    }  
}

为什么能存入?

因为泛型实际上被擦除了,add(E e) 还是add(Object e) ,并不是我们所认为的add(String) ,在取出的时候,如果这样获取元素:

        Animal animal = animals.get(animals.size() - 1);  ////ClassCastException
		System.out.println(animal); 

出错的原因是,这行代码其实被翻译为:

		Animal animal = (Animal)animals.get(animals.size() - 1);  ////ClassCastException
		System.out.println(animal); 

这样一定会出错的,因为该位置的元素实际上是Object类型,但如果换一种方式:

        Object animal = animals.get(animals.size() - 1); 
		System.out.println(animal); 

反编译:

        Object animal = animals.get(animals.size() - 1); 
		System.out.println(animal); 

没有进行强制类型转换,自然也没有异常了。

基于上面的实验,我们可以得到以下4个结论:

  • 泛型是JDK专门为编译器创造的语法糖,只在编译期,由编译器负责解析,虚拟机不知情
  • 存入:普通类继承泛型类并给变量类型T赋值后,就能强制让编译器帮忙进行类型校验

  • 取出:代码编译时,编译器底层会根据实际类型参数自动进行类型转换,无需程序员在外部手动强转

  • 实际上,编译后的Class文件还是JDK1.5以前的样子,虚拟机看到的仍然是Object

ArrayList本身并没有任何改变,只是引入了外部的力量,这个力量能够对一些行为进行约束。换言之,Java在编译期引入了泛型检测机制,对容器的使用进行了强制约束,但容器本身并没有发生实质性的改变。

如何利用反射绕过编译器对泛型的检查:

public class GenericClassDemo {

    public static void main(String[] args) throws Exception {

        List<String> list = new ArrayList<>();
        list.add("aaa");
        list.add("bbb");
        // 编译器会阻止
        // list.add(333);

        // 但泛型约束只存在于编译期,底层仍是Object,所以运行期可以往List存入任何类型的元素
        Method addMethod = list.getClass().getDeclaredMethod("add", Object.class);
        addMethod.invoke(list, 333);

        // 打印输出观察是否成功存入Integer(注意用Object接收)
        for (Object obj : list) {
            System.out.println(obj);
        }
    }
}

泛型与多态

经过上面的介绍,大家开始慢慢觉得泛型只和编译器有关,但实际上泛型的成功离不开多态。

上一篇我们已经解释了为什么需要代码模板(通用),现在我们来聊聊为什么能实现代码模板。

代码模板的定义是,整个类框架都搭建好了,只是不确定操作什么类型的对象(变量)。但它必须做到:无论你传什么类型,模板都能接收。

泛型固然强悍,既能约束入参,又能对返回值进行自动转换。但大家有没有想过,对于编译器的“智能转换”,其实是需要多态支持的。如果Java本身不支持多态,那么即使语法层面做的再好,无法利用多态接收和强转都是白搭。

所以代码模板的本质就是:用Object接收一切对象,用泛型+编译器限定特定对象,用多态支持类型强转。

大家拷贝下方代码本地运行一下:

/**
 * @author qiyu
 * @date 2020-09-09 00:02
 */
public class ObjectArray {
    public static void main(String[] args) {
        // objects其实就是对应ArrayList内部的Object[]
        Object[] objects = new Object[4];
        
        // 引入泛型后的两个作用:

        // 1.入参约束:如果是ArrayList,那么入参约束会在一开始就确定,而下面的objects就惨多了,Integer/String都可以随意出入
        objects[0] = 1;
        objects[1] = 2;
        objects[2] = "3";
        objects[3] = "4";

        // 2.自动类型转换:如果是ArrayList,由于入参已经被约束,那么返回值类型也随之确定,编译器会帮我们自动转换,无需显式地手动强转
        Integer zero = (Integer) objects[0];
        Integer one = (Integer) objects[1];
        String two = (String) objects[2];
        String three = (String) objects[3];

        System.out.println(zero + " " + one + " " + two + " " + three);

    }
}

上面的代码是对泛型底层运作的模拟。

当泛型完成了“编译时检查”和“编译时自动类型转换”的作用后,底层还是要多态来支持。

你可以理解为泛型有以下作用:

抽取代码模板:代码复用并且可以通过指定类型参数与编译器达成约定

类型校验:编译时阻止不匹配元素进入Object[]

类型强转:根据泛型自动强转(多态,向下转型)

但实际运行时,Object[]接收特定类型的元素,体现了多态,取出Object[]元素并强转,也体现了多态。

如果一段代码注定要报错,那么应该尽量提前到编译期,而且编译器自动转型比手动转型安全得多。

所以,泛型只是程序员和编译器的约定,程序员告诉编译器,我假定这个List只能存String,你帮我盯着点。对于存入的方法,如果不小心放错类型,就编译报错提醒我。对于取出的方法,编译时你根据我给的实际类型参数自动帮我类型转换。

泛型:其实一切都是确定的

对于初学者来说,他们惧怕泛型是因为泛型给人一种不确定的感觉。我们一起再来看看文章开头的问题:

但是在Java中,只有多态是不确定的。

泛型真的把对象类型弄成了变量吗

并没有,通过反编译大家也看到了,其实根本没有所谓的泛型类型T,底层还是Object,所以当我们new一个ArrayList时,JVM根本不会傻傻等着T被确定。T作为参数类型,只作用于编译阶段,用来限制存入和强转取出,JVM是感知不到的,它不关心这个对象将来是ArrayList<Integer>还是ArrayList<String>,仍然还是按JDK1.4以前的做法,底层准备好Object[],以便利用多态特性接收任意类型的对象。

更何况JVM实际new对象是在运行期,编译期的小把戏和它有什么关系?

所以对于泛型类本身来说,它的类型是确定的,就是Object或Object[]数组。

只是在用到泛型的地方,编译器会帮你进行存取的限制

在编译之后,类型参数T就是确定的

对象类型不确定导致JVM无法创建对象?

如果你指的是泛型类内部的对象类型,上面已经解释了,它的类型是确定的,就是Object或Object[]数组。

如果你指的是存入的元素类型,这个就更荒谬了:

List<User> list = new ArrayList<>();

list.add(new User());

我就是踏踏实实new了一个User对象,怎么会是不确定的呢?

所以泛型有什么是不确定的吗?没有。

实在要说的话,泛型的不确定性在于程序员要求编译器检查的类型是不确定的:

  • ArrayList<Integer>:嘿,编译器,帮我把元素限制为Integer类型
  • ArrayList<String>:嘿,编译器,帮我把元素限制为String类型

大家可以暂时把Java的运行环境理解为一颗双层巧克力球,第一层是编译器,第二层是JVM,泛型可以暂时简单理解为一种约束性过滤,但JVM本身在JDK1.5前后是没有太大区别。

泛型是对变量类型的抽取,从而让变量类型变成一种参数(type parameter),最终得到通用的代码模板,但这种所谓的类型不确定,只是为了方便套用各种对象类型进行语法校验,都是编译期的。

而编译期的不确定并不影响运行期对象的创建,因为容器的对象类型始终是Object,元素的类型是用户自己指定的,比如new User(),也是确定的。

一点补充

Oracle官方文档解释了为什么要用泛型,但没解释为什么不支持基本类型:

Why Use Generics?

JDK1.0开始就有基本数据类型和引用类型,直到JDK1.5才引入泛型。而引用类型存在多态,基本数据类型没有多态,因为多态是面向对象的特征。

多态极大地扩展了Java的可玩性,但也有一些弊端。还是以ArrayList为例:

ArrayList是JDK1.2出来的,那会儿还没有泛型,而ArrayList想什么都能存,于是内部用的是Object[]。一个Object[]存了具体类型的元素,本身就构成多态,那么取出时就会面临类型强转的问题(不转就用不了实际类型的很多方法)。

存进去时Object obj = new User(),取出来没记住,转成(Cat)obj了,就强转异常了,于是JDK1.5引入了泛型,在编译期进行约束并帮我们自动强转。

可以说,本身泛型的引入就是为了解决引用类型强转易出错的问题,也就自然不会去考虑基本类型

当然,网上也有这样解释的,说JDK1.5不仅引入泛型,还同时发布自动拆装箱特性,所以int完全可以用Integer代替,也就无需支持int。

大家猜猜Integer、Long这些包装类啥时候出来的?是不是和我一样以为是JDK1.5出来的?

其实即使本身JDK1.5没有引入自动拆装箱,用Integer这些包装类也能勉强糊弄事,手动把基本类型包装后丢进去就好了。

但是大家有没有想过,JDK1.5发布泛型的同时为什么还发布了自动拆装箱特性?虽然真实原因已经无法考究,但我猜测自动拆装箱引入的目的有两个:

  • 简化代码
  • 从某种意义上让泛型支持基本类型

泛型是依赖 编译器+泛型擦除 实现的,它底层还是用Object去接收各类数据。即使编译器在语法上让ArrayList<int>过去了,泛型擦除后int可就要被Object接收了。

所以问题就变成了,Java能不能做到

Object obj = 666;

很显然,不能。 引入了自动拆装箱后还真能!

所以JDK1.5以后,只要你敢把基本类型数据赋值给引用类型,JDK就毫不留情地帮你转成包装类,到头来还是引用类型。

从这个层面来讲,JDK1.5以后基本类型也“变成了”引用类型(基本运算除外),泛型写成ArrayList<int>还是ArrayList<Integer>已经没有什么差别,甚至从语义上来讲ArrayList<Integer>似乎ArrayList<int>更自洽,坚持了“泛型是对引用类型变量的抽取”这一信条。

我个人观点是,Java已经尽自己最大的努力让泛型支持基本类型了。只不过它不是从语法上支持,而是从功能上支持。拒绝ArrayList<int>保证语义自洽的同时,通过list.add(1)配合自动拆装箱新特性,从功能上实现对基本类型的支持 。

但归根结底,Java泛型之所以无法支持基本类型,还是因为存在泛型擦除,底层仍是Object,而基本类型无法直接赋值给Object类型,导致JDK只能用自动拆装箱特性来弥补,而自动拆装箱会带来性能损耗。

只能说JDK也是不得已而为之吧。

Java 不能实现真正泛型的原因是什么

泛型边界

之前讲述了泛型是什么以及有什么用:

  • 作用于编译期,由编译器解析,是一种兼具类型约束和自动转型的代码模板
  • 存入:约束存入的元素类型,将可能的类型错误提前到编译期
  • 取出:编译自动转型,消除手动强转,极大降低ClassCastException的风险

泛型只是程序员和编译器的约定。

我们可以通过泛型告诉编译器自己的意图

我现在假定这个List只能存String,你帮我盯着点,后面如果不小心放错类型,在编译期报错提醒我。

当然,要想编译器帮我们约束类型,就必须按人家的规矩办事。就好比Spring明明告诉你默认读取resources/application.yml,你非要把配置文件命名为resources/config.yml当然就报错啦。

而泛型也有一套自己的规则,我们必须遵守这些规则才能让编译器按我们的意愿做出约束。

今天我们来学习泛型通配符。

在讲述通配符的语法规则时,我会尽量给出自己的理解,让大家更容易接受它们。另外需要说明的是,在泛型相关的文章里我们总是以List元素存入、取出举例子,是因为容器类是我们接触最多的,这样更好理解。实际上对于泛型类、泛型方法都是适用的,并不一定要是容器类。

简单泛型

JDK1.5以后,我们全面跨入泛型时代。

假设现在有一个需求:设计一个print方法打印任意类型的List。

你想显摆一下刚学的泛型,于是这样设计:

public class GenericClassDemo {

    public static void main(String[] args) {
        List<Integer> integerList = new ArrayList<>();
        print(integerList);
    }

    public static void print(List<Integer> list) {
		// 打印...
    }
}

需求是打印任意类型的List。目前的print()只能接收List<Integer>,你传List<String>会报错:

你想了想,Object是所有对象的父类,我改成List<Object>吧:

这下连List<Integer>都不行了。这是为什么呢?我们来分析一下原因。

实际编码时,常见的错误写法如下:

// 错误写法1:间接传递(通常发生在方法传参,比如将stringList传给print(List<Object> list))
List<String> stringList = new ArrayList<>();
List<Object> list = stringList;
// 错误写法2:直接赋值
List<Object> list = new ArrayList<String>();

list引用和实际指向的List容器类型必须一致(赋值操作左右两边的类型必须一致)。

JDK推荐的写法:

// 比较啰嗦的写法
List<String> list = new ArrayList<String>();
List<Object> list = new ArrayList<Object>();
// 省略写法,默认左右类型一致
List<String> list = new ArrayList<>();
List<Object> list = new ArrayList<>();

我们在前面已经了解到,泛型底层其实还是Object/Object[],所以上面的几种写法归根到底都是Object[]赋值给Object[],理论上是没有问题的。

那么我们不禁要问:既然底层都支持了,为什么编译器要禁止这种写法呢?

正向思考

首先,Object和String之间确实有继承关系,但List<Object>List<String>没有,不能用多态的思维考虑这个问题(List和ArrayList才是继承/实现关系)

List<Object> list = new ArrayList<String>();

其次,讨论泛型时,大家应该尽量从语法角度分析。

左边List<Object>的意思是希望编译器帮它约束存入的元素类型为Object,而右边new ArrayList<String>()则希望约束存入的类型为String,此时就会出现两个约束标准,而它们却是对同一个List的约束,是自相矛盾的。

反向思考

如果上面的论述还是缺乏说服力,那么我们干脆假设List<Object> list = new ArrayList<String>()是合法的

先来看看数组是怎么处理类似问题的:

数组底层和泛型不同,泛型底层都是Object[]/Object[],而数组是真的分别创建了Object[]和String[],而且允许String[]赋值给Object[]。但这是它的弱点,给了异常可趁之机:

public static void main(String[] args) throws Exception {
    // 直接往String[]存Integer会编译错误
    String[] strings = new String[3];
    strings[0] = "a";
    strings[1] = "b";
    strings[2] = 100; // COMPILE ERROR!

    // 但数组允许String[]赋值给Object[]
    Object[] objects = strings;
    // 这样就能通过编译了,但运行期会抛异常:ArrayStoreException
    objects[2] = 100;
}

数组允许String[]赋值给Object[],但却把错误被拖到了运行期,不容易定位。

同样的,如果泛型也允许这样的语法,那就和数组没区别了:

  • 首先,ls.add(new Object())成功了,那就意味着之前List<String>所做的约束都白费了,因为StringList中混入了别的类型
  • 其次,编译器仍会按String自动转型,会发生ClassCastException

这么看来,泛型强制要求左右两边类型参数一致真是明智的举措,直接把错误扼杀在编译期。

泛型的指向和存取

在之前介绍泛型时,我们观察的维度只有存入和取出,实际上泛型还有一个很重要的约束:指向。为什么之前不提这个概念呢?因为之前接触的泛型都太简单了,比如List<String>只能指向List<String>,也就是泛型左右两边类型必须一致,没什么好讲的。

之前接触的是简单泛型,比如List<String> list = new ArrayList<String>(),左右的泛型类型必须一致

但是,观察如下代码:

List<Number> 不只可以存入Number类型的数据,可以存入任何Number的子类,对于List<Number> 来说,反正取出时会向上转型为Number类型,很安全。

至此,我们完善了泛型最重要的两个概念:指向、存取。

对于简单泛型而言:

  • List<Number>指向:只能指向List<Number>左右两边泛型必须一致(所以简单泛型解决不了
  • print(List<???> list)的通用性问题)
  • List<Number>存入:可以存入Integer/Long/BigDecimal…等Number子类元素
  • List<Number>取出:自动按Number转(存在多态,不会报错)

后面学习通配符时,也请大家时刻保持清醒,多想想当前list可以指向什么类型的List,可以存取什么类型的元素。如果你觉得上面的推演太绕了,那么就记住:简单泛型的左右两边类型必须一致。

通配符

既然泛型强制要求左右两边类型参数必须一致,是否意味着永远无法封装一个方法打印任意类型的List?如何既能享受泛型的约束(防止出错),又能保留一定的通用性呢?

使用通配符就可以完成,我把List<T>BaseDao<T>这样的称为简单泛型,把extends、super、?称为通配符。而简单泛型和通配符组合后又可以得到更为复杂的泛型,比如? extends T、? super T、?等。简而言之,通配符可以用来调节泛型的指向和存取之间的矛盾。

比如,有时我们需要list能指向不同类型的List(希望print()方法能接收更多类型的List)、有时我们又希望泛型能约束元素的存入和取出。但指向和存取往往不可兼得,具体要选用哪种泛型,需要根据实际情况做决定。

extends:上边界通配符

extends是上边界通配符,所以对于 List<? extends Number>,元素类型的天花板就是Number,右边List的元素类型只能比Number“低”。

List<? extends Number> list = new ArrayList<Integer>();

换句话说,List<? extends Number>只能指向List<Integer>List<Long>等子类型List,不能指向List<Object>List<String>

记忆方法: List<? extends Number> list = ...,把?看做右边List的元素(暂不确定,用?代替),? extends Number表示右边元素必须是Number的子类。

你可能会问:

之前简单泛型List<Object>不能指向List<String>,怎么到了extends这就可以了。这不扯淡吗?

其实换个角度就是,Java规定简单泛型左右类型必须一致,但有些情况又要考虑通用性,所以又搞出了extends,允许List<? extends Number>指向子类型List。

之前我们假设过,如果允许简单泛型指向指向子类型List,那么存取会出问题:

现在extends通配符放宽了指向限制(List<? extends Human>允许指向List<Chinese>),是否意味着extends通配符也会发生强转错误呢?

List<String> ls = new ArrayList<>();  
List< ? extends Object> lo = ls;  
lo.add(new Object()); //出错
//java: 不兼容的类型: java.lang.Object无法转换为capture#1, 共 ? extends java.lang.Object
ls.get(0);

jdk直接强制禁止进行存储

public static void main(String[] args) {

    List<Integer> integerList = new ArrayList<>();
    integerList.add(1);

    List<Long> longList = new ArrayList<>();
    longList.add(1L);

    List<? extends Number> numberList = new ArrayList<>();
    numberList = 随机指向integerList或longList等子类型List;
    numberList.add(1);  // 由于无法确定numberList指向哪个List,所以干脆禁止add(万一指向integerList,那么add(1L)就不合适了,取出时可能转型错误)
}

再举一个例子:

对于取出的情况:

public static void main(String[] args) {

    List<Integer> integerList = new ArrayList<>();
    integerList.add(1);

    List<Long> longList = new ArrayList<>();
    longList.add(1L);

    List<? extends Number> numberList = integerList; // 不管numberList指向integerList还是longList
    Number number = numberList.get(0);  // 取出来的元素都可以转Number,因为Long/Integer都是它子类
}

看到这,我们应该有所体会:对于泛型而言,指向和存取是两个不同的方向,很难同时兼顾。要么指向放宽,存取收紧;要么指向收紧,存取放宽。

extends小结:

  • List<? extends Number>指向:Java允许extends指向子类型List,比如List<? extends Number>允许指向List<Integer>
  • List<? extends Number>存入:禁止存入(防止出错)
  • List<? extends Number>取出:由于指向的都是子类型List,所以按Number转肯定是正确的

相比简单泛型,extends虽然能大大提高指向的通用性,但为了防止出错,不得不禁止存入元素,也算是一种取舍。换句话说,print(List<? extends Number> list)对于传入的list只能做读操作,不能做写操作。

super:下边界通配符

super是下边界通配符,所以对于List<? super Integer>,元素类型的地板就是Integer,右边List的元素类型只能比Integer“高”。换句话说,List<? super Integer>只能指向List<Number>List<Object>等父类型List。

记忆方法: List<? super Integer> list = ...,把?看做右边List的元素(暂不确定,用?代替),
? super Integer表示右边元素必须是Integer的父类。

super的特点是:

  • List<? super Integer>指向:只能指向父类型List,比如List<Number>List<Object>
  • List<? super Integer>存入:只能存Integer及其子类型元素
  • List<? super Integer>取出:只能转Object

至此,我们发现Java同时满足了:

  • extends:指向子类型List
  • 简单泛型T:指向同类型List
  • super:指向父类型List

说完指向问题,我们再来探讨一下存取问题。思路还是一样,既然Java允许List<? super Integer>指向List<Number>等父类型,那么如何防止存取出错呢?

假设存在class Human implement Swimming, Speaking,那么Swimming和Speaking都是Human的父类/父接口。由于List<? super Human>可以指向父类型List,要么指向SwimmingList,要么指向SpeakingList。

public static void main(String[] args) {
    List<Swimming> swimmingList = new ArrayList<>();
    // 假设加入了很多实现了Swimming接口的元素,比如Dolphin(海豚)
    // swimmingList.add(dolphin)...

    List<Speaking> speakingList = new ArrayList<>();
    // 假设加入了很多实现了Speaking接口的元素,比如Parrot(鹦鹉)
    // speakingList.add(parrot)...

    List<? super Human> humanList = swimmingList / speakingList; // 指向随机的List
    
    humanList.add(...) // 是否应该允许存入 Parrot(鹦鹉)?

}

此时对于List<? super Human>,是否应该允许加入 Parrot(鹦鹉)呢?答案是最好不要。因为humanList的指向是不确定的,如果刚好指向的是swimmingList,那么list.add(parrot)显然是不合适的。

只有存入Human及其子类才是安全的:

介绍完super的存入,最后聊聊super的取出。由于List<? super Human>可以指向任意Human父类型的List,可能是SwimmingList,也可能是SpeakingList。这意味取出的元素可能是Swimming,也可能是Speaking,是不确定的,所以用Swimming或Speaking都不太合适。

那能不能强转为Human呢?答案是不行。假设humanList指向的是swimmingList,而swimmingList里存的是Shark、Dolphin、Human,此时list.get(0)得到的是 Shark implements Swimming,强转为Human显然不合适。

?:无界通配符

允许指向任何类:

  • 由于指向的List不确定,并且这些List没有共同的子类,所以找不到一种类型的元素,能保证add()时百分百不出错,所以禁止存入。
  • 由于指向的List不确定,并且这些List没有共同的父类(除了Object),所以只能用Object接收。
通配符的使用场景

泛型本身比较复杂,能把简单的T用熟练的已经不多,更别说用上通配符了。但从语法本身来说,通配符就是为了让赋值更具通用性。原先泛型赋值只能是同类型之间赋值,不利于抽取通用方法。而使用通配符后,就可以在一定程度上开放赋值限制。

?是开放限度最大的,可指向任意类型List,但在对List的方法调用上也是限制最大的,具体表现在:

  • 入参和泛型相关的都不能使用(禁止存入)
  • 返回值和泛型相关的都只能用Object接收(只能强转为Object)

extends和super指向性各砍了一半,分别指向子类型List和父类型List,但方法使用上又相对开放了一部分:

  • extends不允许存入,但取出时类型稍微精确些,可以往边界类型转
  • super允许存入子类型元素,但取出时只能转为Object

所以如果要用到通配符,需要结合业务考虑,如果你只是希望造一个方法,接收任意类型的List,且方法内不调用List的特定方法,那就用?。而对于extends和super的取舍,《Effective Java》提出了所谓的:PECS(Producer Extends Consumer Super)

  • 频繁往外读取内容的(向外提供内容,所以是Producer),适合用<? extends T>:extends返回值稍微精确些,对调用者友好
  • 经常往里插入的(消耗数据,所以是Consumer),适合用<? super T>:super允许存入子类型元素

给大家举一个JDK对通配符的使用案例:

ArrayList中定义了一个addAll(Collection<? extends E> c)方法,我单独把这个方法拿出来:

class ArrayList<E> extends ... {
    
    ...

    public boolean addAll(Collection<? extends E> c) {
        Object[] a = c.toArray();
        int numNew = a.length;
        ensureCapacityInternal(size + numNew);  // Increments modCount
        System.arraycopy(a, 0, elementData, size, numNew);
        size += numNew;
        return numNew != 0;
    }
}

以Person为例,假设是List<Person> list = new ArrayList<>(),那么这个方法就变成了:

public boolean addAll(Collection<? extends Person> c) {
    Object[] a = c.toArray();
    int numNew = a.length;
    ensureCapacityInternal(size + numNew);  // Increments modCount
    System.arraycopy(a, 0, elementData, size, numNew);
    size += numNew;
    return numNew != 0;
}

此时,addAll()只能接收Person集合或者它的Person子类的集合,比如Student extends Person:

List<Person> personList = new ArrayList<>();

List<Student> studentList = new ArrayList();
personList.addAll(studentList)

为什么会选择extends呢?还是PECS原则,因为allAll()很显然是消费者场景,我更关心对参数的具体操作,而不怎么关心返回值(就是boolean提示操作成功与否)。这也是我日常使用通配符时的一个思路,PECS确实很实用。

消费者场景下,就需要参数c去生产数据,c生产的数据都是E类型的,就可以供addAll消费

最后,很多人会以为 ? 等同于T,其实两者是有区别的。我们本质还是通过给T“赋值”来确定类型,只不过此时赋值给T的不再是某个具体的类型,而是某个“匹配规则”,帮助编译器确定向上、向下可以指向的List类型范围以及存取的元素类型限定。

总结

当你使用简单泛型时,首要考虑你想把元素规定为何种类型,顺便考虑子类型的存入是否会有影响(一般不会)。而如果要使用通配符,应该先考虑接收的范围,再考虑存取操作如何取舍(PECS原则)。

个人愚见是,通配符的出发点本来是为了解决指向问题,但开放指向后为了避免ClassCastException,不得已又对存取加了限制,实际开发时要灵活利用边界限制并结合实际需求选择合适的泛型。

泛型类

我们来回顾一下泛型类是怎么出现的。

话说JDK1.5以前,还没引入泛型时ArrayList大概是这样的:

public class ArrayList {
    private Object[] array;
    private int size;
    public void add(Object e) {...}
    public void remove(int index) {...}
    public Object get(int index) {...}
}

它有这个最大的问题是:无法限制传入的元素类型、取出时又容易发生ClassCastException。最直观的做法是使用期望类型的元素数组,比如:

public class StringArrayList {
    // 因为这种ArrayList只存String,所以不需要用Object[]兼容所有类型,只要String[]即可
    private String[] array;
    private int size;
    public void add(String e) {...}
    public void remove(int index) {...}
    public String get(int index) {...}
}

但不可能为所有类型都编写专门的XxxArrayList,于是JDK就推出了泛型:抽取一种类模板,方法、变量都写好了,但变量的类型抽取成“形式类型参数”:

再配合编译器做约束:比如ArrayList<User>表示告诉编译器,帮我盯着点,我只存取User类型的元素。

简而言之,泛型类的出现就是为了“通用”,而且还能在编译期约束元素的类型。

泛型类的方法并不是泛型方法

以前使用Hibernate时,基本都会抽取BaseDao:

class BaseDao<T> {
    public T get(T t) {
        
    }
    
    public boolean save(T t) {
        
    }
}

方法里的T和类上的T是同一个,也正因为多个方法操作的POJO类型一致,所以才会被抽取到类上统一声明。

这个T具体是什么类型,或者说里面的get()、save()到底操作什么类型的POJO,取决于BaseDao到底被谁继承。比如:

class UserDao extends BaseDao<User> {
}

那么UserDao从BaseDao继承过来的get()、save()其实已经被约束为“只能操作User类型的元素”。

但要清楚,上面的get()、save()只是“泛型类的方法”,而不是所谓的“泛型方法”。

泛型方法

泛型类上的<T>其实是一种“声明”,如果把类型参数T也看做一种特殊的变量,那么<T>就是变量声明(不是我们一般概念中的变量声明,一个作用于运行期,一个作用于编译期)。

由于泛型类上已经声明了T,所以类中的字段、方法都可以自由使用T。但是当T被“赋值”为某种类型后,就会在编译器的帮助下形成一种强制类型约束,此时这个通用的代码模板也就不再通用了。因而你会发现,对于编译器而言UserDao extends BaseDao<User>里的方法只能操作UserCarDao extends BaseDao<Car>里的方法只能操作Car。

这好吗?一般来说,这很好,因为一个Dao操作的肯定是同一张表同一个对象,限定为某个类型反而能避免出错。但大家想想,如果不是BaseDao,而是一个工具类呢?BaseUtils提供通用的操作,
XxUtils extends BaseUtils<Xx>固然没问题,但如果XxUtils希望提供一个方法处理Yy怎么办?如果还想处理Zz呢?

比如希望提供一个工具方法,接收一个List类型参数,将List反转后输出。

问题的症结并不在于后期想要处理什么类型或者有多少种类型,而是T被过早确定了,从而早早地放弃了“可变性”。

因为对于泛型类的T来说,当UserDao继承BaseDao或者XxUtils继承BaseUtils时,T就被确定为User和Xx了,且已经拒绝了其他可能性,也就无法复用于其他类型。

那么,有没有办法延迟T的确定呢?

有,但泛型类的T已经没办法了,需要另辟蹊径,引入泛型方法。

和泛型类一样,泛型方法使用类型参数前也需要“声明”(<T>要放在返回值的前面):

public class DemoForGenericMethod {

    public static void main(String[] args) {
        List<String> list = new ArrayList<>(Arrays.asList("1", "2", "3", "4", "5"));
        List<String> stringList = reverseList(list);
    }

    /**
     * 一个普通类,也可以定义泛型方法(比如静态泛型方法)
     * @param list
     * @param <T>
     * @return
     */
    public static <T> List<T> reverseList(List<T> list) {
        List<T> newList = new ArrayList<>();
        for (int i = list.size() - 1; i >= 0; i--) {
            newList.add(list.get(i));
        }
        return newList;
    }

}

如果不使用泛型,得到的数组get元素都是Object,如果指定了固定的泛型类型,就只能对一种类型进行操作,所以要定义为T

泛型方法中T的确定时机是使用方法时。而泛型类的确定是使用泛型类创建对象时。

泛型方法和泛型类没有必然联系,你可以理解为这两个东西可以各自使用,也可以硬把它们凑在一块,不冲突:

class BaseDao<T> {
    // 泛型类的方法
    public T get(T t) {

    }

    /**
     * 泛型方法,无返回值,所以是void。<E>出现在返回值前,表示声明E变量
     * @param e
     * @param <E>
     */
    public <E> void methodWithoutReturn(E e) {
        
    }

    /**
     * 泛型方法,有返回值。入参和返回值都是V。注意,即使这个方法也用E,也和上面的E不是同一个
     * @param v
     * @param <V>
     * @return
     */
    public <V> V methodWithReturn(V v) {
        return v;
    }
}
泛型类和泛型方法的使用场景

简单来说,一个类拥有多种同类型方法时使用泛型类,一个方法处理多种类型时使用泛型方法。

比如,在做数据访问层的时候,对一种类型的实体有一系列统一的访问方法,此时采用泛型类会比较合适,而对于
接口的统一结果封装则采用泛型方法比较合适,比如:

@Data
@NoArgsConstructor
public class Result<T> implements Serializable {

    private Integer code;
    private String message;
    private T data;

    private Result(Integer code, String message, T data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }

    private Result(Integer code, String message) {
        this.code = code;
        this.message = message;
        this.data = null;
    }

    /**
     * 带数据成功返回
     * 请注意,虽然泛型方法也用了T,但和Result<T>里的没关系
     * 这里之所以这么写,是因为实际开发时你们会见到很多这种“迷惑性”的写法,放出来作为“反例”,推荐最好使用其他符号,比如K
     *
     * @param data
     * @param <T>
     * @return
     */
    public static <T> Result<T> success(T data) {
        return new Result<>(ExceptionCodeEnum.SUCCESS.getCode(), ExceptionCodeEnum.SUCCESS.getDesc(), data);
    }
}

又比如封装工具类,也非常常用:

public class ConvertUtil {

    private ConvertUtil() {
    }

    /**
     * 将List转为Map
     *
     * @param list         原数据
     * @param keyExtractor Key的抽取规则
     * @param <K>          Key
     * @param <V>          Value
     * @return
     */
    public static <K, V> Map<K, V> listToMap(List<V> list, Function<V, K> keyExtractor) {
        if (list == null || list.isEmpty()) {
            return new HashMap<>();
        }
        Map<K, V> map = new HashMap<>(list.size());
        for (V element : list) {
            K key = keyExtractor.apply(element);
            if (key == null) {
                continue;
            }
            map.put(key, element);
        }
        return map;
    }
}

静态方法无法使用泛型类的类型参数,换言之,静态方法如果想要使用泛型,只能是静态泛型方法,此时类型参数是自己声明的。

泛型与类的实例相关联,静态方法与类的实例无关。

Redis分布式锁
public interface RedisService {

    // 省略其他方法...

    /**
     * 从队列取出消息
     *
     * @param queue    自定义队列名称
     * @param timeout  最长阻塞等待时间
     * @param timeUnit 时间单位
     * @return
     */
    Object popQueue(String queue, long timeout, TimeUnit timeUnit);
    
    // 省略其他方法...
}

但如果使用泛型方法:

public interface RedisService {

    // 省略其他方法...

    /**
     * 从队列取出消息
     *
     * @param queue    自定义队列名称
     * @param timeout  最长阻塞等待时间
     * @param timeUnit 时间单位
     * @return
     */
     <T> T popQueue(String queue, long timeout, TimeUnit timeUnit);
    
    // 省略其他方法...
}

就不用强制转换了。

但这并不保险,只是骗过了编译器而已。你会发现你用任意类型接收都是不会编译报错的:

泛型数组

Java并不支持泛型数组,所以对于ArrayList,底层仍旧采用Object[]。但在日常开发中又确实可能遇到需要new T[]的场景。

public class Test {

    public static void main(String[] args) {
        Integer[] array = new Integer[]{2, 4, 5, 9, 7, 1, 1, 6};
        Integer[] copyArray = copy(array);
    }

    private static <E> E[] copy(E[] array) {
        // 创建同类型数组
        E[] arrayTemp = ...
        
        for (int i = 0; i < array.length; i++) {
            arrayTemp[i] = array[i];
        }
        return arrayTemp;
    }
    
}

和new T()一样,new T[]也是不合法的。

要想new T(),要么方法传入Class<T>,要么通过GenericType获取,最终要通过反射创建T对象(总之要明确Class对象)。那么对于T[],如何获取数组的元素类型呢?

JDK另外提供了所谓的ComponentType指代数组的元素类型:

泛型机制对普通对象、容器对象都很好,唯独对数组苛刻了些,以至于我们回想泛型,基本都想不起数组和泛型有啥关系。这不,为了弥补对数组的亏欠,Java特别给数组搞了ComponentType,其他两个可没有哦。

好了,既然知道了数组元素的类型,那就可以创建对应类型的数组咯:

public class Test {

    public static void main(String[] args) {
        Integer[] array = new Integer[]{2, 4, 5, 9, 7, 1, 1, 6};
        Integer[] copyArray = copy(array);
    }

    private static <E> E[] copy(E[] array) {
        Class<?> componentType = array.getClass().getComponentType();
        
        // 创建同类型数组
        E[] arrayTemp = (E[]) Array.newInstance(componentType, array.length);
        
        for (int i = 0; i < array.length; i++) {
            arrayTemp[i] = array[i];
        }

        return arrayTemp;
    }
    
}

newInstance方法:

其中的newArray是native修饰的

当然,实际开发中要想拷贝数组,有很多其他简单的方式,对于带有泛型参数 [[003-数组、排序、查找#Arrays.copyOf(int[] original,int newLength)|Arrays.copyOfRange]] 的方法来说,JDK的实现如下:

可以看到也是通过Array.newInstance()创建数组对象的。

泛型的应用

Lists是Google提供的工具类,返回一个List集合:

public final class Lists {  
    private Lists() {  
    }  
  
    @GwtCompatible(  
        serializable = true  
    )  
    public static <E> ArrayList<E> newArrayList() {  
        return new ArrayList();  
    }
}

此处定义了返回值的泛型,我们可以使用任意泛型进行接收

实际上,我们自定义一个方法:

ArrayList newArrayList(){  
    return new ArrayList();  
}

即便没有声明泛型,我们也可以使用任意泛型进行接收:

List<String> arrayList = newArrayList();

也就是:

public void doSome() {  
	//并不会报错
    List<String> arrayList = newArrayList();  
}  
  
public ArrayList newArrayList() {  
    return new ArrayList();  
}

Lambda表达式

Lambda表达式是JDK1.8的一个新特性,可以取代大部分的匿名内部类操作,以便写出更优雅的代码,尤其是在集合的操作中,可以极大优化代码结构。

在之前的学习中,如果想对List集合进行降序排列,就需要使用匿名内部类来实现,代码比较繁琐:

// 方式一:使用匿名内部类来实现
List<Integer> list = Arrays.asList(3, 6, 1, 7, 2, 5, 4);
Collections.sort(list, new Comparator<Integer>() {
    @Override
    public int compare(Integer o1, Integer o2) {
        return o2 - o1;
    }
});
System.out.println("排序后:" + list);

针对以上对List集合的的“降序”排序操作,除了使用匿名内部类来实现外,还可以使用Lambda表达式来实现,使用Lambda表达式的代码非常优雅,并且还非常的简洁,代码如下:

// 方式二:使用Lambda表达式来实现
List<Integer> list = Arrays.asList(3, 6, 1, 7, 2, 5, 4);
Collections.sort(list, (o1, o2) -> o2 - o1);
System.out.println("排序后:" + list);

引例

// Math.abs()是JavaScript的内置函数
let result = Math.abs(-10)
console.log('result=' + result)

// JavaScript允许将Function赋值给变量。注意,不能带(),否则就是方法调用
let func = Math.abs

// 更神奇的是,func变量拼上()就可以当方法调用了
console.log('result=' + func(-20))

// 其实,在JavaScript中不仅允许方法赋值,还允许直接传递Function
function sum(x, y, func) {
	return func(x) + func(y)
}

result = sum(-10, -20, Math.abs)
console.log('result=' + result)

结果:

result=10
result=20
result=30
sandbox> exited with status 0

这种写法对于Java程序员来说第一感觉是“神奇”,紧接着能感受到JS的“轻巧”。

其实不仅是JS,Python也是如此:

# encoding: utf-8

# abs是Python的内置函数
result = abs(-1)
print(result)

# 定义add()方法,f显然是一个函数
def add(x, y, f):
    return f(x) + f(y)

print(add(-1, -2, abs))

运行结果:

1
3
sandbox> exited with status 0

Python把上面的表达式称为高阶函数:

  • 函数的参数可以是一个/多个函数
  • 函数的返回值也可以是一个函数

在JS、Python等脚本语言的世界里,这种现象似乎很普遍,因为Function是那个世界的一等公民,而对于Java来说对象才是一等公民。比如,有时为了在A对象的方法中调用B对象的某个方法,我们不得不new一个B对象并把它作为参数传入:

class A {
    public void methodA(B b) {
        System.out.println("methodA is called");
        b.methodB();
    }
}

class B {
    public void methodB() {
        System.out.println("methodB is called");
    }
}

在设计模式中,上面这种写法美其名曰:策略模式。

当然,广义上策略模式关注的是可替换的多种实现,狭义上可以理解为传递不同的方法。

幸运的是Oracle紧跟潮流,终于在2014年发布Java8并宣布支持Lambda表达式,弥补了Java函数式编程的遗憾。

出行案例

MyThread(模仿JDK的Thread,最大的区别是最终调用的还是主线程而不是异步线程)

public class MyThread {

    public void run() {
        System.out.println("去12306买了一张票");
        System.out.println("坐火车...");
    }

    public void start() {
        run(); 
    }
}

public class Demo {
    public static void main(String[] args) {
        // new一个MyThread对象,调用start()
        new MyThread().start();
    }
}

上面的代码有个问题:要运行的代码在MyThread的run里面写死了,我只能坐火车,不能坐飞机。 分析:

我们知道,Java是面向对象的,一个事物一般可以最终抽象成 属性+方法。

属性代表你有什么,方法代表你能干什么。

在这个案例中,“坐火车”、“坐飞机”都是一个动作。按上面分析,应该是一个方法。我们要动态地传入一个动作,也就是要动态地传入一个方法。但是我们知道,在Java中只能传递数据(参数),不能传递方法。JS之所以可以传递function,是因为function本身是JS的一种数据类型。

抽取一下上面的观点:

Java只能传递规定的数据类型的数据,比如int型的age, String型的name, 引用类型的person。

对象包含属性和方法,而Java又可以传递person这样的引用类型。那么就完全可以传递一个对象(包含需要的方法),在内部调用对象的方法,这和直接传递方法是异曲同工的。

所以,我打算把“坐火车”、“坐飞机”这些交通策略单独抽取成一个个类,在new MyThread()的时候通过构造函数传递这些类的实例进去。

但同时可以预见,“坐火车”、“坐飞机”等等对象会非常多,MyThread的有参构造的形参类型需要形成多态。于是我写了一个MyRunnable接口,让每个策略类都去实现它(接口多态)。

public interface MyRunnable {
    void run();
}

public class ByTrain implements MyRunnable {
    @Override
    public void run() {
        System.out.println("去12306买了一张票");
        System.out.println("坐火车...");
    }
}

public class ByAir implements MyRunnable {
    @Override
    public void run() {
        System.out.println("在某App上订了飞机票");
        System.out.println("坐飞机...");
    }
}

public class ByAir implements MyRunnable {
    @Override
    public void run() {
        System.out.println("在某App上订了飞机票");
        System.out.println("坐飞机...");
    }
}

改写MyThread,接收不同的出行策略并调用方法:

public class MyThread {

    // 成员变量
    private MyRunnable target;

    public MyThread() {}
    
    // 构造方法,接收外部传递的出行策略
    public MyThread(MyRunnable target) {
        this.target = target;
    }

    // MyThread自己的run(),现在基本不用了
    public void run() {
        System.out.println("去12306买了一张票");
        System.out.println("坐火车...");
    }

    // 如果外部传递了出行策略,就会调用该策略里的run()
    public void start() {
        if (target != null) {
            target.run();
        } else {
            this.run();
        }
    }
}

Demo:

public class Demo {
    public static void main(String[] args) {
        ByTrain byTrain = new ByTrain();
        new MyThread(byTrain).start();

        ByAir byAir = new ByAir();
        new MyThread(byAir).start();
    }
}

尽管使用了策略模式,上面的代码还是有问题。如果后面还有“坐电瓶车”、“骑自行车”等出行方式,那么出行策略类就太多了,会发生所谓的“类爆炸”。我们可以使用匿名类的方式继续改进Demo:

public class Demo {
    public static void main(String[] args) {
        new MyThread(new MyRunnable() {
            @Override
            public void run() {
                System.out.println("不用买票");
                System.out.println("骑电瓶车...");
            }
        }).start();
    }
}

到此为止这大概是JDK8之前我们能想到的最好的解决办法,不仅运用了策略模式,还使用匿名类的方式成功阻止了类的暴增。

但是,程序永远存在可优化的地方。于是,JDK8又帮我们向前迈进了一大步:

public class Demo {
    public static void main(String[] args) {
        new MyThread(() -> {
            System.out.println("不用买票");
            System.out.println("骑电瓶车...");
        }).start();
    }
}

非常简洁。

Java从诞生那天起,就是纯正的面向对象语言。而从1995年诞生至今,已经过去28年。期间程序届又诞生了很多优秀的语言。

长江后浪推前浪,一般来说后续的新语言在创建之初一定会吸取之前语言的教训,尽量规避之前的设计缺陷。所以,单从语言层面上讲,老的语言往往是没有优势的,甚至存在代码冗余,不够优雅等弊病。

Lambda表达式可以看做函数式编程的子集,但函数式编程绝对不是Java首创的,它只是看到别的语言用的挺好,自己也引入了而已。

Lambda表达式的本质

Lambda表达式,其实是一段可传递的代码。

更精确的描述是:Lambda表达式,其实是一段可传递的代码。它的本质是以类的身份,干方法的活。

public class Demo {
    public static void main(String[] args) {
        new MyThread(() -> System.out.println("哈哈哈")).start();
    }
}

分析上面的代码,会发现new MyThread()这个构造方法原本需要传递一个MyRunnable接口的子类对象(匿名类对象)。 但我们反手就扔了一个Lambda表达式进去,它还真吃下去了。说明什么?

说明Lambda表达式在身份上与匿名类对象等价。

但是Lambda表达式这么一串代码扔进去后,实际干活的也就->后的System.out.println(“哈哈哈”)这段代码(可以看做一个方法,因为方法是对代码块的封装)。这又说明了什么?

说明Lambda表达式在作用上与方法等价。

再次回味上面的那句话:Lambda表达式,其实是一段可传递的代码。Lambda本质是以类的身份,干方法的活。

如果我们把方法看做行为,那么Lambda表达式其实就是把行为参数化(方法作为参数传递)。

Lambda表达式格式解析

public class Demo {
    public static void main(String[] args) {
        String str1 = "abc";
        String str2 = "abcd";

        int i = compareString(str1, str2, new Comparator<String>() {
            @Override
            public int compare(String s1, String s2) {
                return s1.length() - s2.length();
            }
        });
    }

    public static int compareString(String str1, String str2, Comparator<String> comparator) {
        return comparator.compare(str1, str2);
    }
}

以上的代码可以使用Lambda表达式优化:

由上文推导出Lambda表达式和匿名类对象等价,我们可以把Lambda表达式赋值给Comparator接口

可以看到,这段代码还有优化的空间;跳过赋值这一步,直接把整个Lambda传给compareString()方法:

还可以进行优化:

最终版本:


方法引用可以单纯地理解为可复用的Lambda(把其他类已经定义的方法作为Lambda),实际开发时依靠IDEA提示改进即可,并不需要我们记忆。

类名、方法名、形参类型、返回值类型、return关键字及方法体的{}都被省略了。

从语法格式来讲,Lambda表达式其实就是对一个方法掐头去尾,只保留形参列表和方法体。可以粗略认为:

Lambda表达式 = 形参列表 + 方法体

对于匿名内部类而言,类名、方法名真的没什么用。只有当一个方法需要被使用多次时,我们才需要为它命名,以便其他程序通过方法名重复调用。而匿名内部类的性质就是仅调用一次,所以名字对它来说是可有可无的。至于返回值与形参类型,Lambda都可以通过上下文推断,所以也可以省略:

// 基于上下文,很容易推算出返回值类型就是String。形参同理。
public someType get() {
    return "Hello World!";   
}

至于方法体的{}能不能省略,本质和if是否需要{}一样:

if(true) 
    System.out.println("可以省略{}"); 

if(true) {
    int i = 1;
    System.out.println("不可以省略{}"); 
}

至于是否需要return,看方法本身是否需要返回值。回顾上面的推演过程,能省略的都省略后就成了现在的Lambda表达式。

那实际使用Lambda表达式需要注意什么呢?

  • 关注如何编写Lambda,需要实现什么样的逻辑
  • 不要关注当前Lambda将会被如何调用,出现在代码的哪一块

比如:

public static void main(String[] args) {
	// 原始数据
    List<Integer> list = new ArrayList<>(Arrays.asList(1,2,3));
	// filter()方法需要传入一个过滤逻辑
    List<Integer> result = list.stream().filter(value -> value > 2).collect(Collectors.toList());
	
    System.out.println(result);
}

如果你只是调用者,尽量不要去想filter()方法内部代码是怎么写的,只需考虑如何实现过滤规则:大于2,所以你只要写一个能判断value是否大于2的Lambda并传入即可(value->value>2 就是 入参->方法体)。

函数式编程思想

Java从诞生之日起就一直倡导“一切皆对象”,在Java语言中面向对象(OOP)编程就是一切,但是随着Pathon和Scala等语言的崛起和新技术的挑战,Java也不得不做出调整以便支持更加广泛的技术要求,即Java语言不但支持OOP还支持OOF(面向函数编程)。
JDK1.8引入Lambda表达式之后,Java语言也开始支持函数式编程,但是Lambda表达式不是Java语言最早使用的,目前C++、C#、Python、Scala等语言都支持Lambda表示。

  • 面向对象的思想
    • 做一件事情,找一个能解决这个事情的对象,然后调用对象的方法,最终完成事情。
  • 函数式编程思想
    • 只要能获得结果,谁去做的,怎么做的都不重要,重视的是结果,不重视实现过程。

在函数式编程语言中,函数被当成一等公民对待。在将函数当成一等公民的编程语言中,Lambda表达式的类型是函数,但是Lambda表达式却是一个对象,而不是函数,它们必须依附于一类特别的对象类型,也就是所谓的函数式接口。

简单点说,JDK1.8中的Lambda表达式就是一个函数式接口的实例,这就是Lambda表达式和函数式接口的关系。也就是说,只要一个对象是函数式接口的实例,那么该对象就可以使用Lambda表示来表示

如何去理解函数式接口

能够使用Lambda表达式的一个重要依据是必须有相应的函数式接口,所谓的函数式接口,指的就是“一个接口中有且只能有一个抽象方法”。也就是说,如果一个接口只有一个抽象方法,那么该接口就是一个函数式接口。

如果我们在接口上声明了 @FunctionalInterface 注解,那么编译器就会按照函数式接口的定义来要求该接口,也就是该接口中有且只能定义一个抽象方法,如果该接口中定义了多个或0个抽象方法,则程序编译时就会报错。

【示例】定义一个函数式接口

@FunctionalInterface
public interface Flyable {
    // 在函数式接口中,我们有且只能定义一个抽象方法
    void showFly();
    // 但是,可以定义任意多个默认方法或静态方法
    default void show() {
        System.out.println("JDK1.8之后,接口还可以定义默认方法和静态方法");
    }
}

另外,从某种意义上来说,只要你保证你的接口中有且只有一个抽象方法,则接口中没有使用 @FunctionalInterface 注解来标注,那么该接口也依旧属于函数式接口。

在以下代码中,Flyable接口中没有使用@FunctionalInterface 注解,但是Flyable接口中只存在一个抽象方法,因此Flyable接口依旧属于函数式接口,那么使用Lambda表达式就可以表示Flyable 接口的实例,代码如下:

/**
 * 没有使用@FunctionalInterface标注的接口
 */
public interface Flyable {
    void showFly();
}
/**
 * 测试类
 */
public class Test01 {
    public static void main(String[] args) {
        // 使用lambda表示来表示Flyable接口的实例
        Flyable flyable = () -> {
            System.out.println("小鸟自由自在的飞翔");
        };
        // 调用Flyable接口的实例的showFly()方法
        flyable.showFly();
    }
}

如果接口中有两个抽象方法:

Lambda表达式不能用了:

只能用匿名类对象,把两个方法都实现:

因为Lambda的本质是传递一个方法体,而MyRunnable此时有两个方法需要实现,那么你这个Lambda表达式到底是给哪个方法的呢?另一个又该怎么办呢?此时只能用匿名类对象,把两个方法都实现。

当然,我们还可以从另一个角度理解:Java8的Lambda都是基于上下文推导的,当一个接口只有一个方法时,推导结果是唯一确定的,但是方法不唯一时,无法推导得到唯一结果。

Lambda和匿名内部类

  • 所需类型不同
    • 匿名内部类:可以是接口,抽象类,具体类。
    • Lambda表达式:只能是接口。
  • 使用限制不同
    • 如果接口中有且仅有一个抽象方法,可以使用Lambda表达式,也可以使用匿名内部类。
    • 如果接口中有多个抽象方法,则就只能使用匿名内部类,而不能使用Lambda表达式。
  • 实现原理不同
    • 匿名内部类:编译之后,会生成一个单独的.class字节码文件。
    • Lambda表达式:编译之后,没有生成一个单独的.class字节码文件。

上面的案例一直在对比匿名内部类和Lambda,大家可能认为Lambda等同于匿名内部类,认为Lambda是匿名内部类的语法糖。但其实并不是

public class Lambda {  
    public static void main(String[] args) {  
        // 在匿名内部类的外面定义一个String变量  
        final String str = "hello";  
        // 构造一个匿名内部类对象  
        Runnable r = new Runnable() {  
            @Override  
            public void run() {  
                System.out.println(str);  
                System.out.println("this--->" + this);  
            }  
            /**  
             * hello             
             * this--->lambdatest.Lambda$1@482ad665 
             * */        
             };  
        new Thread(r).start();  
    }  
}

注意,带$表示匿名内部类,比如LambdaTest$1表示LambdaTest中的匿名内部类,编译后会产生两个文件。

但如果把Runnbale换成Lambda表达式实现:

public void test(){  //不能在静态方法中使用this
    
    final String str = "hello";  
	//lambda表达式
    Runnable r = () -> {  
        System.out.println(str);  
        System.out.println("this--->" + this);  
    };  
    /**  
     * hello     
     * this--->lambdatest.Lambda@5e7e0ff3
     * */    
     new Thread(r).start();  
}

发现Lambda表达式方法体内部的this指向了LambdaTest,而且编译后也只有一个class文件。

Lambda方法体外部并没有匿名内部类,当然只能指向LambdaTest。更准确地说,this是指向方法的调用者,是隐式传递的。从这个角度看,Lambda和匿名内部类本质上还是不同的。

以后编码时,再遇到这种编译错误就不会迷惑了:

改成Lambda表达式即可,因为Lambda表达式外层就是当前类的实例:

根据IDEA提示,做一下简化:

Lambda表达式的使用

Lambda表达式本质就是一个匿名函数,在函数的语法中包含返回值类型、方法名、形参列表和方法体等,而在Lambda表达式中我们只需要关心形参列表和方法体即可。

在Java语言中,Lambda表达式的语法为“(形参列表) -> {方法体}”,其中“->”为 lambda操作符或箭头操作符,“形参列表”为对应接口实现类中重写方法的形参列表,“方法体”为对应接口实现类中重写方法的方法体。

接下来,我们就以匿名内部类为例,从而将匿名内部类演化为Lambda表达式,代码如下:

List<Integer> list = Arrays.asList(3, 6, 1, 7, 2, 5, 4);
Collections.sort(list, new Comparator<Integer>() {
    @Override
    public int compare(Integer o1, Integer o2) {
        return o2 - o1;
    }
});
System.out.println("排序后:" + list);

在以上的代码中,第5行是完成功能的核心代码,因此将此处的匿名内部类转换为Lambda表达式,我们只需要保留形式参数列表和方法体即可,对应代码:

List<Integer> list = Arrays.asList(3, 6, 1, 7, 2, 5, 4);
Collections.sort(list, (Integer o1, Integer o2) -> {
    return o2 - o1;
});
System.out.println("排序后:" + list);

因此Lambda本质上就是去掉了一堆没有意义的代码,只留下核心的代码逻辑,从而让代码看起来更加的简洁且优雅。

Lambda表达式简化

  1. 形参类型可以省略,如果需要省略,则每个形参的类型都要省略。
  2. 如果形参列表中只存在一个形参,那么形参类型和小括号都可以省略。
  3. 如果方法体当中只有一行语句,那么方法体的大括号也可以省略。
  4. 如果方法体中只有一条return语句,那么大括号可以省略,且必须去掉return关键字。

闭包

在前文中的示例中:

我们可以看到,如果将str的final去掉并重新赋值,报错:lambda表达式中的变量应该是final的

这要从闭包现象开始解释,闭包 由英文 closure 翻译而来。

闭包 = 环境 + 控制

闭包分为:

  • 闭包现象:两个函数嵌套,里层函数使用到外层函数的变量,就出现了闭包现象。
  • 闭包函数:内层函数就是闭包函数
def f():
	a = 5
	def g():
		print(a) # g函数就是闭包函数

	return g

《js高级程序设计》里面说:闭包是函数,是一个可以访问其他函数作用域中变量的函数。

f函数并不能访问g函数里面的变量,只有内部函数可以访问外部函数的变量。类似于内部类可以直接访问外部类,而外部类并不能直接访问内部类的变量。

上文的示例:

return之后:

return的不仅仅是g函数,还带有外面的一层环境信息

def f():
	a = 5
	def visit():
		print(a) # visit函数就是闭包函数

	def add():
		nolocal a
		a += 1
		
	return visit,add

g1 = f() # 拿到闭包
g1() # 5

如果调用多次:

def f():
	a = 5
	def visit():
		print(a) # visit函数就是闭包函数

	def add():
		nolocal a
		a += 1
		
	return visit,add

add, v = f()
add()  
v()   # 2
add()
v()   # 3
add()
v()   # 4

v函数和add函数的环境共享了,对变量a的操作只能通过这两个函数来完成,这就是一种小的封装

改写g函数:

def f():
	a = 5
	def g():
		nolocal a
		a += 1
		print(a) 

	return g

g1 = f()
g2 = f()

g1() # 2
g1() # 3
g1() # 4

g2() # 2
g2() # 3
g2() # 4

以上两个闭包中,a的值并不是同一个,类似于:


每次return都是将环境拷贝一份放出去了,这两个a是独立的a

如果外层环境中有多个变量,内层函数只使用了1个变量,闭包的环境也只有一个变量

如果是引用类型的变量,两个闭包指向的内存地址也是不同的,会对数组进行深拷贝。

Java中的闭包

但是在Java里我们看不到这样的结构,因为Java语法不允许直接的函数嵌套和跨域访问变量。

但是Java到处都是闭包,反而让我们感觉不到在使用闭包。因为Java的对象实际上就是一种闭包的结构。无论是闭包还是对象都是封装数据的一种手段。

class Add{
	private int x = 2;
	public int add(){
		int y = 3;
		return x + y;
	}
}

看上去x在add作用域之外,但是通过Add类实例化的过程,变量x和数值2之间已经绑定了,而且和方法add都打包在了一起。add函数其实是通过this关键字来访问对象的成员字段的。

Java中内部类就是一个经典的闭包结构:

public class Outer{
	private int x = 100;

	private class Inner{
		private y = 100;
		public int innerAdd(){
			return x + y;
		} 
	}
}

下图就是上文代码的结构。内部类通过包含一个指向外部类的引用,做到自由访问外部环境类的所有字段,变相把环境中的自由变量封装到函数中,形成一个闭包。

但Java匿名内部类做的比较尴尬。下面的例子中,getAnnoInner负责返回一个匿名内部类的引用。

interface AnnoInner{  
    int addXYZ();  
}  
public class Outer {  
    public AnnoInner getAnnoInner(final int x){  
        final int y = 100;  
        return new AnnoInner() {  
            int z = 100;  
            @Override  
            public int addXYZ() {  
                return x + y + z;  
            }  
        };  
    }  
}

匿名内部类不能显式地声明构造函数,也不能向构造函数中传参数。不但返回的只是个AnnoInner接口,而且还没有和它外围环境getAnnoInner方法的局部变量x和y构成任何类的结构。但它的addXYZ方法却直接使用了x和y两个自由变量计算结果。这就说明,外部方法getAnnoInner事实上已经对内部类AnnoInner构成了一个闭包。

但是这里x和y都必须用final修饰,不可以修改。如果用一个changeY方法试图修改外部getAnnoInner里的成员变量y,编译不通过:不能对final变量y重新赋值。

原因在于,Java编译器支持了闭包,但支持的不完整。说支持了闭包,是因为编译器编译的时候对方法进行了修改,将外部环境方法的x和y,拷贝了一份到匿名内部类当中,代码如下图所示:

public class Outer {  
    public AnnoInner getAnnoInner(final int x){  
        final int y = 100;  
        return new AnnoInner() {  
                    int copyX = x;  //拷贝副本
                    int copyY = y;  //拷贝副本
            int z = 100;  
            @Override  
            public int addXYZ() {  
                return x + y + z;  
            }  
        };  
    }  
}

Java编译器实现的是capture by value,并没有实现capture by reference。

  • capture by value:值捕获,在需要创建闭包的地方把捕获的值拷贝一份到对象里即可。Java的匿名内部类和lambda表达式都是这样实现的。
  • capture by reference:引用捕获,把捕获的局部变量hoist提升到对象里。C#的匿名函数(匿名委托/lambda表达式)就是这样实现的

而只有后者才能保持匿名内部类和外部环境变量保持同步。

但Java没有明确指出,既然内外不能同步,直接不允许修改外部环境的局部变量了。

几种不太常用的内部类形式,都有这个特性:方法的局部内部类等。

如果变量是不可变的,那么使用者无法感知值捕获和引用捕获的区别。

其他语言的捕获方式:

  1. C++ 11 允许显式指定捕获列表以及捕获方式,这样最清晰。
  2. JS只有引用捕获,要模拟值捕获的效果需要手动创建闭包和局部变量。
  3. C#对不可变量做值捕获,对普通局部变量做引用捕获;由于无法感知对不可变量做值捕获或引用捕获的区别,统一把这个行为描述为引用捕获更方便一点。
  4. Java虽然只实现了值捕获,但是又当又立不承认自己只做了值捕获,只允许捕获事实上不变量
  5. python的lambda实现的是引用捕获,但在lambda内不能对捕获的变量赋值,只能在原本定义那些变量的作用域中对其赋值。

但是Java只不允许改变被lambda表达式捕获的变量,并没有限制这些变量指向的对象状态是否能改变,要从lambda表达式向外传值的常见workaround之一就是用长度为1的数组。JDK内部有些代码就使用的这种方法。

其他关于匿名内部类定义final的回答:

1)从程序设计语言的理论上:局部内部类(即:定义在方法中的内部类),由于本身就是在方法内部(可出现在形式参数定义处或者方法体处),因而访问方法中的局部变量(形式参数或局部变量)是天经地义的.是很自然的

2)为什么JAVA中要加上一条限制:只能访问final型的局部变量?

3)JAVA语言的编译程序的设计者当然全实现:局部内部类能访问方法中的所有的局部变量(因为:从理论上这是很自然的要求),但是:编译技术是无法实现的或代价极高.

4)困难在何处?到底难在哪儿?

局部变量的生命周期与局部内部类的对象的生命周期的不一致性!

5)设方法f被调用,从而在它的调用栈中生成了变量i,此时产生了一个局部内部类对象inner_object,它访问了该局部变量i .当方法f()运行结束后,局部变量i就已死亡了,不存在了.但局部内部类对象inner_object还可能一直存在(只能没有人再引用该对象时,它才会死亡),它不会随着方法f()运行结束死亡。这时,出现了一个”荒唐”结果:局部内部类对象inner_object要访问一个已不存在的局部变量i!

6)如何才能实现?当变量是final时,通过将final局部变量”复制”一份,复制品直接作为局部内部中的数据成员.这样:当局部内部类访问局部变量时,其实真正访问的是这个局部变量的”复制品”(即:这个复制品就代表了那个局部变量).因此:当运行栈中的真正的局部变量死亡时,局部内部类对象仍可以访问局部变量(其实访问的是”复制品”),给人的感觉:好像是局部变量的”生命期”延长了.

那么:核心的问题是:怎么才能使得:访问”复制品”与访问真正的原始的局部变量,其语义效果是一样的呢?

当变量是final时,若是基本数据类型,由于其值不变,因而:其复制品与原始的量是一样.语义效果相同.(若:不是final,就无法保证:复制品与原始变量保持一致了,因为:在方法中改的是原始变量,而局部内部类中改的是复制品)

当变量是final时,若是引用类型,由于其引用值不变(即:永远指向同一个对象),因而:其复制品与原始的引用变量一样,永远指向同一个对象(由于是final,从而保证:只能指向这个对象,再不能指向其它对象),达到:局部内部类中访问的复制品与方法代码中访问的原始对象,永远都是同一个即:语义效果是一样的.否则:当方法中改原始变量,而局部内部类中改复制品时,就无法保证:复制品与原始变量保持一致了(因此:它们原本就应该是同一个变量.)

字节码层面测试

public interface MyInterface {
    void doSomething();
}
public class TryUsingAnonymousClass {
    public void useMyInterface() {
        final Integer number = 123;
        System.out.println(number);

        MyInterface myInterface = new MyInterface() {
            @Override
            public void doSomething() {
                System.out.println(number);
            }
        };
        myInterface.doSomething();

        System.out.println(number);
    }
}

反编译匿名内部类:

class TryUsingAnonymousClass$1
        implements MyInterface {
    private final TryUsingAnonymousClass this$0;
    private final Integer paramInteger;

    TryUsingAnonymousClass$1(TryUsingAnonymousClass this$0, Integer paramInteger) {
        this.this$0 = this$0;
        this.paramInteger = paramInteger;
    }

    public void doSomething() {
        System.out.println(this.paramInteger);
    }
}

可以看到名为number的局部变量是作为构造方法的参数传入匿名内部类的(以上代码经过了手动修改,真实的反编译结果中有一些不可读的命名)。

如果Java允许匿名内部类访问非final的局部变量的话,那我们就可以在TryUsingAnonymousClass$1中修改paramInteger,但是这不会对number的值有影响,因为它们是不同的reference。这就会造成数据不同步的问题。

而Scala中的代码会编译为这样:

public final class TryUsingAnonymousClassInScala$$anonfun$1 extends AbstractFunction0.mcV.sp
        implements Serializable {
    public static final long serialVersionUID = 0L;
    private final IntRef number$2;

    public final void apply() {
        apply$mcV$sp();
    }

    public void apply$mcV$sp() {
        this.number$2.elem = 456;
        Predef..MODULE$.println(BoxesRunTime.boxToInteger(this.number$2.elem));
    }

    public TryUsingAnonymousClassInScala$$anonfun$1(TryUsingAnonymousClassInScala $outer, IntRef number$2) {
        this.number$2 = number$2;
    }
}

可以看到number也是通过构造方法的参数传入的,但是与Java的不同是这里的number不是直接传入的,是被IntRef包装了一层然后才传入的。对number的值修改也是通过包装类进行的:this.number$2.elem = 456;

这样就保证了lambda表达式内外访问到的是同一个对象。

四个基本的函数式接口

以上的函数式接口都在java.util.function包中,通常函数接口出现的地方都可以使用Lambda表达式,所以不必记忆函数接口的名字

接口多态与方法多态

多态,原本指的是接口下有多个子类实例可以指向接口引用,但由于函数式接口恰好仅有一个方法,此时接口多态等同于“方法多态”,即一个抽象方法拥有多个不同的具体实现。

接口多态

多态的精髓在于晚绑定:

PocketMon pocketMon = new Pikaqiu();
pocketMon.releaseSkill();

只看pocketMon.releaseSkill()你能猜出来技能是电击还是喷火吗?换一种形式呢?

Properties pro = new Properties();
FileInputStream in = new FileInputStream("pocketmon.properties");
pro.load(in);
PocketMon pocketMon = Class.forName(pro.getProperty("nextPocketMon")).newInstance();
pocketMon.releaseSkill();

完全看不出来了。

即使你打开pocketmon.properties看了是皮卡丘,运行时虚拟机看到的可能是我修改后的喷火龙。

这种现象其实很奇妙:明明代码都写死了,但虚拟机却无法提前确定具体会是哪只神奇宝贝在调用releaseSkill(),除非实际运行到这行代码。而这,正是得益于多态。

多态的原理,本质是还是JVM层面通过运行时查找方法表实现的。可以简单理解为,JVM在运行时需要去循环遍历这个方法对应的多态实现,选择与当前运行时对象匹配的方法进行调用。所以,从理论上来说,晚绑定的多态在性能上是不如早绑定的(直接写死,不用多态)。而多态是设计模式的灵魂,所以对于一些非常、非常、非常要求性能的场景来说,过于繁重的设计反而会降低性能。说白了,这世上就不存在多、快、好、省。

多态是“晚绑定”思想的体现:对于Java而言,方法的调用并不是编译时绑定,而是运行时动态绑定的,取决于引用具体指向的实例。

方法多态

这个概念在函数式接口的前提下是站得住脚的,而且有利于跳出面向对象,贴近函数式编程。

需求:要求写一个cook()方法,传入鸡翅和可乐,做出可乐鸡翅。

很多人可能下意识地就把代码写死了:

public static CokaChickenWing cook(Chicken chicken, Coka coka){
    1.放油、放姜;
    2.放鸡翅;
    3.倒可乐;
    4.return CokaChickenWing;
}

但是,网上也有人说应该先倒可乐再放鸡翅,每个人的口味不同,做法也不同。有没有办法把这两步延迟确定呢?让调用者自己来安排到底是先倒可乐还是先放鸡翅。

可以这样:

public static CokaChickenWing cook(Chicken chicken, Coka coka, function twoStep){
    1.放油、放姜;
    2~3.twoStep();
    4.return CokaChickenWing;
}

想法很好:既然这两步不确定,那么就由调用者来决定吧,让调用者自己传进来。

我们知道Java是不能直接传递方法的,但利用策略模式可以解决这个问题。

定义一个接口:

interface TwoStep {
    void excute();
}
public static CokaChickenWing cook(Chicken chicken, Coka coka, TwoStep twoStep){
    1.放油、放姜;
    2~3.twoStep.excute();
    4.return CokaChickenWing;
}

这里twoStep.excute()是确定的吗?

没有。方法里的参数放在栈帧中的局部变量表里,必须是程序执行到该方法时,方法才入栈,分配相应内存与数据,所以是不能提前确定的。

所以twoStep.excute()充其量只是先替“某些操作占个坑”,后面再确定。

什么时候确定呢?

main(){
   
   TwoStep twoStep = new TwoStep(){
    	@Override
        public void excute(){
            2.先放鸡翅
            3.再倒可乐
        }
   }
   // 或者
   TwoStep twoStep = () -> {2.先放鸡翅;3.再倒可乐}
   // 调用cook时确定(运行时)
   cook(chicken, coka, twoStep);
}

public static CokaChickenWing cook(Chicken chicken, Coka coka, TwoStep twoStep){
    1.放油、放姜;
    2~3.twoStep.excute();
    4.return CokaChickenWing;
}

学习过Lambda表达式就可以换一种写法:

main(){
   // 调用cook时确定 方案1
   cook(chicken, coka, () -> 2.先放鸡翅,3.再倒可乐);
   // 调用cook时确定 方案2
   cook(chicken, coka, () -> 2.先倒可乐,3.再放鸡翅);
}

public static CokaChickenWing cook(Chicken chicken, Coka coka, TwoStep twoStep){
    1.放油、放姜;
    2~3.twoStep.excute();
    4.return CokaChickenWing;
}

这就是所谓的“方法多态”:通过函数式接口把形参的坑占住,后续传入不同的Lambda实现各自逻辑。

晚绑定和模板方法设计模式

在设计模式中策略模式和模板方法看起来有点像,但其实不一样。策略模式使用接口占坑,然后传入实际对象调用需要的方法,而模板方法模式是用抽象方法占坑,粒度其实小一些。

晚绑定最典型的应用就是模板方法模式:抽象类确定基本的算法骨架,把不确定的、变化的部分做成抽象方法剥离出去,由子类来实现。

还是以发送验证码为例:

/**
 * 验证码发送器
 *
 * @author qiyu
 * @date 2020-09-08 19:38
 */
public abstract class AbstractValidateCodeSender {

    /**
     * 生成并发送验证码
     */
    public void sendValidateCode() {
        // 1.生成验证码
        String code = generateValidateCode();

        // 2.把验证码存入Session
        // ....

        // 3.抽象方法占坑,用于发送验证码
        sendCode();
    }

    /**
     * 具体发送逻辑,留给子类实现:发送邮件、或发送短信都行
     */
    protected abstract void sendCode();

    /**
     * 生成验证码
     *
     * @return
     */
    public String generateValidateCode() {
        return "123456";
    }

}

对于上面的模板,我们可以有多种实现方式,以便把sendCode()这个坑填上:

/**
 * 短信验证码发送
 *
 * @author qiyu
 * @date 2020-09-08 19:44
 */
public class SmsValidateCodeSender extends AbstractValidateCodeSender {

    @Override
    protected void sendCode() {
        // 通过阿里云短信发送
    }
}
/**
 * QQ邮箱验证码发送
 *
 * @author qiyu
 * @date 2020-09-08 19:45
 */
public class EmailValidateCodeSender extends AbstractValidateCodeSender {

    @Override
    protected void sendCode() {
        // 通过QQ邮箱发送
    }
}

内部迭代与外部迭代

外部迭代

简单理解外部迭代就是由用户来决定”做什么“和”怎么做“的操作。比如下面代码中”做什么“(把大写转成小写)与”怎么做“(通过 Iterator 遍历)是由用户来决定的。

List<String> example = Arrays.asList(new String[]{"A", "B", "C"});
 // 遍历List,把大写转成小写
Iterator<String> iterator = example.iterator();
while (iterator.hasNext()) {
  System.out.println(iterator.next().toLowerCase());
} 

内部迭代

  • 如果我们使用 Stream来操作集合的话可以这样写。
List<String> example = Arrays.asList(new String[]{"A", "B", "C"});
example.stream().forEach(e -> System.out.println(e.toLowerCase()));

Stream操作的是集合的流。

内部迭代我们只需要提供”做什么“,把”怎么做“的任务交给了 JVM

使用内部迭代可以带来的好处:

  • 用户只需要关注问题,无需关注如何解决问题的细节。
  • 使得 JVM可以利用短路、并行等对性能的提升变成可能

StreamAPI 设计分析

Lambda底层原理其实是自动生成一个私有静态方法,并实现函数式接口,然后在函数式接口实现类的方法中调用这个私有静态方法。会有一些反编译操作。

模拟filter()

/**
 * 新建Predicate接口
 *
 * @param <T>
 */
@FunctionalInterface
interface Predicate<T> {
    /**
     * 定义了一个test()方法,传入任意对象,返回true or false,具体判断逻辑由子类实现
     *
     * @param t
     * @return
     */
    boolean test(T t);
}


/**
 * Predicate接口的实现类,泛型规定只处理Person
 */
class PredicateImpl implements Predicate<Person> {

    /**
     * 判断逻辑是:传入的person是否age>18,是就返回true
     *
     * @param person
     * @return
     */
    @Override
    public boolean test(Person person) {
        return person.getAge() > 18;
    }
}


@Data
@AllArgsConstructor
class Person {
    private String name;
    private Integer age;
}

测试:

public class MockStream {

    public static void main(String[] args) {

        Person bravo = new Person("bravo", 18);

        // 1.具体实现类,调用它的test()方法
        Predicate<Person> predicate1 = new PredicateImpl();
        // test()内部代码是:bravo.getAge() > 18
        myPrint(bravo, predicate1); // false

        // 2.匿名类,调用它的test()方法
        Predicate<Person> predicate2 = new Predicate<Person>() {
            @Override
            public boolean test(Person person) {
                return person.getAge() < 18;
            }
        };
        myPrint(bravo, predicate2); // false

        // 3.Lambda表达式,调用它的test()方法。
        // 按照Lambda表达式的标准,只要你是个函数式接口,那么就可以接收Lambda表达式
        Predicate<Person> predicate3 = person -> person.getAge() == 18;
        myPrint(bravo, predicate3); // true
    }

    public static void myPrint(Person person, Predicate<Person> filter) {
        if (filter.test(person)) {
            System.out.println("true");
        } else {
            System.out.println("false");
        }
    }
}

myPrint(Person person, Predicate<Person> filter)的精髓是,用Predicate函数式接口先行占坑。我们无需关注Predicate传入myPrint()后将被如何调用,只要关注如何实现Predicate,又由于Predicate只有一个test()方法,所以最终我们只需关注如何实现test()方法。

而实现test()的诀窍在于,盯牢它的入参出参。它有一个入参(待测试目标),而返回值是一个boolean(测试结果)。

// 1.普通的方法
public boolean test(Person p) {
    // 啥逻辑都没有,直接返回true,你也可以按照实际业务写,比如p.age>18
    return true; 
}

// 2.Lambda形式
p -> return true;

之前提到过,在函数式接口的前提下,接口多态可以直接看作是方法多态,那么Filter#test()其实就是抽象方法占坑,实现类、匿名类或者Lambda表达式就可以指向它。就像之前说的,不管你先放鸡翅还是先倒可乐,我先用twoStep()占坑,你后面确定了自己传进来

上面3个例子,重要的不是结论,而是想揭露一个事实

不论具体实现类、匿名类还是Lambda表达式,其实做的事情本质上是一样的:

1.先让函数式接口占坑

2.自己后续调用时制定映射规则,规则可以被藏在具体类、匿名类中,或者Lambda表达式本身

有了上面的铺垫,我们来仔细看看之前山寨Stream API对filter()的实现:

class MyList<T>{
    private List<T> list = new ArrayList<>();
    
	// 1.外部调用add()添加的元素都会被存在list
    public boolean add(T t) {
        return list.add(t);
    }

    /**
     * 过滤方法,接收过滤规则
     * @param predicate
     * @return
     */
    public List<T> filter(Predicate<T> predicate){
        List<T> filteredList = new ArrayList<>();

        for (T t : list) {
            // 2.把规则应用于list
            if (predicate.test(t)) {
                // 3.收集符合条件的元素
                filteredList.add(t);
            }
        }

        return filteredList;
    }
}
  • filter(Predicate predicate)方法需要一个过滤规则,但这个规则不能写死,所以随便搞了一个接口占坑

  • 具体的过滤规则被延迟到传入具体类实例或Lambda时才确定

你可以理解为就是对策略模式的应用,但函数式接口更喜欢接收Lambda表达式,代码更加简洁。

Predicate接口已经提前占好坑位,而Lambda们则是去填坑。对于filter()这个案例而言,它需要外界传入具体的“判断器”,然后filter()内部让元素挨个排队去做检查,检查通过就加入filteredList,然后一起返回。

不关注Predicate接口接收的是谁(匿名对象/具体实现/Lambda),只关心它能干什么(方法)。这也是面向对象和函数式编程的思维差异

模拟map()

再模拟一个map()。因为实际编程中,个人认为filter()和map()是最常用的,希望大家能理解它们的“原理”。

/**
 * 定义一个Function接口
 * 从接口看Function<E, R>中,E(Enter)表示入参类型,R(Return)表示返回值类型
 * 
 * @param <E> 入参类型
 * @param <R> 返回值类型
 */
@FunctionalInterface
interface Function<E, R> {
    /**
     * 定义一个apply()方法,接收一个E返回一个R。也就是把E映射成R
     *
     * @param e
     * @return
     */
    R apply(E e); //方法调用时,R被确定。
}

/**
 * Function接口的实现类,规定传入Person类型返回Integer类型
 */
class FunctionImpl implements Function<Person, Integer> {

    /**
     * 传入person对象,返回age
     *
     * @param person
     * @return
     */
    @Override
    public Integer apply(Person person) {
        return person.getAge();
    }
}
public class MockStream {

    public static void main(String[] args) {
        Person bravo = new Person("bravo", 18);

        // 1.具体实现类,Function<Person, Integer>中,Person是入参类型,Integer是返回值类型
        Function<Person, Integer> function1 = new FunctionImpl();
        myPrint(bravo, function1);

        // 2.匿名类
        Function<Person, Integer> function2 = new Function<Person, Integer>() {
            @Override
            public Integer apply(Person person) {
                return person.getAge();
            }
        };
        myPrint(bravo, function2);


        // 3.Lambda表达式 person(入参类型) ->  person.getAge()(返回值类型)
        Function<Person, Integer> function3 = person -> person.getAge();
        myPrint(bravo, function3);
    }

    public static void myPrint(Person person, Function<Person, Integer> mapper) {
        System.out.println(mapper.apply(person));
    }
}

目前为止应该还蛮好理解的。

之前我们在MyList中写了个filter()方法并用Predicate接口占了坑,它接收一个“过滤器”来过滤元素。而现在,map()方法用Function接口占坑,它需要接收一个“转换器”来帮元素“变身”:

class MyList<T> {
    private List<T> list = new ArrayList<>();

    public boolean add(T t) {
        return list.add(t);
    }


    /**
     * 把MyList中的List<T>转为List<R>
     * 不要关注Function<T, R>接口本身,而应该关注apply()
     * apply()接收T t,返回R t。具体实现需要我们从外面传入,这里只是占个坑
     *
     * @param mapper
     * @param <R>
     * @return
     */
    public <R> List<R> map(Function<T, R> mapper) {
        List<R> mappedList = new ArrayList<>();

        for (T t : list) {
            // mapper通过apply()方法把T t 变成 R t,然后加入到新的list中
            mappedList.add(mapper.apply(t));
        }

        return mappedList;
    }

}

无论filter(Predicate predicate)还是map(Function mapper),其实就是接口占坑、Lambda填坑的过程,其中函数式接口只有唯一方法,所以可以直接把接口多态看做方法多态。比如Predicate只有一个抽象方法boolean test(),那么你写一个符合的具体实现即可:一个入参,返回值为boolean。

以后但凡遇到函数式接口占坑的,只要关注入参和出参即可:

Person ==> {坑} ==> boolean

很明显,入参是Person,出参是Integer。那Person类型怎么变成Integer类型呢?填坑的方案并不唯一,你可以编写任意逻辑,比如:

Person ==> {person -> person.getAge()} ==> Integer

当然,你也可以传递person.getAge()+1。总之,坑已经占好了,注意一下入参和出参满足条件即可。

方法引用

方法引用和匿名对象、lambda表达式等价

方法引用就是将已有的方法拿过来用,可以作为函数式接口中抽象方法的方法体。

如果一个Lambda表达式只是调用某个方法,就可以使用方法引用。

有如下要求:

  1. 引用处必须是函数式接口
  2. 被引用的方法必须是已经存在的
  3. 被引用的方法的形参和返回值需要和抽象方法保持一致
  4. 被引用的方法功能满足当前的需求

现有如下代码:

假设现在有写好的代码:

就可以替换为:

::就是方法引用符

方法引用的分类

flowchart LR 1.引用静态方法 2.引用成员方法 –> 1.引用其他类的成员方法 & 2.引用本类的成员方法 & 3.引用父类的成员方法 3.引用构造方法 4.其他调用方式 –> 1.使用类名引用成员方法 & 2.引用数组的构造方法

引用静态方法

类名::静态方法
Integer::parseInt

练习:集合中有字符串类型数字,要求将其转换为int类型

List<String> list = Arrays.asList("1", "2", "3", "4");  
list.stream().map(Integer::parseInt).forEach(System.out::println);

map的参数:函数式接口Function,消费T类型,生产R类型;方法的形参和返回值需要与抽象方法的形参和返回值保持一致。

方法的功能是把形参的字符串转换为整数

引用成员方法

对象::成员方法
 
    其他类:  其他类对象::方法名
    本类:    this::方法名
    父类:	 super::方法名

以上的都是在成员方法中引用其他成员方法,静态方法里没有super和this,只能创建对象

练习1:集合中有一些名字,按照要求过滤数据

有成员方法:

class StringTool{  
    public boolean startWithWordAndSizeEqualsThree(String s){  
        return s.startsWith("张") && s.length() == 3;  
    }  
}
  • 其他类引用这个其他类的成员方法
public static void main(String[] args) {  
    List<String> list = Arrays.asList("张三丰", "张无忌", "张无", "赵敏");  
    list.stream().map(new StringTool()::stringJudge).forEach(System.out::println);  
}
  • 在子类引用这个成员方法:
class StringToolSub extends StringTool{  
    public void doSome(){  
        List<String> list = Arrays.asList("张三丰", "张无忌", "张无", "赵敏");  
        list.stream().map(super::stringJudge).forEach(System.out::println);  
    }  
}

因为public修饰的成员方法是可以被继承的,所以此处也可以使用this

  • 本类中引用这个成员方法
class StringTool{  
    public boolean stringJudge(String s){  
        return s.startsWith("张") && s.length() == 3;  
    }  
      
    public void doSome(){  
        List<String> list = Arrays.asList("张三丰", "张无忌", "张无", "赵敏");  
        list.stream().map(this::stringJudge).forEach(System.out::println);  
    }  
}

引用构造方法

引用构造方法是为了创建对象

类名::new
Student::new
List<String> list = Arrays.asList("张三丰-15", "张无忌-20", "张无-25", "赵敏-30");

需要在构造方法里对流中的每一个字符串进行处理

class Person{  
    private String name;  
    private Integer age;  
  
    public Person(String str) {  
        this(str.split("-")[0],Integer.parseInt(str.split("-")[1]));  
    }  
  
    public Person(String name, Integer age) {  
        this.name = name;  
        this.age = age;  
    }  
}
List<String> list = Arrays.asList("张三丰-15", "张无忌-20", "张无-25", "赵敏-30");  
list.stream().map(Person::new).forEach(System.out::println);

其他方式调用

类名引用成员方法

类名::成员方法
String::substring

练习:集合中的字符串,要求变成大写之后输出

list.stream().map(s -> s.toUpperCase()).forEach(System.out::println);

但是这样也可以这样调用:

List<String> list = Arrays.asList("abc", "def", "ghi", "jkm");  
list.stream().map(String::toUpperCase).forEach(System.out::println);

toUpperCase方法:

public String toUpperCase(String this) {  
    return toUpperCase(Locale.getDefault());  
}

在调用时,正常情况下:

String s = "abc";
s.toUpperCase();

虚拟机在调用成员方法时,会隐含的将s引用赋值给隐含参数this,所以此时stream流中的每一个元素都赋值给参数this了,这样就可以通过类名来引用成员方法。

这里有两个问题:

  • 成员方法需要使用对象调用
  • 参数列表没有保持一致

这里遵守的是另一套规则:

  • 如果某个Lambda表达式只是调用一个实例方法,并且参数列表中第一个参数作为方法的主调,后面的所有参数都是作为实例方法的入参,就可以使用类名引用实例方法

抽象方法形参:

  1. 第一个参数:表示被引用方法的调用者,决定了可以引用哪些类中的方法
    在Stream流中,第一个参数一般都表示流里面的每一个数据。
    假设流里面的数据是字符串,那么使用这种方式进行方法引用,只能引用Srting类中的方法
  2. 第二个参数到最后一个参数:与被引用方法的形参保持一致,如果没有第二个参数,表示被引用的方法是无参的成员方法

也就是说明,流中的参数可以调用自身的成员方法,但是成员方法的形参需要与流的其他形参保持一致

也就是拿着流中的每一个数据,调用String类的toUpperCase方法

引用数组构造方法

数据类型[]::new
int[]::new
List<Integer> list = Arrays.asList(1, 2, 3, 4);  
list.stream().toArray(Integer[]::new);

练习

集合中存储:”张三,23″类的字符串,收集到Person类型的数组中

class Person{  
    private String name;  
    private Integer age;  
  
    public Person(String str) {  
        this(str.split(",")[0],Integer.parseInt(str.split(",")[1]));  
    }  
  
    public Person(String name, Integer age) {  
        this.name = name;  
        this.age = age;  
    }  
}
List<String> list = Arrays.asList("张三,23", "张三,23", "张三,23", "张三,23");  
Person[] persons = list.stream().map(Person::new).toArray(Person[]::new);  
System.out.println(Arrays.toString(persons));

创建集合添加Person对象,只获取姓名放在数组中

class Person{  
    private String name;  
    private Integer age;  
  
    public Person(String str) {  
        this(str.split(",")[0],Integer.parseInt(str.split(",")[1]));  
    }  
  
    public Person(String name, Integer age) {  
        this.name = name;  
        this.age = age;  
    }
}
List<Person> list = List.of(new Person("张三,23"),new Person("张三,23"));  
String[] names = list.stream().map(Person::getName).toArray(String[]::new);  
System.out.println(Arrays.toString(names));

创建集合添加Person对象,把name和age拼接为:张三-23的字符串,放入数组

List<Person> list = List.of(new Person("张三,23"),new Person("张三,23"));  
String[] persons = list.stream()
				  .map(person -> person.getName() + "-" + person.getAge()).toArray(String[]::new); 
System.out.println(Arrays.toString(persons));

也可以重写Person的toString方法,引用这个toString

Lambda表达式与方法引用

引例

public class MethodReferenceTest {

    private static final List<Person> list;

    static {
        list = new ArrayList<>();
        list.add(new Person(19));
        list.add(new Person(18));
        list.add(new Person(20));
    }

    public static void main(String[] args) {
        System.out.println(list);
        // sort()方法是List本身就有的,主要用来排序
        list.sort((p1, p2) -> p1.getAge() - p2.getAge());
        System.out.println(list);
    }


    @Data
    @AllArgsConstructor
    static class Person {
        private Integer age;
    }

}

结果


排序前:

[MethodReferenceTest.Person(age=19), MethodReferenceTest.Person(age=18), MethodReferenceTest.Person(age=20)]

排序后:

[MethodReferenceTest.Person(age=18), MethodReferenceTest.Person(age=19), MethodReferenceTest.Person(age=20)]

把上面的案例稍作改动:

public class MethodReferenceTest {

    private static final List<Person> list;

    static {
        list = new ArrayList<>();
        list.add(new Person(19));
        list.add(new Person(18));
        list.add(new Person(20));
    }

    public static void main(String[] args) {
        System.out.println(list);
        // 改动2:既然Person内部有个逻辑一样的方法,就用它来替换Lambda
        list.sort(Person::compare);
        System.out.println(list);
    }

    @Data
    @AllArgsConstructor
    static class Person {
        private Integer age;

        // 改动1:新增一个方法,逻辑和之前案例的Lambda表达式相同
        public static int compare(Person p1, Person p2) {
            return p1.getAge() - p2.getAge();
        }
    }
}

从Lambda到方法引用

/**
 * 从匿名对象 到Lambda 再到方法引用
 *
 * @author qiyu
 */
public class MethodReferenceTest {
    public static void main(String[] args) {
        String str1 = "abc";
        String str2 = "abcd";

        // 方式1:匿名对象
        Comparator<String> comparator1 = new Comparator<String>() {
            @Override
            public int compare(String o1, String o2) {
                return o1.length() - o2.length();
            }
        };
        compareString(str1, str2, comparator1);


        // 方式2:过渡为Lambda表达式
        Comparator<String> comparator2 = (String s1, String s2) -> {
            return s1.length() - s2.length();
        };
        compareString(str1, str2, comparator2);

        // 方式2的改进版:省去赋值操作,直接把整个Lambda表达式作为参数丢进去
        compareString(str1, str2, (String s1, String s2) -> {
            return s1.length() - s2.length();
        });

        // 方式2的最终版:把变量类型和return也去掉了,因为Java可以自动推断
        compareString(str1, str2, (s1, s2) -> s1.length() - s2.length());


        // 方式3:换种比较方式,本质和方式2是一样的,不信你去看看String#compareTo()
        Comparator<String> comparator3 = (s1, s2) -> s1.compareTo(s2);


        // 方式4:IDEA提示有改进的写法,最终变成了方法引用
        compareString(str1, str2, String::compareTo);

        // 完美。

    }

    /**
     * 传递Comparator,对str1和str2进行比较
     *
     * @param str1
     * @param str2
     * @param comparator
     */
    public static void compareString(String str1, String str2, Comparator<String> comparator) {
        System.out.println(comparator.compare(str1, str2));
    }
}

我们在学习Lambda时,把它和匿名类作比较。因为匿名类和Lambda处理的逻辑是一样的,所以就用Lambda简化了匿名类:

同样的,如果项目中已经定义了相同逻辑的方法,我们为什么还要再写一遍呢?即使Lambda表达式再怎么简洁,终究还是要手写好几行代码。

所以,JDK在Lambda表达式的基础上又提出了方法引用的概念,允许我们复用当前项目(或JDK源码)中已经存在的且逻辑相同的方法。

比如上面那个例子中的:

// 方式3:换种比较方式,本质和方式2是一样的
Comparator<String> comparator3 = (s1, s2) -> s1.compareTo(s2);

// 方式4:IDEA提示有改进的写法,最终变成了方法引用
compareString(str1, str2, String::compareTo);

String::compareTo看起来形式有点诡异,但这只是一种语法而已,习惯就好了,关键是明白它代表什么意思。Java8引入::符号,用来表示方法引用。所谓的方法引用,就是把方法搬过来使用。那么,String::compareTo把哪个类的什么方法搬过来了呢?

一般来说,String类定义的compareTo方法的正常使用方式是这样的:

public class MethodReferenceTest {
    public static void main(String[] args) {
        String str = "hello";
        String anotherStr = "world";
        int difference = str.compareTo(anotherStr);
    }
}

作为更高阶的Lambda表达式,方法引用也能作为参数传递,于是就有了:

public class MethodReferenceTest {
    public static void main(String[] args) {
        String str = "hello";
        String anotherStr = "world";

        // 匿名内部类
        Comparator<String> comparator = new Comparator<String>() {
            @Override
            public int compare(String str, String anotherStr) {
                return str.compareTo(anotherStr);
            }
        };

        // 方法引用。上面的str.compareTo(anotherStr)不就是String::compareTo吗!!
        Comparator<String> newComparator = String::compareTo;

        compareString(str, anotherStr, newComparator);
    }

    /**
     * 传递Comparator,对str1和str2进行比较
     *
     * @param str1
     * @param str2
     * @param comparator
     */
    public static void compareString(String str1, String str2, Comparator<String> comparator) {
        System.out.println(comparator.compare(str1, str2));
    }
}

总之,Java8的意思就是:

兄弟,如果已经存在某个方法能完成你的需求,那么你连Lambda表达式都别写了,直接引用这个方法吧。

但我个人更推荐Lambda表达式,原因有两个:

  • 对初学者而言,Lambda表达式语义更清晰、更好理解
  • Lambda表达式细粒度更小,能完成更精细的需求

第一点,你懂的。

第二点,请容许我来证明一下。

Lambda表达式VS方法引用

/**
 * MyPredict是模拟Predict
 * MyInteger是模拟Integer
 * <p>
 * 本次测试的目的旨在说明:Lambda毕竟是手写的,自由度和细粒度要高于方法引用。
 *
 * @author sunting
 */
public class MethodAndLambdaTest {
    public static void main(String[] args) {
        // 1.匿名对象
        MyPredict myPredict1 = new MyPredict() {
            @Override
            public boolean test(int a, int b) {
                return a - b > 0;
            }
        };
        boolean result1 = myPredict1.test(1, 2); // false

        // 2.从匿名对象过渡到Lambda表达式
        MyPredict myPredict2 = (a, b) -> a - b > 0;
        myPredict2.test(1, 2); // false

        // 3.MyInteger#compare()的方法体和上面的Lambda表达式逻辑相同,可以直接引用
        MyPredict myPredict3 = MyInteger::compare;
        myPredict3.test(1, 2); // false

        // 4.Lambda说,你想模仿我?想得美!老子要DIY一下比较规则(a减b 变成了 b减a)
        MyPredict myPredict4 = (a, b) -> b - a > 0;
        myPredict4.test(1, 2); // true

        // 5.看到这,方法引用不服气,也想DIY一把
        MyPredict myPredict5 = MyInteger::compare;
        // ???,没法DIY,MyInteger::compare是把整个方法搬过来,不能修改内部的逻辑
    }
}

interface MyPredict {
    boolean test(int a, int b);
}

class MyInteger {
    public static boolean compare(int a, int b) {
        return a - b > 0;
    }
}

方法引用,其实就是把现成的某个方法拿来替代逻辑相似的Lambda表达式。

但Lambda表达式由(a, b) -> a - b > 0 变为 (a, b) -> b - a > 0 ,说明Lambda逻辑已经变了,此时原先的方法引用就不匹配了,不能再用了。此时我们最自然的想法应该是从现成的项目中找到逻辑和(a, b) -> b - a > 0相同的另一个方法,然后把那个方法引用过来,而不是想着改变原来的MyInteger::Compare,那不是你的方法,你也只是借用而已!!

所以,我们给MyInteger加一个方法吧:

class MyInteger {
    public static boolean compare(int a, int b) {
        return a - b > 0;
    }

    public static boolean anotherCompare(int a, int b) {
        return b - a > 0;
    }
}

这样,方法引用的逻辑又和Lambda匹配了:

public class MethodAndLambdaTest {
    public static void main(String[] args) {

        MyPredict myPredict2 = (a, b) -> a - b > 0;
        myPredict2.test(1, 2); // false

        MyPredict myPredict3 = MyInteger::compare;
        myPredict3.test(1, 2); // false

        MyPredict myPredict4 = (a, b) -> b - a > 0;
        myPredict4.test(1, 2); // true
		
        // MyInteger::anotherCompare的逻辑和上面的Lambda才是匹配的
        MyPredict myPredict5 = MyInteger::anotherCompare;
        myPredict5.test(1, 2); // true
    }

}

interface MyPredict {
    boolean test(int a, int b);
}

class MyInteger {
    public static boolean compare(int a, int b) {
        return a - b > 0;
    }

    public static boolean anotherCompare(int a, int b) {
        return b - a > 0;
    }
}

再看一个Stream API的例子:

filter此时需要的逻辑是:年龄大于等于30岁的teacher。

你能从现有项目中找到逻辑为“年龄大于等于30岁的teacher”的方法吗?

答案是没有。

你最多只能调用Teacher::getAge(),但是这个方法引用的逻辑是“获取老师的年龄”,而不是“是否大于等于30岁”,两者逻辑不同,无法替换。

那能不能使用 Teacher::getAge()>=30 呢?

答案是不能。

首先,filter()的参数要么是Lambda表达式,要么是方法引用,不能是方法引用+语句,不伦不类。

其次,也是最重要的,你可以认为Teacher::getAge表示

public Integer getAge(){
    return this.age;
}

中的return this.age;,它是一个语句。我们可以对表达式叠加判断,比如 a-b ,我们可以继续叠加变成 a-b+c。但是 int d = a-b+c; 已经没办法再叠加了,因为 int d = a-b+c; >= 30 是不可接受的!

处理办法也简单,就是找一个相同逻辑的方法并引用它。假设存在以下方法:

public boolean isBiggerThan30(){
    return this.age >= 30;
}

那就可以写成:

list.stream().filter(Teacher::isBiggerThan30);

后话

关于方法引用其实还可以展开说,比如可以分为:

  • 静态方法引用(Integer::compare)
  • 实例方法引用(this::getName、user::getName)
  • 构造器方法引用(User::new)

总体来说,方法引用(包括构造器引用)的前提是,函数式接口的方法对应的参数列表和返回值 与 引用类定义的方法的参数列表和返回值 一致。这样说可能比较绕,这里举一个demo:

public class StreamConstructorTest {

    public static void main(String[] args) {

        // 下面4个语句都是Person::new,却能赋值给不同的函数式接口
        // 原因是:每个函数式接口都能从Person类中找到对应的方法(参数列表一致),从而完成方法引用
        PersonCreatorNoConstruct person1 = Person::new;

        // 大家可以尝试把Person中Age构造函数注释,那么下面的赋值语句会提示错误,因为此时不存在只有一个age参数的构造器!
        PersonCreatorWithAge person2 = Person::new;

        PersonCreatorWithName person3 = Person::new;

        PersonCreatorAllConstruct person4 = Person::new;

    }


    public interface PersonCreatorNoConstruct {
        // 对应Person无参构造
        Person create();
    }

    public interface PersonCreatorWithAge {
        // 对应Person的age构造函数
        Person create(Integer age);
    }

    public interface PersonCreatorWithName {
        // 对应Person的name构造函数
        Person create(String name);
    }

    public interface PersonCreatorAllConstruct {
        // 对应Person的全参构造函数
        Person create(Integer age, String name);
    }

    @Getter
    @Setter
    static class Person {
        private Integer age;
        private String name;

        public Person() {
        }

        public Person(Integer age) {
            this.age = age;
        }

        public Person(String name) {
            this.name = name;
        }

        public Person(Integer age, String name) {
            this.age = age;
            this.name = name;
        }
    }
}

public class MethodReferenceTest {
    public static void main(String[] args) {
        System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");
        String str1 = "abc";
        String str2 = "abcd";

        //这里不太明白为什么可以直接用string的compareTo的方法引用
        //compareString方法第三个参数要求的是Comparator类型的对象,以Lambda表达式来讲 它接受的对象对应实现的方法要求的是两个入参。
        //而String::compareTo方法只有一个入参,所以这里不太明白为什么可以直接用string的compareTo的方法引用
        compareString(str1, str2, String::compareTo);
    }

    public static void compareString(String str1, String str2, Comparator<String> comparator) {
        System.out.println(comparator.compare(str1, str2));
    }
}

java.lang.String#compareTo只接收一个入参。但是,Comparator接口的compare()方法接收两个入参。因此,当使用方法引用时,需要将第一个入参传递给this。在本例中,this指向String对象。因此,方法引用String::compareTo等效于Lambda表达式(this, o) -> this.compareTo(o)。

函数式接口

以Predicate为例,之前在分析山寨Stream API时,我们已经不知不觉使用过函数式接口:

// 1.用Lambda表达式实现Predicate接口的抽象方法test(),并赋值给函数式接口
Predicate<User> ifHigherThan180 = user -> user.getHeight()>180;
// 2.作为参数传入filter(Predicate<T> ifHigherThan180),filter()会在内部调用test()用来判断
ifHigherThan180.test(user);

总的来说,就是把方法A(Lambda)传给方法B(filter),然后在方法B内部调用方法A完成一些操作:

public void methodB(method a) {
    // 操作1
    // 调用method a
    // 操作3
}

我们可以理解为Java8支持传递方法(之前只能传递基本数据类型和引用类型),也可以按原来面向对象的思维将Lambda理解为“特殊的匿名对象”。

为什么函数式接口重要

之前只是向大家介绍了函数式接口的定义:

有且仅有一个抽象方法的接口(不包括默认方法、静态方法以及对Object方法的重写)

就我个人学习体会来看,函数式接口虽然不难,但作为参数类型出现时,经常会让初学者感到无所适从,甚至不知道该传递什么。比如Stream API中就大量使用了函数式接口接收形参:

CompletableFuture中也大量使用了函数式接口:

不知道大家是否曾经在调用filter()、supplyAsync()等方法时感到迷茫,起码我刚接触Java8时,每次调用这些方法都力不从心,不知道该传什么进去,也不知道Lambda表达式该怎么写。

如果你问我函数式接口难吗,我大概率会告诉你,它本身很简单。但如果你问我函数式接口重要吗,我会告诉你,它非常非常重要,能否熟练使用Java8的诸多新特性(Stream、CompletableFuture等)取决于你对函数式接口的熟悉程度。

函数式接口类型

根据方法的出入参类型不同,各种排列组合后,其实有很多种类型,最常用的有以下4种函数式接口:

public class FunctionalInterfaceTest {

    /**
     * 给函数式接口赋值的格式:
     * 函数式接口 = 入参 -> 出参/制造出参的语句
     *
     * @param args
     */
    public static void main(String[] args) {
        FunctionalInterface1 interface1 = str -> System.out.println(str);
        FunctionalInterface2 interface2 = () -> {
            return "abc";
        };
        FunctionalInterface3 interface3 = str -> Integer.parseInt(str);
        FunctionalInterface4 interface4 = str -> str.length() > 3;
    }


    /**
     * 消费型,吃葡萄不吐葡萄皮:有入参,无返回值
     * (T t) -> {}
     */
    interface FunctionalInterface1 {
        void method(String str);
    }

    /**
     * 供给型,无中生有:没有入参,却有返回值
     * () -> T t
     */
    interface FunctionalInterface2 {
        String method();
    }

    /**
     * 映射型,转换器:把T转成R返回
     * T t -> R r
     */
    interface FunctionalInterface3 {
        int method(String str);
    }

    /**
     * 特殊的映射型:把T转为boolean
     * T t -> boolean
     */
    interface FunctionalInterface4 {
        boolean method(String str);
    }
}

正因为各自出入参不同,导致Lambda表达式的写法也不同。

函数式接口作用

实际开发中,出入参类型的排列组合是有限的,所以JDK干脆内置了一部分函数式接口。一般来说,我们只需熟练掌握以下4大核心函数式接口即可:

  • 消费型接口 Consumer<T>    void accept(T t)
  • 供给型接口 Supplier<T>       T get()
  • 函数型接口 Function<T, R>   R apply(T t)
  • 断定型接口 Predicate<T>     boolean test(T t)

其实是对各个方法的抽象类型

什么JDK要在Java8这个版本引入函数式接口,并且提供这么多内置接口呢?

其实这些接口本不是给我们用的,而是JDK自己要用。

之前的filter和map方法模拟:

/**
 * 新建Predicate接口
 *
 * @param <T>
 */
@FunctionalInterface
interface Predicate<T> {
    /**
     * 定义了一个test()方法,传入任意对象,返回true or false,具体判断逻辑由子类实现
     *
     * @param t
     * @return
     */
    boolean test(T t);
}

// Function接口 略...

同样的,我和Java8一样,也把Predicate接口作为形参类型(详见myPrint方法):

public class MockStream {

    public static void main(String[] args) {

        Person bravo = new Person("bravo", 18);

        // 1.匿名对象,调用它的test()方法
        Predicate<Person> predicate1 = new Predicate<Person>() {
            @Override
            public boolean test(Person person) {
                return person.getAge() < 18;
            }
        };
        myPrint(bravo, predicate1); // false

        // 2.Lambda表达式,调用它的test()方法。
        // 按照Lambda表达式的标准,只要你是个函数式接口,那么就可以接收Lambda表达式
        Predicate<Person> predicate2 = person -> person.getAge() == 18;
        myPrint(bravo, predicate2); // true
    }

    public static void myPrint(Person person, Predicate<Person> filter) {
        if (filter.test(person)) {
            System.out.println("true");
        } else {
            System.out.println("false");
        }
    }
}

JDK之所以要在Java8内置那么多函数式接口,本质原因和我的山寨Stream API一样,是为了配合Java8的各种API。单独发布Stream API和CompletableFuture是不现实的,它们的方法形参都依赖函数式接口呢。

Java8函数式接口的作用和我们自定义的Predicate接口一样:

  • 接收Lambda表达式/方法引用
  • 占坑

或者说,这两个作用是一体两面的。

如何克服面向对象的思维惯性

对于函数式接口,上面已经讲得很多了,本身很简单,没什么好介绍的。这里主要聊一聊初学者应该如何习惯函数式接口传参的方式,以及关注点应该放哪里。换句话说,帮助初学者克服面向对象的思维惯性,学会用函数式编程的思维去使用Java8的诸多特性。

还是以Predicate为例,它唯一的抽象方法是:

boolean test(T t);

很多Java程序员会对这种传参形式感到眩晕,因为我们的潜意识一直无法摆脱面向对象的影响。当你看到getHealthPerson(List<User> users, Predicate<User> filter)时,你的大脑会告诉你Predicate是一个接口,所以应该传一个匿名对象给它。然后你的大脑自己也懵了,因为它突然发现光看Predicate接口,根本不知道该实现什么方法,甚至连入参和返回值都不清楚,怎么手写Lambda?

但之前介绍Python的函数时,我们很自然就接受了:

# encoding: utf-8

# abs是Python的内置函数
result = abs(-1)
print(result)

# 定义add()方法,f显然是一个函数
def add(x, y, f):
    return f(x) + f(y)

print(add(-1, -2, abs))

因为相比Python这些原本就支持函数式编程的语言来说,形参就是函数,不需要用接口做中间层过渡!而对于Stream这种面向对象世界中的异类,Java程序员还没准备好如何接纳它,看到接口形参第一时间想到的还是传匿名对象,突然要改成Lambda确实有点棘手,因为我们对接口内部的方法及出入参一概不知!

  • 每个函数式接口就一个方法,记住那个方法的出入参,看到接口就写对应方法的Lambda

这里主要讲讲如何记忆函数式接口的方法。一定要注意,函数式接口的方法名字不重要,重要的是出入参:

  • 消费型接口 Consumer<T>    void accept(T t) 例:x -> System.out.println(x)
  • 供给型接口 Supplier<T>       T get() 例:() ->
  • 函数型接口 Function<T, R>   R apply(T t) 例:user -> user.getAge(),T是入参,R是出参
  • 断定型接口 Predicate<T>     boolean test(T t)  例:user -> user.getAge() > 18

记住了接口对应的出入参,Lambda就好办了,其实传递的都是:

接口声明 = 入参 -> 出参/制造出参的语句

不信你可以重新看看上面给出的例子,比如Function:user -> user.getAge(),就是把User user映射为Integer age,user是入参,user.getAge()是制造出参的语句。又比如Predicate:user -> user.getAge()>18,user是入参,user.getAge()>18就是制造出参的语句,因为boolean test(T t)需要返回boolean。

所以要习惯函数式接口传参,最重要的是记住该接口对应的方法的出入参,然后编写Lambda时套用模板:

接口声明 = 入参(有无入参?) -> 出参/制造出参的语句(有无出参?什么类型?)

虽然我们还没正式学习Stream API,但已经可以试着写写啦:

public class FunctionalInterfaceTest {

    public static void main(String[] args) {

        List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6));

        /**
         * Predicate:特殊映射,只断是非   Function:T t --> f(x) --> boolean
         * Function:映射               Function:T t --> f(x) --> R r
         */
        list.stream().filter().map().collect(Collectors.toList());

        // Supplier,无源之水(S),却滔滔不绝,没有入参,却可以get()
        CompletableFuture<Object> completableFuture = CompletableFuture.supplyAsync();

        // Consumer,黑洞,吃(C)葡萄不吐葡萄皮,入参拿过来就是一顿操作,不返回
        completableFuture.thenAccept();

    }

}

函数式接口与类型推断

之前在《Lambda表达式》中,我举过一个例子

当接口内只有一个抽象方法时,可以使用Lambda表达时给接口赋值:

MyInterface interface = () -> System.out.println(“吃东西”);

但如果函数式接口内有多个方法抽象方法,就无法传递Lambda表达式:

此时只能传入匿名对象分别实现两个抽象方法:

学完这一章,相信大家会有更深刻的理解:

public void run()和public void eat()如果只看出入参,其实是一样的,所以编译时无法自动推断。

由于本章节一直在强调函数式接口传参,所以这里再给出一个例子:

public class FunctionalInterfaceTest {

    public static void main(String[] args) {
		// Ambiguous method call
        lambdaMethod(() -> System.out.println("test"));
    }

    /**
     * 方法重载
     *
     * @param param1
     */
    public static void lambdaMethod(Param1 param1) {
        param1.print();
    }

    /**
     * 方法重载
     *
     * @param param2
     */
    public static void lambdaMethod(Param2 param2) {
        param2.print();
    }

    /**
     * 函数式接口1
     */
    interface Param1 {
        void print();
    }

    /**
     * 函数式接口2
     */
    interface Param2 {
        void print();
    }

}

原因也是一样的,Lambda只关注方法的出入参,所以在进行类型推断时,interface Param1和interface Param2是一样的,编译器无法替我们决定实现哪一个接口,解决办法是使用匿名对象。

如果说《Lambda表达式》中举的例子是方法级别的推断冲突,那么上面的例子讲的就是接口级别的推断冲突,但归根结底就一句话:Lambda上下文推断只看出入参,无论是接口名还是方法名,都不重要!

所以,学习函数式接口最重要的就是记住出入参的格式!!

参照附:ConvertUtil.java

小试牛刀

实际开发中,经常会看到这样的写法:

public void XxMethod(){
    
    // ...
    
    List<Long> iidList = new ArrayList<>();
    List<Long> eventList = new ArrayList<>();
    if(items != null && items.size > 0) {
        for(Item item : items){
            iidList.add(item.getId());
            eventList.add(item.getEvent());
        }
    }
    
    // ...
}
public void XxMethod(){
    
    // ...
    
    List<Long> iidList = new ArrayList<>();
    List<Long> eventList = new ArrayList<>();
	List<xxx> xxxList = new ArrayList<>();
	...

	//对iidList和eventList和其他List的其他操作

    if(items != null && items.size > 0) {
        for(Item item : items){
            iidList.add(item.getId());
            eventList.add(item.getEvent());
            xxxList.add(...);
            ....
        }
    }
    
    // ...
}

这里能用ConvertUtil的resultToList()吗?

答案是,不能。resultToList()一次只处理一个List,而上面一次循环可能处理N个List。所以我们能做的,只是抽取循环的操作,具体循环里做什么,每个List可能不同,不好抽取。

有了上面的经验,我们完全可以再给ConvertUtil封装个方法:

/**
 * foreach,内部判空
 *
 * @param originList 需要遍历的List
 * @param processor  需要执行的操作
 * @param <T>
 */
public static <T> void foreachIfNonNull(List<T> originList, Consumer<T> processor) {
    if (originList == null || originList.isEmpty()) {
        return;
    }
    for (T originElement : originList) {
        if (originElement != null) {
        	processor.accept(originElement);
        }
    }
}
public void XxMethod(){
    
    // ...
    
    List<Long> iidList = new ArrayList<>();
    List<Long> eventList = new ArrayList<>();
    ConvertUtil.foreachIfNonNull(items, item -> {
         iidList.add(item.getId());
         eventList.add(item.getEvent());
    })
    
    // ...
}

上面相当于自己实现了foreach。你可能会想,为什么不直接用Stream API或者直接List.foreach()?原因在于它们都要额外判断非空,否则可能引发NPE。

实际上这个操作也是可以完全抽取的,只是利用了前文中泛型擦除的特性:

public static <T,R> void add(List<T> baseList, List[] lists, Function<T,R> ... functions){  
    for (T t : baseList) {  
        for (int i = 0; i < lists.length; i++) {  
            lists[i].add(functions[i].apply(t));  
        }  
    }  
}

虽然存入的时候按照Object存入(实际上也是利用了这个特性才能完成这个操作),取出的时候因为声明的泛型可以转换为对应的类型

最后留个思考题:自己实现groupBy()。

public static <T, K> Map<K, List<T>> groupBy(List<T> originList, Function<T, K> keyExtractor) {  
    HashMap<K, List<T>> hashMap = new HashMap<>();  
    for (T t : originList) {  
        if (t != null){  
            K key = keyExtractor.apply(t);  
            if (hashMap.get(key) == null) {  
                ArrayList<T> tList = new ArrayList<>();  
                tList.add(t);  
                hashMap.put(key,tList);  
            }else {  
                hashMap.get(key).add(t);  
            }  
        }  
    }  
    return hashMap;  
}

如果分组后,我要的不是Map(City, List(User)),而是Map(City, List(username))呢?你能基于上面的方法进行改造吗?

public static <T,K,R> Map<K, List<R>> groupBy(List<T> originList, Function<T, K> keyExtractor, java.util.function.Function<T,R> mapper) {  
    HashMap<K, List<R>> hashMap = new HashMap<>();  
    for (T t : originList) {  
        if (t != null){  
            K key = keyExtractor.apply(t);  
            if (hashMap.get(key) == null) {  
                ArrayList<R> rList = new ArrayList<>();  
                rList.add(mapper.apply(t));  
                hashMap.put(key,rList);  
            }else {  
                hashMap.get(key).add(mapper.apply(t));  
            }  
        }  
    }  
    return hashMap;  
}

Stream

获取Stream流

获取Stream流:

获取方式 方法名 说明
单列集合 default Stream<E> stream() Collection接口的默认方法
双列集合 无法直接使用Stream流
数组 public static <T> Stream<T> stream(T[] array) Arrays工具类中的静态方法
一堆零散数据 public static <T> Stream<T> of(T ...values) Stream接口中的静态方法

单列集合

ArrayList<String> list = new ArrayList<>();
Collections.addAll(list,"a","b","c","d","e");

//获取流并将集合中的数据放上去
Stream<String> stream1 = list.stream();

//使用终结方法打印
stream1.forEach(s -> System.out.println(s));

双列集合

HashMap<String,Integer> hm = new HashMap<>();
hm.put("aaa",111);
hm.put("bbb",222);
hm.put("ccc",333);
hm.put("ddd",444);

hm.keySet().stream().forEach(s->{
	System.out.println(s + " : " + hm.get(s));
});

hm.entrySet().stream().forEach(e ->{
	System.out.println(e.getKey() + " : " + e.getValue());
});

数组

int[] arr = {1,2,3,4,5,6,7,8,9,10};
String[] strs = {"a","b","c"};

Arrays.stream(arr).forEach(s-> System.out.println(s));
Arrays.stream(strs).forEach(s-> System.out.println(s));

零散数据

//stream流中元素是Integer类型
Stream.of(1,2,3,4,5).forEach(s-> System.out.println(s));
//stream流中元素是String类型
Stream.of("1","2","3","4","5").forEach(s-> System.out.println(s));

//stream流中元素是Serializable类型
Stream.of(1, 3, "a").forEach(s-> System.out.println(s));

不建议使用Stream.of方法创建数组的Stream流,引用数据类型的数组没有问题,基本数据类型的数组就会出现以下问题:

//引用类型数组  
String[] strs = {"a","b","c"};  
//Stream.of获取引用类型的数组没有问题,输出:abc  
Stream.of(strs).forEach(System.out::print);  
  
//基本类型数组  
int[] ints = {1,2,3};  
//Stream.of获取基本类型的数组出现问题,输出:[I@21e360a  
Stream.of(ints).forEach(System.out::print);

对于基本类型数组,Stream.of会将整个数组当作一个元素放在Stream流上。

接口默认、静态方法

之前学习函数式接口时,曾经提到过Java8新出的接口静态方法、默认方法:

为什么Java8要引入静态方法和默认方法呢?

原因可能有多种,但其中一种肯定是为了“静默升级”。打开Collection接口:

这下明白了吧,list.stream().filter()…用的这么爽,其实都直接继承自顶级父接口Collection。

这和引入default有啥关系呢?

试想一下,要想操作Stream必须先获取Stream,获取流的方法理应放在Collection及其子接口、实现类中。但如果作为抽象方法抽取到Collection中,那么原先的整个继承链都会产生较大的震动:

JDK官方要从Collection接口沿着继承链向下都实现一遍stream()方法。这还不是最大的问题,最致命的是全球各地保不齐就有人直接实现了Collection,比如MyArrayList啥的,此时如果贸然往Collection增加一个抽象方法,那么当他们升级到JDK1.8后就会立即编译错误,强制他们自己实现stream()…

所以JDK的做法是,把获取Stream的一部分方法封装到StreamSupport类,另一部分封装到Stream类,StreamSupport用来补足原先的集合体系,比如Collection,然后引入default方法包装一下,内部调用StreamSupport完成偷天换日。而得到Stream后的一系列filter、map操作是针对Stream的,已经封装在Stream类中,和原来的集合无关。

所以,StreamSupport+default就是原先集合体系和Stream之间的“中介”:

调用者–>Collection–>StreamSupport+default–>Stream

总之,引入接口默认方法和静态方法后,接口越来越像一个类。从某个角度来说,这种做法破坏了Java的单继承原则。Java原本的特点是“单继承、多实现”,假设接口A和接口B都有methodTest(),那么class Test implements interfaceA, interfaceB时,就不得不考虑使用哪个父接口的methodTest()方法。JDK的做法是,编译时强制子类覆盖父接口同名的方法。

Stream API

我们最常用的集合其实来自两个派系:Collection和Map,实际开发时使用率大概是这样的:

  • ArrayList:50%
  • HashMap:40%
  • 其他:10%

而用到Stream的地方,占比就更极端了:

  • List:90%
  • Set:5%
  • Map:5%

对于一般人来说,只要学好List的Stream用法即可。

认识几个重要的接口与类

Stream的方法众多,不要期望能一次性融会贯通,一定要先了解整个API框架。有几个很重要的接口和类:

  • Collection
  • Stream
  • StreamSupport
  • Collector
  • Collectors

Collection

之前介绍过了,为了不影响之前的实现,JDK引入了接口默认方法,并且在Collection中提供了一系列将集合转为Stream的方法:

要想使用Stream API,第一步就是获取Stream,而Collection提供了stream()和parallelStream()两个方法,后续Collection的子类比如ArrayList、HashSet等都可以直接使用顶级父接口定义好的默认方法将自身集合转为Stream。

Collection:定义stream()/parallelStream()
    |--List
        |--ArrayList:list.stream().filter().map().collect(...)
        |--LinkedList:list.stream().filter().map().collect(...)
        |--Vector
    |--Set
        |--HashSet
        |--TreeSet

Stream

Java的集合在设计之初就只是一种容器,用来存储元素,内部并没有提供处理元素的方法。更多时候,我们其实是使用集合提供的遍历方法,然后手动在外部进行判断并处理元素。

Stream是什么呢?简单来说,可以理解为更高级的Iterator,把集合转为Stream后,我们就可以使用Stream对元素进行一系列操作

平时使用的filter()、map()、sorted()、collect()都来自:

方法太多了,记忆时需要带点技巧,比如分类记忆。

常用的有两种分类方法:

  • 终止/中间操作
  • 短路/非短路操作

前一种分类和我们关系大一些,所以我们按终止、中间操作分类记忆。

所谓中间操作和终止操作,可以粗略地理解为后面还能不能跟其他的方法。比如filter后面还可以跟map等操作,那么filter就是中间操作,而collect后返回的就是元素了,而不是流,无法继续使用。就好比一缕山泉,经过小草、小花、竹林,还是一缕水,但到了你的锅里煮成一碗粥,就没法继续使用了。

思维导图占位

还有几个不是特别常用的操作就不放在思维导图里了,这里简要介绍一下。比如,除了Collection接口中定义的stream()和parallelStream(),Stream也定义了创建流的方法(不常用):

还有一个合并流的方法(知道即可):

StreamSupport

没啥好介绍的,一般不会直接使用StreamSupport,Collection接口借助它实现了stream()和parallelStream()。

collect()、Collector、Collectors

直接演示:

public class StreamTest {

    private static List<Person> list;

    static {
        list = new ArrayList<>();
        list.add(new Person("甲", 18, "杭州", 999.9));
        list.add(new Person("乙", 19, "温州", 777.7));
        list.add(new Person("丙", 21, "杭州", 888.8));
        list.add(new Person("丁", 17, "宁波", 888.8));

    }

    public static void main(String[] args) {
        List<Person> result = list.stream()
                .filter(person -> person.getAge() > 20)
                .collect(Collectors.toList());
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    static class Person {
        private String name;
        private Integer age;
        private String address;
        private Double salary;
    }

}

collect()是用来收集处理后的元素的,它有两个重载的方法:

我们暂时只看下面那个,它接收一个Collector对象,而我们一般不会自己去new Collector对象,因为JDK给我提供了Collectors,可以调用Collectors提供的方法返回Collector对象:

所以collect()、Collector、Collectors三者的关系是:

collect()通过传入不同的Collector对象来明确如何收集元素,比如收集成List还是Set还是拼接字符串,而通常我们不需要自己实现Collector接口,只需要通过Collectors获取。

这倒颇有点像Executor和Executors的关系,一个是线程池接口,一个是线程池工具类。

如何高效学习Stream API

和几个重要接口、类混个脸熟后,我们来谈谈如何高效学习Stream API。很多同学应该已经被上面的内容吓到了,不要怕,通过后面的实操,整个知识脉络很快就会清晰起来。但还是那句话,一开始不要扣细节,先抓主干

初学者如果对stream的流式操作感到陌生,可以暂时理解为外部迭代(实际不是这样的,后面会大家一起观察):

特别注意reduce():

特别适合做累加、累乘啥的。

铺垫结束,接下来我们通过实战来正式学习Stream API。学习其他技术都可以追求理论深度,唯独Stream API就是一个字:干!

基础操作

map/filter

先来个最简单:

public class StreamTest {

    private static List<Person> list;

    static {
        list = new ArrayList<>();
        list.add(new Person("i", 18, "杭州", 999.9));
        list.add(new Person("am", 19, "温州", 777.7));
        list.add(new Person("iron", 21, "杭州", 888.8));
        list.add(new Person("man", 17, "宁波", 888.8));
    }

    public static void main(String[] args) {
        // 我们先学中间操作

        // 1.先获取流(不用管其他乱七八糟的创建方式,记住这一个就能应付95%的场景)
        Stream<Person> stream = list.stream();
        // 2.过滤得到年纪大于18岁的(filter表示过滤【得到】符合传入条件的元素,而不是过滤【去除】)
        Stream<Person> filteredByAgeStream = stream.filter(person -> person.getAge() > 18);
        // 3.只要名字,不需要整个Person对象(为什么在这个案例中,filter只能用Lambda,map却可以用方法引用?)
        Stream<String> nameStream = filteredByAgeStream.map(Person::getName);
        // 4.现在返回值是Stream<String>,没法直接使用,帮我收集成List<String>
        List<String> nameList = nameStream.collect(Collectors.toList());
        
        // 现在还对collect()为什么传递Collectors.toList()感到懵逼吗?
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    static class Person {
        private String name;
        private Integer age;
        private String address;
        private Double salary;
    }
}

sorted()

试着加入sorted()玩一下。

在此之前,我们先来见见一位老朋友:Comparator。这个接口其实早在JDK1.2就有了,但当时只有两个方法:

  • compare()
  • equals()

JDK1.8通过默认方法的形式引入了很多额外的方法,比如reversed()、comparing()等。

public class StreamTest {

    private static List<Person> list;

    static {
        list = new ArrayList<>();
        list.add(new Person("i", 18, "杭州", 999.9));
        list.add(new Person("am", 19, "温州", 777.7));
        list.add(new Person("iron", 21, "杭州", 888.8));
        list.add(new Person("man", 17, "宁波", 888.8));
    }

    public static void main(String[] args) {
        // JDK8之前:Collections工具类+匿名内部类。Collections类似于Arrays工具类,我经常用Arrays.asList()
        Collections.sort(list, new Comparator<Person>() {
            @Override
            public int compare(Person p1, Person p2) {
                return p1.getName().length()-p2.getName().length();
            }
        });
        
        // JDK8之前:List本身也实现了sort()
        list.sort(new Comparator<Person>() {
            @Override
            public int compare(Person p1, Person p2) {
                return p1.getName().length()-p2.getName().length();
            }
        });
        
        // JDK8之后:Lambda传参给Comparator接口,其实就是实现Comparator#compare()。注意,equals()是Object的,不妨碍
        list.sort((p1,p2)->p1.getName().length()-p2.getName().length());
        
        // JDK8之后:使用JDK1.8为Comparator接口新增的comparing()方法
        list.sort(Comparator.comparingInt(p -> p.getName().length()));
    }
    
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    static class Person {
        private String name;
        private Integer age;
        private String address;
        private Double salary;
    }
}

大家不好奇吗?sort()需要的是Comparator接口的实现,调用Comparator.comparingInt()怎么也可以?

传入一个转换器,将key转换为Integer,不传入比较规则的都是按照升序排列的

重点:Comparator.comparingXxx()返回的也是Comparator,comparingXxx()方法其实都来源于comparing方法:

该方法接收一个比较器,在比较器中比较两个经过mapper转换的数据,比较的规则就是compare方法的方法体,例如:

Comparator<Object> comparing =
		Comparator.comparing(person -> ((Person) person).getAge(), (age1, age2) -> age1 - age2);

所以上文中对List按照年龄升序排列也可以写为:

list.sort(Comparator.comparing(Function.identity(),Comparator.comparingInt(Person::getAge)));

OK,铺垫够了,来玩一下Stream#sorted(),看看和List#sort()有啥区别。

public class StreamTest {

    private static List<Person> list;

    static {
        list = new ArrayList<>();
        list.add(new Person("i", 18, "杭州", 999.9));
        list.add(new Person("am", 19, "温州", 777.7));
        list.add(new Person("iron", 21, "杭州", 888.8));
        list.add(new Person("man", 17, "宁波", 888.8));
    }

    public static void main(String[] args) {
        // 直接链式操作
        List<String> nameList = list.stream()
                .filter(person -> person.getAge() > 18)
                .map(Person::getName)
                .collect(Collectors.toList());
        System.out.println(nameList);

        // 我想按姓名长度排序
        List<String> sortedNameList = list.stream()
                .filter(person -> person.getAge() > 18)
                .map(Person::getName)
                .sorted()
                .collect(Collectors.toList());
        System.out.println(sortedNameList);

        // 你:我擦,说好的排序呢?

        // Stream:别扯淡,你告诉我排序规则了吗?(默认自然排序)

        // 明白了,那就按照长度倒序吧(注意细节啊,str2-str1才是倒序)
        List<String> realSortedNameList = list.stream()
                .filter(person -> person.getAge() > 18)
                .map(Person::getName)
                .sorted((str1, str2) -> str2.length() - str1.length())
                .collect(Collectors.toList());
        System.out.println(realSortedNameList);

        // 优化一下:我记得在之前那张很大的思维导图上看到过,sorted()有重载方法,是sorted(Comparator)
        // 上面Lambda其实就是调用sorted(Comparator),用Lambda给Comparator接口赋值
        // 但Comparator还供了一些方法,能返回Comparator实例
        List<String> optimizeNameList = list.stream()
                .filter(person -> person.getAge() > 18)
                .map(Person::getName)
                .sorted(Comparator.reverseOrder())
                .collect(Collectors.toList());
        System.out.println(optimizeNameList);

        // 又是一样的套路,Comparator.reverseOrder()返回的其实是一个Comparator!!

        // 但上面的有点投机取巧,来个正常点的,使用Comparator.comparing()
        List<String> result1 = list.stream()
                .filter(person -> person.getAge() > 18)
                .map(Person::getName)
                .sorted(Comparator.comparing(t -> t, (str1, str2) -> str2.length() - str1.length()))
                .collect(Collectors.toList());
        System.out.println(result1);

        // 我去,更麻烦了!!
        // 不急,我们先来了解上面案例中Comparator的两个参数
        // 第一个是Function映射,就是指定要排序的字段,由于经过上一步map操作,已经是name了,就不需要映射了,所以是t->t
        // 第二个是比较规则
        
        // 我们把map和sorted调换一下顺序,看起来就不那么别扭了
        List<String> nameList = list.stream()  
        .filter(person -> person.getAge() > 18)  
        .sorted(Comparator.comparing(Person::getName,(str1,str2) -> str2.length() - str1.length()))  
        .map(Person::getName)  
        .collect(Collectors.toList());  
		System.out.println(nameList);

		//但是想实现倒序排列,还可以这样做:
        List<String> result2 = list.stream()
                .filter(person -> person.getAge() > 18)
                .sorted(Comparator.comparing(Person::getName, String::compareTo).reversed())
                .map(Person::getName)
                .collect(Collectors.toList());
        System.out.println(result2);

        // 为什么Comparator.comparing().reversed()可以链式调用呢?
        // 上面说了哦,因为Comparator.comparing()返回的还是Comparator对象~
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    static class Person {
        private String name;
        private Integer age;
        private String address;
        private Double salary;
    }
}

需要注意的是:

Comparator.comparing(Person::getName, String::compareTo) 

进行升序排列,如果要降序可以自定义,或者可以在升序排列后再链式调用reverse方法,该方法:

会将当前元素反转

limit/skip

public class StreamTest {

    private static List<Person> list;

    static {
        list = new ArrayList<>();
        list.add(new Person("i", 18, "杭州", 999.9));
        list.add(new Person("am", 19, "温州", 777.7));
        list.add(new Person("iron", 21, "杭州", 888.8));
        list.add(new Person("man", 17, "宁波", 888.8));
    }

    public static void main(String[] args) {
        List<String> result = list.stream()
                .filter(person -> person.getAge() > 17)
                // peek()先不用管,它不会影响整个流程,就是打印看看filter操作后还剩什么元素
                .peek(person -> System.out.println(person.getName()))
                .skip(1)
                .limit(2)
                .map(Person::getName)
                .collect(Collectors.toList());
        System.out.println(result);
    }
    
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    static class Person {
        private String name;
        private Integer age;
        private String address;
        private Double salary;
    }
}

结果

==== 过滤后的元素有3个 ====

i

am

iron

==== skip(1)+limit(2)后的元素 ====

[am, iron]

collect

collect()是最重要、最难掌握、同时也是功能最丰富的方法。

最常用的4个方法:Collectors.toList()、Collectors.toSet()、Collectors.toMap()、Collectors.joining()

public class StreamTest {

    private static List<Person> list;

    static {
        list = new ArrayList<>();
        list.add(new Person("i", 18, "杭州", 999.9));
        list.add(new Person("am", 19, "温州", 777.7));
        list.add(new Person("iron", 21, "杭州", 888.8));
        list.add(new Person("man", 17, "宁波", 888.8));
    }

    public static void main(String[] args) {
        // 最常用的4个方法

        // 把结果收集为List
        List<String> toList = list.stream().map(Person::getAddress).collect(Collectors.toList());
        System.out.println(toList);
        
        // 把结果收集为Set
        Set<String> toSet = list.stream().map(Person::getAddress).collect(Collectors.toSet());
        System.out.println(toSet);
        
        // 把结果收集为Map,前面的是key,后面的是value,如果你希望value是具体的某个字段,可以改为toMap(Person::getName, person -> person.getAge())
        Map<String, Person> nameToPersonMap = 
	        list.stream().collect(Collectors.toMap(Person::getName, person -> person));
        System.out.println(nameToPersonMap);

        // 把结果收集起来,并用指定分隔符拼接
        String result = list.stream().map(Person::getAddress).collect(Collectors.joining("~"));
        System.out.println(result);
    }
}

关于collect收集成Map的操作,有一个小坑需要注意:

public class StreamTest {

    private static List<Person> list;

    static {
        list = new ArrayList<>();
        list.add(new Person("i", 18, "杭州", 999.9));
        list.add(new Person("am", 19, "温州", 777.7));
        list.add(new Person("iron", 21, "杭州", 888.8));
        list.add(new Person("iron", 17, "宁波", 888.8));
    }

    public static void main(String[] args) {
        Map<String, Person> nameToPersonMap = 
	        list.stream().collect(Collectors.toMap(Person::getName, person -> person));
        System.out.println(nameToPersonMap);
    }
}

尝试运行上面的代码,会观察到如下异常:

Exception in thread “main” java.lang.IllegalStateException: Duplicate key Person

这是因为toMap()不允许key重复,我们必须指定key冲突时的value解决策略(比如,保留已存在的value)

public static void main(String[] args) {
    Map<String, Person> nameToPersonMap = 
			list.stream()
            .collect(Collectors.toMap(Person::getName, person -> person, (preValue, nextValue) -> preValue));
    System.out.println(nameToPersonMap);
}

如果你希望key覆盖,可以把(preKey, nextKey) -> preKey)换成(preKey, nextKey) -> nextKey)。

你可能会在同事的代码中发现另一种写法:

public static void main(String[] args) {
    Map<String, Person> nameToPersonMap = 
			    list.stream().collect(Collectors.toMap(Person::getName, Function.identity());
    System.out.println(nameToPersonMap);
}

Function.identity()其实就是v->v:

但它依然没有解决key冲突的问题,而且对于大部分人来说,相比person->person,Function.identity()的可读性不佳。

聚合:max/min/count

max/min

public class StreamTest {

    private static List<Person> list;

    static {
        list = new ArrayList<>();
        list.add(new Person("i", 18, "杭州", 999.9));
        list.add(new Person("am", 19, "温州", 777.7));
        list.add(new Person("iron", 21, "杭州", 888.8));
        list.add(new Person("man", 17, "宁波", 888.8));
    }

    public static void main(String[] args) {
		// 匿名内部类的方式,实现Comparator,明确按什么规则比较(所谓最大,必然是在某种规则下的最值)  
		//Optional<Integer> maxAge = 
		//             		list.stream().map(Person::getAge).max((age1,age2) -> age1 - age2);  
		
		Optional<Integer> maxAge = 
					list.stream().map(Person::getAge).max(Comparator.comparingInt(age -> age));  
		System.out.println(maxAge.orElse(0));  
		  
		Optional<Integer> max = list.stream().map(Person::getAge).max(Integer::compareTo);  
		System.out.println(max.orElse(0));
    }
    
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    static class Person {
        private String name;
        private Integer age;
        private String address;
        private Double salary;
    }
}

count

public static void main(String[] args) {
    long count = list.stream().filter(person -> person.getAge() > 18).count();
    System.out.println(count);
}

去重:distinct

public class StreamTest {

    private static List<Person> list;

    static {
        list = new ArrayList<>();
        list.add(new Person("i", 18, "杭州", 999.9));
        list.add(new Person("am", 19, "温州", 777.7));
        list.add(new Person("iron", 21, "杭州", 888.8));
        list.add(new Person("man", 17, "宁波", 888.8));
    }

    public static void main(String[] args) {
        long count = list.stream().map(Person::getAddress).distinct().count();
        System.out.println(count);
    }
    
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    static class Person {
        private String name;
        private Integer age;
        private String address;
        private Double salary;
    }
}

所谓“去重”,就要明确怎样才算“重复”。那么,distinct()是基于什么标准呢?

还是那两样:hashCode()和equals(),所以记得重写这两个方法(一般使用Lombok的话问题不大)。

distinct()提供的去重功能比较简单,就是判断对象重复。如果希望实现更细粒度的去重,比如根据对象的某个属性去重,可以参考:分享几种 Java8 中通过 Stream 对列表进行去重的方法

forEach

public class StreamTest {

    private static List<Person> list;

    static {
        list = new ArrayList<>();
        list.add(new Person("i", 18, "杭州", 999.9));
        list.add(new Person("am", 19, "温州", 777.7));
        list.add(new Person("iron", 21, "杭州", 888.8));
        list.add(new Person("man", 17, "宁波", 888.8));
    }

    public static void main(String[] args) {
        // 遍历操作,接收Consumer
        list.stream().forEach(System.out::println);
        // 简化,本质上不算同一个方法的简化
        list.forEach(System.out::println);
    }
    
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    static class Person {
        private String name;
        private Integer age;
        private String address;
        private Double salary;
    }
}

需要的就是一个Consumer参数

高阶操作

两部分内容:

  • 深化一下collect()方法,它还有很多其他玩法
  • 介绍flatMap、reduce、匹配查找、peek、forEach等边角料

collect高阶操作

聚合

public class StreamTest {

    private static List<Person> list;

    static {
        list = new ArrayList<>();
        list.add(new Person("i", 18, "杭州", 999.9));
        list.add(new Person("am", 19, "温州", 777.7));
        list.add(new Person("iron", 21, "杭州", 888.8));
        list.add(new Person("man", 17, "宁波", 888.8));
    }

    /**
     * 演示用collect()方法实现聚合操作,对标max()、min()、count()
     * @param args
     */
    public static void main(String[] args) {
        // 方式1:匿名对象
        Optional<Person> max1 = list.stream().collect(Collectors.maxBy(new Comparator<Person>() {
            @Override
            public int compare(Person p1, Person p2) {
                return p1.getAge() - p2.getAge();
            }
        }));
        System.out.println(max1.orElse(null));

        // 方式2:Lambda
        Optional<Person> max2 = 
			        list.stream().collect(Collectors.maxBy((p1, p2) -> p1.getAge() - p2.getAge()));
        System.out.println(max2.orElse(null));

        // 方式3:方法引用
        Optional<Person> max3 = 
			        list.stream().collect(Collectors.maxBy(Comparator.comparingInt(Person::getAge)));
        System.out.println(max3.orElse(null));
	    //Optional<Person> max1 = 
//list.stream().collect(Collectors.maxBy(Comparator.comparing(Person::getSalary,Double::compareTo)));

        // 方式4:IDEA建议直接使用 max(),不要用 collect(Collector)
        Optional<Person> max4 = list.stream().max(Comparator.comparingInt(Person::getAge));
        System.out.println(max4.orElse(null));
        
        // 特别是方式3和方式4,可以看做collect()聚合和max()聚合的对比
        
        // 剩下的minBy和counting

        Optional<Person> min1 = 
			        list.stream().collect(Collectors.minBy(Comparator.comparingInt(Person::getAge)));
        Optional<Person> min2 = list.stream().min(Comparator.comparingInt(Person::getAge));

        Long count1 = list.stream().collect(Collectors.counting());
        Long count2 = list.stream().count();
    }
    
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    static class Person {
        private String name;
        private Integer age;
        private String address;
        private Double salary;
    }
}

分组

public class StreamTest {

    private static List<Person> list;

    static {
        list = new ArrayList<>();
        list.add(new Person("i", 18, "杭州", 999.9));
        list.add(new Person("am", 19, "温州", 777.7));
        list.add(new Person("iron", 21, "杭州", 888.8));
        list.add(new Person("man", 17, "宁波", 888.8));
    }

    /**
     * 按字段分组
     * 按条件分组
     *
     * @param args
     */
    public static void main(String[] args) {
        // GROUP BY address
        Map<String, List<Person>> groupingByAddress = 
        list.stream().collect(Collectors.groupingBy(Person::getAddress));
        System.out.println(groupingByAddress);

        // GROUP BY address, age
        Map<String, Map<Integer, List<Person>>> doubleGroupingBy = list.stream()
                .collect(Collectors.groupingBy(Person::getAddress, Collectors.groupingBy(Person::getAge)));
        System.out.println(doubleGroupingBy);

        // 简单来说,就是collect(groupingBy(xx)) 扩展为 collect(groupingBy(xx, groupingBy(yy))),嵌套分组

        // 解决了按字段分组、按多个字段分组,我们再考虑一个问题:有时我们分组的条件不是某个字段,而是某个字段是否满足xx条件
        // 比如 年龄大于等于18的是成年人,小于18的是未成年人
        Map<Boolean, List<Person>> adultsAndTeenagers = list.stream().collect(Collectors.partitioningBy(person -> person.getAge() >= 18));
        System.out.println(adultsAndTeenagers);
    }
    
   @Data
    @AllArgsConstructor
    @NoArgsConstructor
    static class Person {
        private String name;
        private Integer age;
        private String address;
        private Double salary;
    }
}

提一句,collect()方法是最为丰富的,可以搭配Collector玩出很多花样,特别是经过各种嵌套组合。本文末尾留了几道思考题,大家到时可以试着做做。

统计

public class StreamTest {

    private static List<Person> list;

    static {
        list = new ArrayList<>();
        list.add(new Person("i", 18, "杭州", 999.9));
        list.add(new Person("am", 19, "温州", 777.7));
        list.add(new Person("iron", 21, "杭州", 888.8));
        list.add(new Person("man", 17, "宁波", 888.8));
    }

    /**
     * 统计
     * @param args
     */
    public static void main(String[] args) {
        // 平均年龄
        Double averageAge = list.stream().collect(Collectors.averagingInt(Person::getAge));
        System.out.println(averageAge);

        // 平均薪资
        Double averageSalary = list.stream().collect(Collectors.averagingDouble(Person::getSalary));
        System.out.println(averageSalary);
        
        // 其他的不演示了,大家自己看api提示。简而言之,就是返回某个字段在某个纬度的统计结果
        
        // 有个更绝的,针对某项数据,一次性返回多个纬度的统计结果:总和、平均数、最大值、最小值、总数,但一般用的很少
        IntSummaryStatistics allSummaryData = list.stream().collect(Collectors.summarizingInt(Person::getAge));
        long sum = allSummaryData.getSum();
        double average = allSummaryData.getAverage();
        int max = allSummaryData.getMax();
        int min = allSummaryData.getMin();
        long count = allSummaryData.getCount();
    }
    
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    static class Person {
        private String name;
        private Integer age;
        private String address;
        private Double salary;
    }
}

flatMap

总的来说,就是flatMap就是把多个流合并成一个流:

public class StreamTest {

    private static List<Person> list;

    static {
        list = new ArrayList<>();
        list.add(new Person("i", 18, "杭州", 999.9, new ArrayList<>(Arrays.asList("成年人", "学生", "男性"))));
        list.add(new Person("am", 19, "温州", 777.7, new ArrayList<>(Arrays.asList("成年人", "打工人", "宇宙最帅"))));
        list.add(new Person("iron", 21, "杭州", 888.8, new ArrayList<>(Arrays.asList("喜欢打篮球", "学生"))));
        list.add(new Person("man", 17, "宁波", 888.8, new ArrayList<>(Arrays.asList("未成年人", "家里有矿"))));
    }

    public static void main(String[] args) {
        Set<String> allTags = 
        list.stream().flatMap(person -> person.getTags().stream()).collect(Collectors.toSet());
        System.out.println(allTags);
    }
    

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    static class Person {
        private String name;
        private Integer age;
        private String address;
        private Double salary;
        // 个人标签
        private List<String> tags;
    }
}

对于每个Person对象中的tags,如果没有flatMap,想要合并去重会比较麻烦。

对比map和flatMap:

flatMap传入的Function要求返回值是Stream<? extends R>而不是R,所以上面写的是:

map是将对象的类型转换,flatMap是将对象的成员属性变为流,集合中的多个流合并为一个流。

去重也可以:

List<String> allTags = list.stream().flatMap(person -> person.getTags().stream()).distinct().toList();  
System.out.println(allTags);

总之,当你遇到List中还有List,然后你又想把第二层的List都拎出来集中处理时,就可以考虑用flatMap(),先把层级打平,再统一处理。

peek()

它接受一个Consumer,一般有两种用法:

  • 设置值
  • 观察数据

设置值的用法:

public class StreamTest {
    public static void main(String[] args) {
        list.stream().peek(person -> person.setAge(18)).forEach(System.out::println);
    }
}

也就是把所有人的年龄设置为18岁。

peek这个单词本身就带有“观察”的意思。

简单来说,就是查看数据,一般实际开发很少用,但可以用来观察数据的流转:

public class StreamTest {
    public static void main(String[] args) {
        Stream<Integer> stream = Stream.of(1, 2, 3);
        stream.peek(v-> System.out.print(v+","))
				        .map(value -> value + 100)
				        .peek(v-> System.out.print(v+","))
					    .forEach(System.out::println);
    }
    
}

结果

1,101,101

2,102,102

3,103,103

有没有感到好奇?

用图表示的话,就是这样:

通过peek,我们观察到每一个元素都是逐个通过Stream流的。

为了看得更清楚些,重新写个demo:

public static void main(String[] args) {
    Stream.of(1, 2, 3, 4)
            .peek(v -> System.out.print(v + ","))
            .filter(v -> v >= 2)
            .peek(v -> System.out.print(v + ","))
            .filter(v -> v >= 3)
            .forEach(System.out::println);
            //1,2,2,3,3,3
            //4,4,4
}

结果

1,2,2,3,3,3

4,4,4

这打印,怎么这么诡异?

其实不诡异,元素确实是逐个通过的:

一个元素如果中途被过滤,就不会继续往下,换下一个元素。最终串起来,就会打印:

1,2,2,3,3,3

4,4,4

大家思考一下,真正的Stream和我们之前山寨的Stream,遍历时有何不同?能画图解释一下吗?

元素一个个通过关卡

元素一起通过一个个关卡

匹配、查找

findFirst

public static void main(String[] args) {
    Optional<Integer> first = Stream.of(1, 2, 3, 4)
            .peek(v -> System.out.print(v + ","))
            .findFirst();
}

结果:

1,

只要第一个,后续元素不会继续遍历。找到的元素在Optional中

findAny

public static void main(String[] args) {
    Optional<Integer> any = Stream.of(1, 2, 3, 4)
            .peek(v -> System.out.print(v + ","))
            .findAny();
}

结果:

1,

findFirst和findAny几乎一样,但如果是并行流,结果可能不一致:

并行流颇有种“分而治之”的味道(底层forkjoin线程池),将流拆分并行处理,能较大限度利用计算资源,提高工作效率。但要注意,如果当前操作对顺序有要求,可能并不适合使用parallelStream。比如上图右边,使用并行流后返回的可能是4而不是1。

关于findFirst()和findAny()有没有人觉得这方法很傻逼?其实是我demo举的不对,通常来说应该是一堆filter操作任取一个等场景下使用。比如list.stream().filter(student->student.getAge()>18).findFirst(),即符合条件的任选一个。

allMatch

public static void main(String[] args) {
    boolean b = Stream.of(1, 2, 3, 4)
        .peek(v -> System.out.print(v + ","))
        .allMatch(v -> v > 2);
}

结果

1,

由于是要allMatch,第一个就不符合,那么其他元素也就没必要测试了。这是一个短路操作。

就好比:

if(0>1 && 2>1){
    // 2>1 不会被执行,因为0>1不成立,所以2>1被短路了
}

noneMatch

public static void main(String[] args) {
    boolean b = Stream.of(1, 2, 3, 4)
            .peek(v -> System.out.print(v + ","))
            .noneMatch(v -> v >= 2);
}

结果

1,2,

和allMatch一样,它期望的是没有一个满足,而2>=2已经false,后面元素即使都大于2也不影响最终结果:noneMatch=false,所以也是个短路操作。

anyMatch

reduce

略,实际开发没用过,大家可以在熟悉上面的用法后再去了解,否则可能比较乱。

Stream API的效率问题

代码见附件:StreamTimeTest.java

大家把测试案例拷贝到本地test包下运行看看,相信心中有判断。虽然上面的案例中没有测试list.stream.filter().map.dictinc()等连环操作,但结果应该差不多。

但我想说的是,绝大部分时候代码的可读性应该优先于性能,况且Stream API性能并不差。和传统代码相比,Stream API让程序员专注于实现的步骤而不是细节,大大提高了代码的可读性。

Integer minPrice = itemSkuPriceTOS.stream()
                .sorted(Comparator.comparingLong(ItemSkuPriceTO::getPrice)) // 先正序排列
                .findFirst()                              // 找到第一个,也就是价格最低的item
                .map(ItemSkuPriceTO::getPrice)                              // 得到item的价格
                .orElse(0);                                                 // 兜底处理

如果你习惯了Stream API,上面的代码在可读性上会比用for循环实现好得多,所以能用Stream API的尽量用Stream API吧。毕竟上面测试用了1000w数据差距也就几十毫秒,更别说实际项目中可能就几十条了。

Stream API练习

实验数据:

public class StreamTest {

    private static List<Person> list;

    static {
        list = new ArrayList<>();
        list.add(new Person("i", 18, "杭州", 999.9));
        list.add(new Person("am", 19, "温州", 777.7));
        list.add(new Person("iron", 21, "杭州", 888.8));
        list.add(new Person("iron", 17, "宁波", 888.8));
    }
    
    public static void main(String[] args) {
        
    }
    
    @Getter
    @Setter
    @AllArgsConstructor
    static class Person {
        private String name;
        private Integer age;
        private String address;
        private Double salary;

        @Override
        public String toString() {
            return "Person{" +
                    "name='" + name + '\'' +
                    ", age=" + age +
                    ", address='" + address + '\'' +
                    ", salary=" + salary +
                    '}';
        }
    }
}

map

获取所有的Person的名字

List<String> personNames = list.stream().map(Person::getName).collect(Collectors.toList());  
System.out.println(personNames);

获取一个List,每个元素的内容为:{name}来自{address}

List<String> nameFromAddress = list.stream().map(person -> person.getName() + "来自" + person.getAddress()).collect(Collectors.toList());  
System.out.println(nameFromAddress);

filter

过滤出年龄大于等于18 并且 月薪大于等于888.8 并且 来自杭州的Person

// 一般写法(这个filter,完全就是强迫别人花时间从头到尾阅读你代码里的细节,而且毫无提示!)
public static void main(String[] args) {
    List<Person> personList = list.stream()
            .filter(person -> (person.getAge() >= 18 && person.getSalary() > 888.8 && "杭州".equals(person.getAddres()))
            .collect(Collectors.toList());
    System.out.println(personList);
}

// 较好的写法:当我们需要给filter()、map()等函数式接口传递Lambda参数时,逻辑如果很复杂,最好抽取成方法,优先保证可读性
public static void main(String[] args) {
    List<Person> personList = list.stream()
            .filter(StreamTest::isRichAdultInHangZhou) // 改为方法引用,见名知意,隐藏琐碎的细节
            .collect(Collectors.toList());
    System.out.println(personList);
}

/**
 * 是否杭州有钱人
 *
 * @param person
 * @return
 */
private static boolean isRichAdultInHangZhou(Person person) {
    return person.getAge() >= 18
            && person.getSalary() > 888.8
            && "杭州".equals(person.getAddres());
}

统计

获取年龄的min、max、sum、average、count。

很多人看到这个问题,会条件反射地想到先用map()把Person降到Age纬度,然后看看有没有类似min()、max()等方法,思路很合理。

然后会发现:map()后只提供了max()、min()和count()三个方法(且max、min需要传递Comparator对象),没有average()和sum()。

为什么会设计得这么“麻烦”且看起来这么不合理呢?

Stream作为接口,首要任务是保证方法通用性。由于Stream需要处理的元素是不确定的,这次是Integer,下次可能是String,甚至是自定义的Person,所以要使用Stream计算min、max时,必须告诉它“怎样才是最大”、“怎样才是最小”,具体到代码里就是策略模式,需要我们传入比较规则,也就是Comparator对象。

这其实是很合理的,比如当我们把两个人进行比较时,其实都是基于某一个/某一组评判标准的,比如外貌方面,我比吴彦祖帅,或者从身高来看,我比姚明高。所有的比较结果,必须在某一个比较规则之下才成立、才有意义。

至于sum()和average(),Stream干脆没定义…这又是为什么?你想想,如果map()后得到的是name,对于字符类型来说sum()和average()是没有意义的,因为你也说不出一串名字的平均值代表着什么。也就是说sum()和average()不是通用的,不应该定义在Stream接口中。

一句话,Stream接口只提供了通用的max()、min()、count(),而且要自己指定Comparator(策略模式)。

但是,如果你能确定元素的类型,比如int、long啥的,那么可以选择对应的mapToInt()、mapToLong()等方法得到具体类型的Stream:

比如得到IntStream后,多了几个方法:

转为具体类型的Stream有以下好处:

  • 不用传递Comparator,你都mapToInt()了,那么元素的类型也就确定了,比较的规则自然也确定了(自然数比较)
  • 额外新增sum()、average(),因为对于数字类型来说,求和、求平均是有意义的

也就是说,如果你要操作的数据刚好是int、long、double,那么不妨转为IntStream、LongStream、DoubleStream,api会得到相应的增强!

总结:

如果你想要做通用的数据处理,直接使用map()等方法即可,此时返回的是Stream,提供的都是通用的操作。但如果要统计的数据类型恰好是int、long、double,那么使用mapToXxx()方法转为具体的Stream类型后,方法更多更强大,处理起来更为方便!

看到这,大家应该知道这道题这么解了,就不贴代码了。特别注意下最后那个summaryStatistics():五合一。

其实,关于统计的方法,Stream共提供了3大类API,后面会总结。有时选择太多也是一种罪过,难怪有些人会感到混乱。

查找

查找其实分为两大类操作:

  • 查找并返回目标值,以findFirst()为代表
  • 告诉我有没有即可,无需返回目标值,以anyMatch()为代表
public static void main(String[] args) {
    Optional<Person> personMatch = list.stream()
            .filter(person -> "宁波".equals(person.getAddress())) // 经过一些筛选,看看有没有符合条件的元素
            .findFirst();
    personMatch.ifPresent(System.out::println); // 不了解Optional的同学,可以去看后面关于Optional的章节
}
public static void main(String[] args) {
    // 但有时候你并不关心符合条件的是哪个或哪些元素,只想知道有没有,此时anyMatch()更合适,代码会精炼很多
    boolean exists = list.stream().anyMatch(person -> "宁波".equals(person.getAddress()));
}

其他3个方法:findAny()、noneMatch()、allMatch()就不介绍了。

collect()

最牛逼的一个方法,当你想要实现某种操作却一时找不到具体方法时,一般都是在collect()里,因为它玩法实在太多了!

List转List:Collectors.toList()

所谓List转List,一般指的是原List经过filter()、sorted()、map()、limit()等一顿操作后,最终还是以List的形式返回。

public static void main(String[] args) {
    List<String> top2Adult = list.stream()
            .filter(person -> person.getAge() >= 18)          // 过滤得到年龄大于等于18岁的人
            .sorted(Comparator.comparingInt(Person::getAge))  // 按年龄排序
            .map(Person::getName)                             // 得到姓名
            .limit(2)                                         // 取前两个数据
            .collect(Collectors.toList());                    // 得到List<String> names
    System.out.println(top2Adult);
}

问一个问题:Collectors.toList()默认返回的是ArrayList,如何指定返回LinkedList或其他类型呢?答案见下方练习题。

List转Map:Collectors.toMap()

list转map,一般来说关注两个点:

  • 转化后,你要以什么作为key,以什么作为value
  • 出现key冲突时怎么解决(保留旧的值还是覆盖旧的值)
public static void main(String[] args) {
    Map<String, Person> personMap = list.stream().collect(Collectors.toMap(
        Person::getName,    // 以name作为key
        person -> person,   // person->person表示保留整个person作为value
        (pre, next) -> pre  // (pre, next) -> pre)表示key冲突时保留旧值
    ));
    System.out.println(personMap);
}

// 如果你只需要person的部分数据作为value,比如address
public static void main(String[] args) {
    Map<String, String> personMap = list.stream().collect(Collectors.toMap(
        Person::getName, 
        Person::getAddress, 
        (pre, next) -> pre
    ));
    System.out.println(personMap);
}

有时你会见到同事这样写:

public static void main(String[] args) {
    // Function.identity() 本质上等于 person->person
    Map<String, Person> personMap = list.stream().collect(Collectors.toMap(
        Person::getName, 
        Function.identity()
    ));
    System.out.println(personMap);
}

// Function接口定义的方法
static <T> Function<T, T> identity() {
    return t -> t;
}

Function.identity()只是person->person的另类写法,易读性并不好,而且并没有指定key冲突策略:

所以,即使使用Function.identity(),仍需要手动指定key冲突策略:

public static void main(String[] args) {
    Map<String, Person> personMap = list.stream().collect(Collectors.toMap(
        Person::getName, 
        Function.identity(),  // 看起来很酷,其实就是v->v,甚至不如v->v直观
        (pre, next) -> pre
    ));
    System.out.println(personMap);
}

List转Set:Collectors.toSet()

主要目的是利用Set的特性去重。以最常用的HashSet为例,你是否清楚为什么用HashSet存储自定义对象时,要求重写hashCode()和equals()?

因为HashSet本质还是HashMap,只不过HashSet的value是空,只利用了key(HashSet是单列集合,而HashMap是双列集合)。

使用却很简单:

public static void main(String[] args) {
    // String、Integer这些类本身重写了hashCode()和equals(),可以直接toSet()
    Set<String> names = list.stream().map(Person::getName).collect(Collectors.toSet());
    System.out.println(names);

    // 如果你要对自定义的对象去重,比如Person,那么你必须重写hashCode()和equals()
    Set<Person> persons = list.stream().collect(Collectors.toSet());
    System.out.println(persons);
    
    // 一般来说,用到Collectors.toSet()之前,也是filter()等一顿操作,最后希望去重。像上面那样单纯地想得到Set,可以简单点
    Set<Person> anotherPersons = new HashSet<>(list);
    System.out.println(anotherPersons);
}

@Getter
@Setter
// 利用Lombok的注解,如果有条件建议手写。Lombok默认把所有字段加入计算,但实际上你可能只需要计算id和name就能确定唯一性
@EqualsAndHashCode
@AllArgsConstructor
static class Person {
    private String name;
    private Integer age;
    private String address;
    private Double salary;
}

去重

和Collectors.toSet()一样,但凡要去重,最关键的就是“怎么判断两个对象是否相同”,于是必须明确“怎样才算相同”。在Java中通常有两种做法:

  • 重写hashCode()和equals()
  • 指定Comparator(比较器)

Java8以后Stream API专门提供了distinct()方法用来去重,底层就是根据元素hashCode()和equals方法判断是否相同,然后再去重。

public static void main(String[] args) {
    // 如果不重写Person的hashCode()和equals(),去重无效!!!
    List<Person> persons = list.stream().distinct().collect(Collectors.toList());
    System.out.println(persons);
}

你可能感到诧异:不对啊,我平时工作就是这么去重的。其实你平时的写法是这样的:

public static void main(String[] args) {
    // Integer、String这些基础包装类已经重写了hashCode()和equals()
    List<String> personNameList = list.stream().map(Person::getName).distinct().collect(Collectors.toList());
    System.out.println(personNameList);
}

有时我们的判断标准是,只要某些字段相同就去重(比如name),该怎么做呢?(注意,我希望的是按name去重,但最终得到的还是PersonList,而不是PersonNameList,所以上面的方法行不通)

第一种办法,仍然重写hashCode()和equals(),但只选择需要的字段(比如你只根据name作为判断标准)。

public static void main(String[] args) {
    List<Person> persons = list.stream().distinct().collect(Collectors.toList());
    System.out.println(persons);
}


@Getter
@Setter
@AllArgsConstructor
static class Person  {
    private String name;
    private Integer age;
    private String address;
    private Double salary;

    // IDEA自动生成的
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return name.equals(person.name);
    }

    // IDEA自动生成的
    @Override
    public int hashCode() {
        return Objects.hash(name);
    }

    // 不用理会,只是方便打印观察
    @Override
    public String toString() {
        return "Person{" +
                "name='" + name + '\'' +
                ", age=" + age +
                ", address='" + address + '\'' +
                ", salary=" + salary +
                '}';
    }

}

但这个方法有个缺点,如果这个Pojo不止你一个人用,直接重写hashCode()和equals()可能会影响到别人。

另外hashSet、hashMap这种底层是hash结构的容器,在去重时也会依赖hashCode()和equals()。比如你希望根据name去重,而name是String类型,本身是重写了hashCode()和equals()的,那么可以根据name先去重。比如我们可以利用HashMap完成去重(HashSet同理):

public static void main(String[] args) {
    // 先通过Map去重,只保留key不同的对象。
    Map<String, Person> personMap = list.stream().collect(Collectors.toMap(
        Person::getName,   // 用person.name做key,由于key不能重复,即根据name去重
        p -> p,            // map的value就是person对象本身
        (pre, next) -> pre // key冲突策略:key冲突时保留前者(根据实际需求调整)
    ));
    
    // 然后收集value即可(特别注意,hash去重后得到的person不保证原来的顺序)
    List<Person> peoples = new ArrayList<>(personMap.values());
    System.out.println(peoples);
}

使用linkedHashSet即可保证顺序,使用collectingAndThen就可以将其转变为List集合

可能还有其他方法,但顶多形式看起来不一样,个人认为底层思路都是一样的:hashCode()和equals()。

介绍完hashCode()和equals()这个派系以后,我们再来说说Comparator,尤其是对于TreeSet这样的容器:

public static void main(String[] args) {
    // 先把元素赶到TreeSet中(根据Comparator去重),然后再倒腾回ArrayList
    List<Person> list = list.stream().collect(
            Collectors.collectingAndThen(
                    Collectors.toCollection(() -> new TreeSet<>(Comparator.comparing(Person::getName))),
                    ArrayList::new
            )
    );
}

在创建集合时通过Supplier<C> collectionFactory 指定创建出一个集合

在TreeSet的构造方法中可以指定比较器,该比较器决定了TreeSet的排序规则

上面介绍的几种方式,去重后顺序会打乱。关于去重后如何保持元素顺序,网上有很多方法:

https://www.cnblogs.com/better-farther-world2099/articles/11905740.html

要做到去重并保持顺序,光靠Stream似乎有点无能为力,也显得比较啰嗦。还记得之前封装的ConvertUtil吗?不妨往里面再加一个方法:

/**
 * 去重(保持顺序)
 *
 * @param originList           原数据
 * @param distinctKeyExtractor 去重规则
 * @param <T>
 * @param <K>
 * @return
 */
public static <T, K> List<T> removeDuplication(List<T> originList, Function<T, K> distinctKeyExtractor) {
    LinkedHashMap<K, T> resultMap = new LinkedHashMap<>(originList.size());
    for (T item : originList) {
        K distinctKey = distinctKeyExtractor.apply(item);
        if (resultMap.containsKey(distinctKey)) {
            continue;
        }
        resultMap.put(distinctKey, item);
    }
    return new ArrayList<>(resultMap.values());
}

总的来说,去重通常有两类做法:要么通过重写hashCode和equals,要么传入Comparator(Comparable接口也行)。

其实也是借助了LinkedHashMap的特性来完成排序

分组

Stream的分组有两类操作:

  • 字段分组

  • 条件分组

先看字段分组。所谓的groupingBy(),和MySQL的GROUP BY很类似,比如:

public static void main(String[] args) throws JsonProcessingException {
    // 简单版
    Map<String, List<Person>> result = list.stream().collect(Collectors.groupingBy(Person::getAddress));
    System.out.println(new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(result));
}

得到的结果类似于:

{
    "温州": [
        {
            "name": "am",
            "age": 19,
            "address": "温州",
            "salary": 777.7
        }
    ],
    "宁波": [
        {
            "name": "iron",
            "age": 17,
            "address": "宁波",
            "salary": 888.8
        }
    ],
    "杭州": [
        {
            "name": "i",
            "age": 18,
            "address": "杭州",
            "salary": 999.9
        },
        {
            "name": "iron",
            "age": 21,
            "address": "杭州",
            "salary": 888.8
        }
    ]
}

总的来说,groupingBy()的最终结果是Map,key是分组的字段,value是属于该分组的所有元素集合,默认是List。

为什么我说默认是List呢?因为我们还可以自行指定将分组元素收集成到什么容器中,比如Set:

public static void main(String[] args) throws JsonProcessingException {
    // groupingBy()还可以传入第二个参数,指定如何收集元素
    Map<String, Set<Person>> result = list.stream()
							    .collect(Collectors.groupingBy(Person::getAddress, Collectors.toSet()));
    System.out.println(new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(result));
}

如果你期望得到的是各个城市的年龄构成呢?比如:

{
    "温州": [
        19
    ],
    "宁波": [
        17
    ],
    "杭州": [
        18,
        21
    ]
}

做法是,可以把Collectors.toSet()替换成Collectors.mapping(Function mapper,Collector),然后进行嵌套:

public static void main(String[] args) throws JsonProcessingException {
    // 进阶版版
    Map<String, List<Integer>> result = list.stream().collect(Collectors.groupingBy(
            Person::getAddress,                                        // 以Address分组
            Collectors.mapping(Person::getAge, Collectors.toList()))   // mapping()的做法是先映射再收集
    );
    System.out.println(new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(result));
}

其他过于复杂的api就不再介绍了,太多了反而会乱。实际开发时,如果遇到某个场景,可以自行百度,一般都能解决问题。总之要记住,collect()里面经常可以通过“套娃”操作完成复杂的需求。

有了这么好用的groupingBy(),为啥还需要partitioningBy()呢?因为groupingBy()也是有局限性的,它不能自定义“分组条件”。比如,如果你的分组条件是:

  • 年龄大于18岁 && 来自杭州 的分为一组
  • 其他的分为另一组

那么groupingBy()就无能为力了。

此时,支持自定义分组条件的partitioningBy()就派上用场:

public static void main(String[] args) throws JsonProcessingException {
    // 简单版
    Map<Boolean, List<Person>> result = 
						    list.stream().collect(Collectors.partitioningBy(StreamTest::condition));
    System.out.println(new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(result));
}

// 年龄大于18,且来自杭州
private static boolean condition(Person person) {
    return person.getAge() > 18
        && "杭州".equals(person.getAddress());
}
{
    "false": [
        {
            "name": "i",
            "age": 18,
            "address": "杭州",
            "salary": 999.9
        },
        {
            "name": "am",
            "age": 19,
            "address": "温州",
            "salary": 777.7
        },
        {
            "name": "iron",
            "age": 17,
            "address": "宁波",
            "salary": 888.8
        }
    ],
    "true": [
        {
            "name": "iron",
            "age": 21,
            "address": "杭州",
            "salary": 888.8
        }
    ]
}

partitioningBy()也返回Map,但key是true/false,因为条件分组的依据要么true、要么false。partitioningBy()也支持各种嵌套,大家自己尝试即可。

排序

说到排序,大家最先想到的是Stream API中的sorted(),共有两个方法,其中一个支持传入Comparator:

排序的前提是比较,而只要涉及到比较,就必须明确比较的标准是什么。

public static void main(String[] args) {
    List<Person> result = list.stream().sorted().collect(Collectors.toList());
    System.out.println(result);
}

大家猜猜上面代码的运行结果是什么?

答案是:ClassCastException。

在学习Java基础时,我们了解到,如果希望进行对象间的比较:

  • 要么对象实现Comparable接口(对象自身可比较)
  • 要么传入Comparator进行比较(引入中介,帮对象们进行比较)

就好比你和朋友进行100米比赛,要么你们自己计时,要么请个裁判。而上面sorted()既然没有传入Comparator,那么Person要实现Comparable接口:

public static void main(String[] args) {
    List<Person> result = list.stream().sorted().collect(Collectors.toList());
    System.out.println(result);
}


@Getter
@Setter
@EqualsAndHashCode
@AllArgsConstructor
static class Person implements Comparable<Person> {
    private String name;
    private Integer age;
    private String address;
    private Double salary;


    @Override
    public String toString() {
        return "Person{" +
                "name='" + name + '\'' +
                ", age=" + age +
                ", address='" + address + '\'' +
                ", salary=" + salary +
                '}';
    }
 
    // 定义比较规则
    @Override
    public int compareTo(Person anotherPerson) {
        return anotherPerson.getAge() - this.getAge();
    }
}

sorted()容易采坑而且语义不够明确,个人建议使用sort(Comparator),显式地传入比较器:

public class ComparatorTest {

    private static List<Person> list;

    static {
        list = new ArrayList<>();
        list.add(new Person("i", 18, 170));
        list.add(new Person("am", 19, 180));
        list.add(new Person("am", 20, 180));
        list.add(new Person("iron", 19,  181));
        list.add(new Person("iron", 19,  179));
        list.add(new Person("man", 17,  160));
        list.add(new Person("man", 16,  160));
    }

    public static void main(String[] args) {
        // 先按身高降序,再按年龄降序
        list.sort(Comparator.comparingInt(Person::getHeight).thenComparingInt(Person::getAge).reversed());
        System.out.println(list);

        // 先按身高升序,再按年龄升序
        list.sort(Comparator.comparingInt(Person::getHeight).thenComparingInt(Person::getAge));
        System.out.println(list);

        // 先按身高降序,再按年龄升序
        list.sort(Comparator.comparingInt(Person::getHeight).reversed().thenComparingInt(Person::getAge));
        System.out.println(list);

        // 先按身高升序,再按年龄降序
        list.sort(Comparator.comparingInt(Person::getHeight).thenComparing(Person::getAge, Comparator.reverseOrder()));
        System.out.println(list);

        /**
         * 大家可以理解为Comparator要实现排序可以有两种方式:
         * 1、comparingInt(keyExtractor)、comparingLong(keyExtractor)... + reversed()表示倒序,默认正序
         * 2、comparing(keyExtractor, Comparator.reverseOrder()),不传Comparator.reverseOrder()表示正序
         * 
         * 第四个需求如果采用reversed(),似乎达不到效果,反正我没查到。
         * 个人建议,单个简单的排序,无论正序倒序,可以使用第一种,简单一些。但如果涉及多个联合排序,建议使用第二种,语义明确不易搞错。
         * 
         * 最后,上面是直接使用Collection的sort()方法,请大家自行改成Stream中的sorted()实现一遍。
         */
    }


    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    static class Person {
        private String name;
        private Integer age;
        private Integer height;
    }
}

截取

对于List的截取,可能大家都习惯用List.subList(),但它有个隐形的坑:对截取后的List进行元素修改,会影响原List(除非你就希望改变原List)。

究其原因,subList()并非真的从原List截取出元素,而是偏移原List的访问坐标罢了,SubList的get方法:

比如你要截取(5, 6),那么下次你get(index),我就直接返回5+index给你,看起来好像真的截取了。

另外,这个方法限制太大,用起来也麻烦,比如对于一个不确定长度的原List,如果你想做以下截取操作:list.subList(0, 5)或者list.subList(2, 5),当原List长度不满足List.size()>=5时,会抛异常。为了避免误操作,你必须先判断size:

if(list != null && list.size()>=5) {
    return list.subList(2, 5);
}

较为简便和安全的做法是借助Stream(Stream一个很重要的特性是,不修改原数据,而是新产生一个流):

public static void main(String[] args) {

    List<String> list = Lists.newArrayList("a", "b", "c", "d");

    List<String> limit3 = list.stream().limit(3).collect(Collectors.toList());
    // 超出实际长度也不会报错
    List<String> limit5 = list.stream().limit(5).collect(Collectors.toList());
    List<String> range3_4 = list.stream().skip(2).limit(2).collect(Collectors.toList());
    // 超出实际长度也不会报错
    List<String> range3_5 = list.stream().skip(2).limit(3).collect(Collectors.toList());

    System.out.println(limit3 + " " + limit5 + " " + range3_4 + " " + range3_5);
}

多用Stream

Stream有个很重要的特性,常常被人忽略:Stream操作并不改变原数据。这个特性有什么用呢?

假设有两个List,AList和BList。AList固定10个元素,BList元素长度不固定,0~10个。

要求:

要把AList和BList合并,BList的元素在前,合并后的List不能有重复元素(过滤AList),且不改变两个List原有元素的顺序。

你可以先思考一下怎么处理。

之前介绍过List转Map然后利用Key去重的方法,但转Map后元素顺序可能会打乱,为了保证顺序,你可能会选择双层for:

for (int i = aList.size() - 1; i >= 0; i--) {
    for (Item bItem : bList) {
        // 注意,思考一下aItem.get(i)有没有问题
        if (Objects.equals(bItem.getItemId(), aList.get(i).getItemId())) {
            aList.remove(i);
        }
    }
}

// bList在前
bList.addAll(aList);

当aList.remove(i)以后,第二层for又执行aList.get(i)就有可能造成数组越界异常,比如aList.remove(9),移除第10个元素,但第二层for在下一轮还是aList.get(9),此时aList其实只有9个元素。改进的写法可以是这样:

for (int i = aList.size() - 1; i >= 0; i--) {
    // 在第一层for取出aItem,且只取一次
    Item aItem = aList.get(i);
    
    for (Item bItem : bList) {
        if (Objects.equals(bItem.getItemId(), aItem.getItemId())) {
            aList.remove(i);
        }
    }
}

// bList在前
bList.addAll(aList);

但这样不够直观,而且下次再有类似需求,可能还是心惊胆战。此时使用Stream可以编写出更为安全、易读的代码:

Map<String, Item> bItemMap = ConvertUtil.listToMap(bList, Item::getItemId);

// 对aList去重(收集bList中没有的元素)
aList = aList.stream().filter(aItem -> !bItemMap.contains(aItem.getItemId())).collect(Collectors.toList());

// bList在前
bList.addAll(aList);

当然,List其实也提供了取并集、差集的方法,只不过上面的做法会更通用一些,但这不是重点。这里主要是想提醒大家,对于原数组的增删改操作一不小心会带来意想不到的问题,比如数组越界、并发修改异常等,此时用Stream往往是更安全的做法。

总结

  • filter()、map()如果逻辑过长,最好抽取函数
  • IntStream、LongStream、DoubleStream在统计方面比Stream方法更丰富,更好用
  • collect()是最强大的,但一般掌握上面6种情景问题不大
  • 去重的原理是利用hashCode()和equals()来确定两者是否相同,无论是自定义对象还是String、Integer等常用内置对象,皆是如此
  • 排序的原理是,要么自身实现Comparable接口,要么传入Comparator对象,总之要明确比较的规则
  • 平时可能觉得skip()、limit()用不到,但需要截取List或者内存分页时,可以尝试一下
  • 尽量用Stream代替List原生操作,代码健壮性和可读性都会提升一个台阶
  1. flatMap
public class FlatMapTest {
    /**
     * 需求:
     * 1.要求返回所有的key,格式为 list<Long>      提示:keyset
     * 2.要求最终返回所有value,格式为 List<Long>   提示:flatMap(),Function需要啥你就转成啥
     *
     * @param args
     */
    public static void main(String[] args) {
        Map<Long, List<Long>> map = new HashMap<>();
        map.put(1L, new ArrayList<>(Arrays.asList(1L, 2L, 3L)));
        map.put(2L, new ArrayList<>(Arrays.asList(4L, 5L, 6L)));
    }
}
List<Long> keys = map.keySet().stream().toList();  
System.out.println(keys);  
  
List<Long> values = map.keySet().stream().flatMap(key -> map.get(key).stream()).toList();  
System.out.println(values);

//更好的方法
List<Long> valueList = map.values().stream().flatMap(Collection::stream).collect(Collectors.toList());
  1. 分组统计:
private static List<Person> list;

static {
    list = new ArrayList<>();
    list.add(new Person("i", 18, "杭州", 999.9));
    list.add(new Person("am", 19, "温州", 777.7));
    list.add(new Person("iron", 21, "杭州", 888.8));
    list.add(new Person("man", 17, "宁波", 888.8));
}

1.要求分组统计出各个城市的年龄总和,返回格式为 Map<String, Integer>。

Map<String, Integer> ageSumByCity = list.stream().collect(Collectors  
        .groupingBy(Person::getAddress,  
                Collectors.mapping(Function.identity(), Collectors.summingInt(Person::getAge))));  
System.out.println(ageSumByCity);

2.要求得到Map<城市, List<用户工资>>

Map<String, List<Double>> salaryListByCity = list.stream()  
        .collect(Collectors.groupingBy(Person::getAddress,  
                Collectors.mapping(Person::getSalary, Collectors.toList())));  
System.out.println(salaryListByCity);
  1. 实际开发遇到的问题:处理优惠券信息

public class CouponTest {

    public static void main(String[] args) throws JsonProcessingException {
        List<CouponResponse> coupons = getCoupons();
        
        // TODO 对优惠券统计数量
	    //1. 不使用Stream,直接new HashMap操作

		//2. hashMap + stream
		List<CouponResponse> coupons = getCoupons();  
		coupons.stream()
			.collect(Collectors.toMap(CouponResponse::getId, couponResponse -> {    
			    CouponInfo couponInfo = new CouponInfo();  
			    couponInfo.setId(couponResponse.getId());  
			    couponInfo.setName(couponResponse.getName());  
			    couponInfo.setNum(1);  
			    couponInfo.setCondition(couponResponse.getCondition());  
			    couponInfo.setDenominations(couponResponse.getDenominations());  
			    return couponInfo;  
			},(pre,next) ->{  
			  pre.setNum(pre.getNum() + 1);  
			  return pre;  
			}));

       
    }

    private static List<CouponResponse> getCoupons() {
        return Lists.newArrayList(
                new CouponResponse(1L, "满5减4", 500L, 400L),
                new CouponResponse(1L, "满5减4", 500L, 400L),
                new CouponResponse(2L, "满10减9", 1000L, 900L),
                new CouponResponse(3L, "满60减50", 6000L, 5000L)
        );
    }

    @Data
    @AllArgsConstructor
    static class CouponResponse {
        private Long id;
        private String name;
        private Long condition;
        private Long denominations;
    }

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    static class CouponInfo {
        private Long id;
        private String name;
        private Integer num;
        private Long condition;
        private Long denominations;
    }
}
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。