《JAVA核心技术 卷I》第六章 - 接口,lambda表达式与内部类

第六章-接口,lambda表达式与内部类

1. 接口

1.1 接口的特性

  • 接口(interface),用来描述类应该做什么,而不指定它们具体应该怎么做。一个类可以实现(implement)一个或多个接口。接口不是类,而是对希望符合这个接口的类的一组需求。

  • 接口中所有方法都默认为也必须为public方法;接口内一般不会有实例字段,提供实例字段和方法实现的任务应该由实现接口的那个类来完成。可以粗略的将接口看成是没有实例字段的抽象类。

  • 为了让类实现一个接口,通常需要下面两个步骤:

    1. 将类声明为实现给定的接口
    2. 对接口中所有的方法提供定义
  • 在声明接口的时候,推荐加入泛型,这样更规范

    class Employee implements Comparable<Employee>
    {
        //...
    }
    
  • 接口的一些特性

    //接口不是类,具体来说,不能使用new运算符实例化一个接口
    x = new Comparable(...); //error
    
    //但是可以声明接口的变量
    Comparable x;
    //接口变量必须引用实现了这个接口的类对象
    x = new Employee(...);
    
    //可以使用instanceof检查一个对象是否实现了某个特定的接口
    if(anObject instanceof Comparable){...};
    
    //一个接口可以继承另一个接口
    public interface Powered extends Moveable{
        //...
    }
    
    //虽然接口中不能包含实例字段,但是可以包含常量
    public interface Powered extends Moveable{
        double SPEED_LIMIT = 95;
    }
    

1.2 Comparable接口

  • Comparable接口建议compareTo方法应该与equals方法兼容,也就是说,当x.equals(y)的时候x.compareTo(y)应该等于0。大多数API实现Comparable接口的时候都遵循了这个规定,但是BigDecimal除外。BigDecimal中,BigDecimal("1.0") ≠ BigDecimal("1.00"),当使用equals比较的时候返回的是false,因为两者的精度不同。但是x.compareTo(y)返回值为0。理想结果下应该和equals结果一致返回非0,但实在没有更好的办法比较两者的大小。

  • 语言标准规定:对于任意的x和y,实现者必须能保证sgn(x.compareTo(y)) = -sgn(y.compareTo(x))。这里的sgn是一个数值的符号(如,若n为负数,sgn(n)就为-1)。简而言之,如果反转compareTo的参数,结果的符号也应该反转。

  • 由于Comparable和compareTo涉及比较,和equals一样在继承中可能会出现问题

    如果类A是类B的父类,而类B中又重写了compareTo方法,那么就会违反"反对称"规则。如果x是一个A对象,y是一个B对象,调用x.compareTo(y)不会抛出异常,他只是将x和y都作为类A类型的变量比较。但是反过来,y.compareTo(x)将会抛出一个ClassCastException异常。

    而这个问题的补救方法和equals也一样,就是在比较之前检测比较双方的类型

    if(getClass() != other.getClass()) throw new ClassCastException();
    
  • 对于已经实现了Comparable接口的类(比如String),由于我们无法再去修改,所以想要重写比较方法,只能使用Comparator接口,具体操作方法和Comparable差不多。但是实现了Comparator接口的类需要通过比较器来实现比较。而并非由被比较的类本身来调用来比较

    comp.compare(words[i],words[j]);
    

1.3 默认方法

可以为接口方法提供一个默认实现,必须用default修饰符标记这样一个方法。

一般来说这个方法没什么用处,因为每个实现Comparable的类都会重写覆盖这个方法,但是在某些场合,比如在实现的时候懒得写这个方法,那么就会默认调用接口中的default方法。

默认方法的一个重要用法是"接口演化",如果要往已有接口中添加新的方法,直接修改接口会导致之前版本的代码报错(没有完全实现接口内的所有方法),为接口增加一个非默认方法不能保证"源代码兼容"。

public interface Comparable<T>{
    default int compareTo(T other){
        return 0;
    }
}

如果在一个接口中将一个方法定义为默认方法,然后又在超类或另一个接口中定义同样的方法,会发生一些冲突。当程序中出现这种冲突的时候,编译器会遵守以下原则:

  1. 超类优先。如果超类提供了一个具体方法,同名而且有相同参数类型的默认方法会被忽略

  2. 接口冲突,如果一个接口提供了一个默认方法,另一个接口提供了一个同名且参数类型相同的方法,则必须在使用这两个接口的类中指明使用哪个方法

    interface Person
    {
        default String getName() {return "";}
    }
    
    interface Named
    {
        default String getName() {return "AAA";}
    }
    
    class Student implements Person,Named{
        public String getName(){
            return Person.super.getName();	//指定使用Person接口中的方法
        };
    }
    

1.4 Cloneable接口

  • 对象克隆

    由于Java的特性,原变量和其副本都是同一个对象的引用。如果希望创建一个新对象,其初始形态与original相同,但是之后它们各自会有自己不同的状态,这种情况下就要使用clone方法。

    但是并不是任何情况下都可以直接使用clone方法,如果对象包含子对象的引用,拷贝字段就会得到相同子对象的另一个引用,这样一来,原对象和克隆的对象仍然会共享一些信息。这样默认的克隆操作称为"浅拷贝",并没克隆对象中引用的其它对象。如果需要完全克隆,就必须重新定义clone方法来建立一个深拷贝,同时克隆所有子对象。

  • Cloneable接口的出现与接口的正常使用没有关系,它没有指定clone方法,这个方法是从Object类继承的。这个接口只是作为一个标记。如果一个对象请求克隆,但是没有实现这个接口,就会生成一个检查型异常。即使clone的默认(浅拷贝)实现能够满足要求,还是需要实现Cloneable接口,将clone重新定义成public,再调用super.clone()

    //下面是一个实现Cloneable接口的深拷贝例子
    class Employee implements Cloneable
    {
        public Employee clone() throw CloneNotSupportedException
        {
            //首先使用浅拷贝
            Employee cloned = (Employee) super.clone;
            
            //对于类内部的子对象,使用其对象内置的clone,完成深拷贝
            cloned.hireDay = (Date) hireDay.clone;
            return cloned;
        }
    }
    

    Cloneable是Java提供的少数标记接口之一。标记接口不包含任何方法,它唯一的作用就是允许在类型查询中使用instanceof。

2. Lambda表达式

2.1 lambda表达式

  • lambda表达式就是一个代码块,以及必须传入代码的变量规范。在使用lambda表达式之前,对于传递代码块,我们一般都是新建一个匿名类对象,在内部声明匿名类的方法。lambda表达式在书面上简化了这一操作

    //lambda表达式的基本形式:参数 + 箭头(->) + 一个表达式
    (String first, String second) -> first.length() - second.length()
    //无需指定lambda表达式的返回类型,lambda表达式的返回值类型总是会从上下文推导得出
    
    //如果代码要完成的计算无法放在一个表达式中,可以放在{}中,并包含显式的return语句
    (String first, String second) ->
    {
        if(first.length() < second.length())
            return -1;
        else if(first.length() > second.length())
            return 1;
        else
            return 0;
    }
    
    //即使lambda表达式没有参数,仍然要提供空括号
    () -> {for(int i = 100; i >= 0;i--) System.out.println(i);}
    
    //如果可以推导出一个lambda表达式的参数类型,则可以忽略其类型
    Comparator<String> comp = (first,second) -> first.length - sceond.length;
    
    //如果方法只有一个参数,而且这个参数的类型可以通过推导得出,甚至可以省略小括号
    ActionListener listener = event -> System.out.println("....");
    
  • 对于只有一个抽象方法的接口,需要这种接口的对象时,就可以提供一个lambda表达式。这种接口称为函数式接口。用lambda表达式代替特定的类作为参数有一个好处,它只有在被需要调用的时候才会被加载,可以提高程序的运行效率。

    //比如Arrays.sort的第二个参数需要一个Comparator实例,而Comparator就是只有一个方法的接口
    Arrays.sort(words,(first,second) -> first.length() - second.length());
    
  • 只有在函数符合函数式接口模板的时候才能使用lambda表达式!

2.2 方法引用

  • 方法引用(method reference)可以指示编译器生成一个函数式接口的实例,并覆盖这个接口的抽象方法来调用给定的方法。类似于lambda表达式,方法引用也不是一个对象,不过,给一个类型为函数式接口的变量赋值时会生成一个对象。

    在使用::运算符时需要分隔方法名与对象或类名,主要分为以下三种情况:

    1. object::instanceMethod

      这种情况下,方法引用等价于向方法传递参数的lambda表达式。对于System.out::println,对象是System.out,所以方法表达式等价于x -> System.out.println(x)

    2. Class::instanceMethod

      这种情况下,第一个参数会成为方法的隐式参数,例如,String::compareToIgnoreCase等同于(x,y) -> x.compareToIgnoreCase(y)

    3. Class::staticMethod

      这种情况下,所有参数都传递导静态方法:Math::pow等价于(x,y) -> Math.pow(x,y)

    方法引用时具体调用哪一个方法,由上下文决定

    (如果难以记忆,可以简单粗暴的把"::"看作是"." ,方法引用不过是略去了参数而已)

    Timer timer = new Timer(1000,event -> System.out.println(event));
    Timer timer = new Timer(1000,System.out::println);
    
  • 构造器引用与方法引用很类似,只不过方法名为new

ArrayList<String> names = ...;
Stream<Person> stream = names.stream().map(Person::new);
List<Person> people = stream.collect(Collectors.toList());

2.3 变量作用域

2.3.1 捕获自由变量
public static void repeatMessage(String text, int delay){
    ActionListener listener = event ->
    {
        System.out.println(text);
        Toolkit.getDefaultToolkit().beep();
    };
    new Timer(delay,listener).start();
}

在上面这段代码中,lambda表达式使用了text这个变量,这个变量并不是在lambda表达式内定义的。这就会带来一个问题,那就是由于lambda表达式可能延后运行,这个变量届时可能已经被释放了。

(说一下为什么会被释放,诚然包含这个方法的Class一旦被实例化,就会一直存在,由于它又实现了监听器接口,所以一旦被回调,就会自动调用监听器,无论这个监听器在哪里。现在最大的问题是,监听器位于一个方法内,这个方法并不是持久的,当方法运行结束时,可能监听器还没被调用。如果监听器与方法没有耦合还好,但不幸的是这个监听器使用了方法的局部变量(text),所以一旦方法结束,text就会被释放掉,监听器就无法使用text了)

lambda表达式为了解决这个问题,会事先将这种不在内部定义的变量(一般称为自由变量)复制一份,这个复制的过程,我们称之为“捕获”(captured)。在上面的例子中,text这个自由变量被lambda表达式"捕获"了

在Java中,要确保所捕获的值是明确定义的,而且只能引用值不会改变的变量(即便会在外界可能发生改变也不行),我们称这种变量为"事实最终变量",这个变量初始化之后就不会再为它赋新值。这是为了防止在多线程并发中出现错误造成线程不安全。

2.3.2 this关键字的使用

在一个lambda表达式中使用this关键字时,是指创建这个lambda表达式的方法的this参数

public class Application
{
    public void init()
    {
        ActionListener listener = event -> {
            System.out.println(this.toString);
            // ...
        }
    }
}
//这里调用的是Application的toString,而不是lambda表达式创建的ActionListener的toString。只需要把这个this看作是一般方法的this放在Application里就可以了,不用多想
  • Comparator接口包含很多方便的静态方法来创建比较器。这些方法可以用于lambda表达式或方法引用

    静态comparing方法会取一个"键提取器"函数,它会将类型T映射为一个可比较的类型。对要比较的对象应用这个函数,然后对返回的键完成比较

    Arrays.sort(people,Comparator.comparing(Person::getName));
    

    还可以把比较器与thenComparing方法串起来,来比较结果相同的情况

    Arrays.sort(people,Comparator.comparing(Person::getLastName).thenComparing(Person::getFirstName));
    

    可以为comparing和thenComparing方法提取的键指定一个比较器

    Arrays.sort(people,Comparator.comparing(Person::getName,(s,t) -> Integer.compare(s.length,t.length())));
    //或者
    Arrays.sort(people,Comparator.comparingInt(p -> p.getName().length));
    

    如果键函数可以返回null,可能要用到nullsFirst和nullsLast适配器。这些方法会修改现有的比较器,从而在遇到null值时不会抛出异常,而是将这个值标记为小于或大于正常值。

    //nullFirst方法需要一个比较器,在这里就是比较两个字符串的比较器
    comparing(Person::getMiddleName(),Comparator.nullsFirst(...));
    

    naturalOrder方法可以为任何实现了Comparable的类建立一个比较器,实际应用到上面的例子中就是

    Arrays.sort(people,Comparator.comparng(Person::getMiddleName,nullsFirst(naturalOrder())));
    

    静态reverseOrder方法会提供自然顺序的逆序,要让比较器逆序比较,可以使用reversed实例方法

3. 内部类

3.1 内部类基础

  • 内部类(inner class)是定义在另一个类中的类。

    使用内部类主要有两个原因:

    1. 内部类可以对同一个包中的其它类隐藏
    2. 内部类方法可以访问定义这个类的作用域中的数据,包括原本私有的数据
    public class TalkingClock
    {
        private int interval;
        private boolean beep;
        
        public TalkingClock(int interval, boolean beep){...};
        public void start(){...};
        
        public class TimePrinter implements ActionListener{
            //...
        }
    }
    

    这里的TimePrinter类位于TalkingClock类内部。这并不意味着每个TalkingClock都有一个TimePrinter的实例字段。必须要在TalkingClock内调用方法构造TalkingClock,它才能有实例字段。

3.2 内部类对外部类的隐式调用

下面来看一下TimePrinter内部的内容

public class TimePrinter implements ActionListener{
    public void actionPerformed(ActionEvent event){
        System.out.println("At the tone, the time is" + Instant.ofEpochMilli(event.getWhen()));
        if(beep) Toolkit.getDefaultToolkit().beep();
    }
}

可以看到这里使用了beep变量,这个变量从未在内部类中被定义过,所以说明,一个内部类方法可以访问自身的数据字段,也可以访问创建它的外围类对象的数据字段。为此,内部类的对象总有一个隐式引用,指向创建它的外部类对象,而这个引用在内部类的定义中是不可见的。

对外围类的引用会在构造器中设置,编译器会修改所有的内部类构造器,添加一个对应外围类引用的参数。因为TimePrinter类没有定义构造器,所以编译器为这个类生成了一个无参数构造器,生成的代码如下所示

public TimePrinter(TalkingClock clock){
    outer = clock;	//注意这里的outer并不存在,此处只是象征外部类,方便理解
}

在start方法中构造一个TimePrinter对象后,编译器就会将当前语音时钟的this引用传递给这个构造器

TimePrinter listener = new TimePrinter(this);	//注意这里的this并不在,系统会自动将外部类引用
  • 内部类的语法规则围绕着对外部类的引用展开,在外部类内,对外部类引用的正规语法为:OuterClass.this

    //按照正式语法书写,TimePrinter内对beep的引用可以这样写
    if(Talking.this.beep) Toolkit.getDefaultToolkit().beep();
    

    同理,可以使用以下语法更加明确的编写内部类对象的构造器:OuterObject.new InnerClass(construction parameters)

    //按照正式写法,创建TimePrinter的方式可以这样写,同时,这也是在外部类外创建内部类实例的方法
    ActionListener listener = this.new TimePrinter();
    

    在外部类之外,可以这样引用内部类:OuterClass.InnerClass

  • 内部类中声明的所有静态字段都必须是final,并初始化为一个编译时常量,内部类不能有static方法。但是内部类本身可以是静态的。可以把内部类声明为private的,这样除了外部类,其它类无法访问内部类

3.3 局部内部类

  • 可以把内部类定义在方法,构造器内,这样的内部类一般称为局部内部类

    public void start(){
        class TimePrinter implements ActionListener{
            //...
        }
    }
    

    声明局部内部类的时候不能有权限修饰符,局部类的作用域被限定在声明这个局部类的块中。局部类的优势在于,他对外界完全隐藏,除了start方法之外,没有任何方法知道TimePrinter类的存在

  • 和其它内部类一样,局部内部类也可以访问外部类变量,但不仅如此,它也可以访问局部变量(毕竟是声明在方法或构造器内的)。当然,访问的局部变量也必须是事实最终变量

    //此处方法参数中的beep为即为局部变量,内部类可以直接使用
    public void start(int interval,boolean beep){
        class TimePrinter implements ActionListener{
            public void actionPerformed(ActionEvent event){
                System.out.println("the time is" + Instant.ofEpochMilli(event.getWhen()));
                if(beep) Toolkit.getDefaultToolkit().beep();
            }
        }
        
        TimePrinter listener = new TimePrinter();
        Timer timer = new Timer(interval,listener);
        timer.start();
    }
    

    和之前提到的"lambda表达式捕获变量一样",这里由于局部变量也有被提前释放的风险,局部内部类也会做类似的捕获操作。比如这里beep就会被捕获以便之后回调监听器使用

3.4 匿名内部类

  • 匿名内部类的书写方法相当令人困惑,而且容易混淆,注意区分

    /*格式
    new SuperType(construction parameters)
    {
    	[inner class methods and data]
    }
    SuperType可以是一个接口或父类,由于匿名类没有名字,所以也就没有构造器。匿名类的构造由其父类的构造器完成。如果匿名类的SuperType刚好为接口,那么就完全没有构造器,括号内不用填任何东西。
    */
    
    public void start(int interval,boolean beep){
        TimePrinter listener = new ActionListener(){
            public void actionPerformed(ActionEvent event){
                System.out.println("the time is" + Instant.ofEpochMilli(event.getWhen()));
                if(beep) Toolkit.getDefaultToolkit().beep();
            }
        }
    
        Timer timer = new Timer(interval,listener);
        timer.start();
    }
    
    //一定要注意区分"构造一个类的新对象"与"构造一个扩展了那个类的匿名内部类的对象"之间的区别
    //Person count = new Person("Dra");	构造一个类的新对象
    //Person count = new Person("Dra"){...}; 定义匿名内部类
    

3.5 静态内部类

  • 有时候,使用内部类只是为了把一个类隐藏在一个类的内部,并不需要内部类由外围类对象的一个引用。为此,可以将内部类声明为static,制作一个静态内部类,这样就不会生成那个引用。

    class ArrayAlg{
        
        //pair内部类
        public static class Pair{
            private double first;
            private double second;
            
            public Pair(double f,double s){
                first = f;
                second = s;
            }
            
            public double getFirst(){
                return first;
            }
            
            public double getSecond(){
                return second;
            }
        }
        
        public static Pair minmax(double[] values){
            double min = Double.POSITIVE_INFINITY;
            double max = Double.NEGATIVE_INFINITY;
            for(double v : values){
                if(min > v) min = v;
                if(min < v) max = v;
            }
            return new Pair(min,max);
        }
    }
    

    只要内部类不需要访问外围对象,就都应该使用静态内部类,静态内部类的使用相当常见

4. 代理

4.1 代理基础

  • 代理可以在运行时创建实现一组给定接口的新类。只有在编译器无法确定需要实现那个接口时才有必要使用代理
  • 具体地,代理类包含以下方法:
    • 指定的接口中所需要的全部方法
    • Object类中的全部方法(equals,toString等)

4.2 创建代理对象

  • 创建代理对象需要使用Proxy类的newProxyInstance方法,这个方法需要三个参数

    1. 一个类加载器(class loader),关于加载器的内容在此不详细展开,参考卷II内容
    2. 一个Class对象数组:数组内每个元素,对应需要实现的接口
    3. 一个调用处理器(invocation handler):调用处理器是实现了InvocationHandler接口的类的对象。InvocationHandler接口内只有一个方法:Object invoke(Object proxy,Method method,Object[] args)。一旦调用代理对象内的方法,调用处理器内的invoke方法就会被调用并由它决定如何处理这个调用
    class TraceHandler implements InvocationHandler{
    
        private Object target;
    
        public TraceHandler(Object t){
            target = t;
        }
    
        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            System.out.print(target);
            System.out.print("." + method.getName() + "(");
            if(args != null){
                for(int i = 0;i < args.length;i++){
                    System.out.print(args[i]);
                    if(i < args.length - 1) System.out.print(", ");
                }
            }
            System.out.println(")");
    
            return method.invoke(target,args);
        }
    }
    

4.2 代理类的特性

  • 代理类是在程序运行过程中创建的。一旦被创建,他们就变成了常规类,与虚拟机中的其它类没有区别
  • 一个代理类只有一个实例字段——调用处理器,它在Proxy超类中被定义。完成代理对象任务所需要的任何额外数据都必须储存在调用处理器中
  • 对于一个特定的类加载器和预设的一组接口来说,只能有一个代理类。也就是说,如果使用同一个类加载器和接口数组调用两次newProxyInstance方法,将得到同一个类的两个对象
  • 代理类总是public和final。如果代理类实现的所有接口都是public,这个代理类就不属于任何特定的包,否则,所有非公共的接口都必须属于同一个包,同时,代理类也属于这个包。
posted @   Solitary-Rhyme  阅读(55)  评论(0编辑  收藏  举报
编辑推荐:
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)
点击右上角即可分享
微信分享提示