匿名內部類別


在撰寫Java程式時,經常會有臨時繼承某個類別或實作某個介面並建立實例的需求,由於這類子類別或介面實作類別只使用一次,不需要為這些類別定義名稱,這時可以使用匿名內部類別(Anonymous inner class)來解決這個需求。匿名內部類別的語法為:

new 父類別() | 介面() {
    // 類別本體實作
};

在談 內部類別 時略談過匿名內部類別,那時以繼承Object重新定義toString()方法為例:

Object o = new Object() {  // 繼承Object重新定義toString()並直接產生實例
    @Override
    public String toString() {
        return "無聊的語法示範而已";
    }
};

如果是實作某個介面,例如若Some介面定義了doService()方法,要建立匿名類別實例,可以如下:

Some some = new Some() {  // 實作Some介面並直接產生實例
    public void doService() {
        System.out.println("作一些事");
    }
};

實際上從JDK8開始,若介面僅定義一個抽象方法,可以使用Lambda來簡化這個程式的撰寫,例如:

Some some = () -> {
    System.out.println("作一些事");
};

有關Lambda語法的細節,之後會有專區說明。

先來舉個介面應用的例子。假設你打算開發多人連線程式,對每個連線客戶端,都會建立Client物件封裝相關資訊:

package cc.openhome;

public class Client {
    public final String ip;
    public final String name;
    public Client(String ip, String name) {
        this.ip = ip;
        this.name = name;
    }
}

程式中建立的Client物件,都會加入ClientQueue集中管理,若程式中其它部份希望在ClientQueueClient加入或移除時可以收到通知,以便作一些處理(例如進行日誌記錄),那麼可以將Client加入或移除的資訊包裝為ClentEvent

package cc.openhome;

public class ClientEvent {
    private Client client;
    public ClientEvent(Client client) {
        this.client = client;
    }
    
    public String getName() {
        return client.name;
    }
    
    public String getIp() {
        return client.ip;
    }
}

你可以定義ClientListener介面,如果有物件對Client加入ClientQueue有興趣,可以實作這個介面:

package cc.openhome;

public interface ClientListener {
    void clientAdded(ClientEvent event);    // 新增Client會呼叫這個方法
    void clientRemoved(ClientEvent event); // 移除Client會呼叫這個方法
}

如何在ClientQueue新增或移除Client時予以通知呢?直接來看程式碼:

package cc.openhome;

import java.util.ArrayList;

public class ClientQueue {
    private ArrayList clients = new ArrayList();
    private ArrayList listeners = new ArrayList();
            
    public void addClientListener(ClientListener listener) {
        listeners.add(listener);
    }
    
    public void add(Client client) {
        clients.add(client);
        ClientEvent event = new ClientEvent(client);
        for(int i = 0; i < listeners.size(); i++) {
            ClientListener listener = (ClientListener) listeners.get(i);
            listener.clientAdded(event);
        }
    }
    
    public void remove(Client client) {
        clients.remove(client);
        ClientEvent event = new ClientEvent(client);
        for(int i = 0; i < listeners.size(); i++) {
            ClientListener listener = (ClientListener) listeners.get(i);
            listener.clientRemoved(event);
        }
    }
}

ClientQueue會收集連線後的Client物件,Java SE API就提供了java.util.ArrayList,可以讓你進行物件收集,範例中使用了java.util.ArrayList來收集Client以及對ClientQueue感興趣的ClientListener

如果有物件對Client加入ClientQueue有興趣,可以實作ClientListener,並透過addClientListner()註冊。當每個Client透過ClientQueueadd()收集時,會用ArrayList收集Client,接著使用ClientEvent封裝Client相關資訊,接著使用for迴圈將註冊的ClientListener逐一取出,並呼叫clientAdded()方法進行通知。如果有物件被移除,流程也是類似,這可以在ClientQueueremove()方法中看到相關程式碼。

作為測試,可以使用以下的程式碼,其中使用匿名內部類別,直接建立實作ClientListener的物件:

package cc.openhome;

public class MultiChat {
    public static void main(String[] args) {
        Client c1 = new Client("127.0.0.1", "Caterpillar");
        Client c2 = new Client("192.168.0.2", "Justin");

        ClientQueue queue = new ClientQueue();

        queue.addClientListener(new ClientListener() {
            @Override
            public void clientAdded(ClientEvent event) {
                System.out.printf("%s 從 %s 連線%n",
                        event.getName(), event.getIp());
            }

            @Override
            public void clientRemoved(ClientEvent event) {
                System.out.printf("%s 從 %s 離線%n", 
                        event.getName(), event.getIp());
            }
        });
        
        queue.add(c1);
        queue.add(c2);

        queue.remove(c1);
        queue.remove(c2);
    }
}

執行的結果如下所示:

caterpillar 從 127.0.0.1 連線
justin 從 192.168.0.2 連線
caterpillar 從 127.0.0.1 離線
justin 從 192.168.0.2 離線

在JDK8之前,如果要在匿名內部類別中存取區域變數,則該區域變數必須是final,否則會發生編譯錯誤:

內部類別只能取得final區域變數


必須宣告arrsfinal才可以通過編譯:

final int[] x = {10, 20};
Object o = new Object() {
    public String toString() {
        return "example: " + x[0];
    }
};

要瞭解為什麼,必須涉及一些底層機制。區域變數的生命週期往往比物件短,像是方法呼叫後傳回物件,區域變數生命週期就結束了,此時再透過物件嘗試存取區域變數會發生錯誤,Java的作法是採用傳值,以上例而言,實際上會在匿名內部類別的實例中,建立新的變數參考原本的物件:

int ai[] = {10, 20};
Object obj = new Object(ai) {
    public String toString() {
        return (new StringBuilder())
                    .append("example: ").append(x[0]).toString();
    }
    final int x[];
    {
        x = ai;
    }
};

所以實際上你也不能改變x的參考,為此,編譯器才會強制你在區域變數加上final。這個限制在JDK8中,為了配合Lambda的引進有了放寬,如果變數本身等效於final區域變數,也就是說,如果變數不會在匿名類別中有重新指定的動作,就可以不用加上final關鍵字。