讀古今文學網 > Android程序設計:第2版 > Java編程慣例 >

Java編程慣例

對於一門語言,介於編程語言語義規範和好的面向模式的設計之間的是對語言的良好使用。一名喜歡遵循慣例的程序員會使用一致的代碼來表達類似的思想,而且通過使用這種方式,程序會更易於理解,且能夠在充分利用運行時環境的同時避免語言中存在的「陷阱」。

Java的類型安全性

Java的一個主要設計目標是編程安全。其中存在的很多冗余和不靈活機制都是為了幫助編譯器預防在運行時出現各種錯誤,而這些措施在其他語言,如Ruby、Python和Objective-C中是不存在的。

Java的靜態類型(static typing)是已經被證明的特性,其優越性已不再局限於Java自己的編譯器中。機器能夠自動解析並識別Java代碼的語義是開發強大的工具,如FindBugs和IDE重構工具的主要驅動力。

很多開發人員對這些限製表示認同,尤其是對於使用了一些現代編碼工具的開發人員,他們認為,與能夠快速定位問題相比,這些限制條件所耗費的代價很低,因為如果不能快速定位這些問題,它們可能只有在部署時才會顯現出來。當然,也一定會有大量的開發者認為在動態語言中,他們節省了很多編碼時間,使用節省的這些時間可以編寫出廣泛的單元測試和集成測試用例,也同樣能夠提早發現問題。

在這些討論中,無論你支持的是哪一方,盡可能充分地利用工具是非常有意義的。Java的靜態綁定絕對是一種約束,而在另一方面,Java是一門非常好的靜態綁定的語言。Java不是一門好的動態語言。實際上,使用Java的reflection機制和內省(introspection)API也能執行很多類型轉換類的動態功能。除了在非常受限的環境中之外,Java語言的這種使用方式通常是為了實現跨平台。你的程序可能會非常緩慢,即使使用很多Android工具進行優化也無濟於事。可能最大的好處是,如果平台很少被使用到的那部分存在bug,你會是第一個找到這些bug的人。希望你能夠喜歡Java的靜態本質(至少在有另一門更好的動態語言之前)並充分利用Java的靜態特徵。

封裝

開發人員通過代碼封裝(encapsulation)的方式實現對對像成員可見性的限制。封裝的思想是對像不要暴露其本身不想提供的詳細信息。回到之前提到的雞尾酒的例子,當要製作雞尾酒時,你只在乎同事給你買了必要的配料,卻並不關心她是如何買的。然而,假定你和她這麼說:「你可以去買一下配料嗎?另外,出去的時候,是否可以順便給玫瑰花澆水?」這句話意味著「不關心她是如何買配料的」這個情況不存在了,因為你現在已經對她買配料所要經過的路線有所考慮了。

同樣,一個對象的接口(有時簡稱為API)就是調用者可以訪問的方法和變量。仔細封裝之後,開發者可以做到其開發的對象的實現細節對於使用它的代碼而言是不透明的。使用這種控制和保護機制開發出的程序更加靈活,使得開發者在對其實現進行修改時不需要調用方進行任何修改。

getter和setter方法

在Java中,一種簡單常用的封裝方式是使用getter和setter方法。下面這段代碼是一個簡單的命名為Contact的類的定義:


public class Contact {
    public String name;
    public int age;
    public String email;
}
  

該定義使得外部對像能夠直接訪問類Contact的成員變量,如下所示:


Contact c = new Contact;
c.name = "Alice";
c.age = 13;
c.email = "[email protected]";
  

用過幾次之後,發現Contact實際上需要包含多個email地址。遺憾的是,當在這個類的實現中增加多個地址時,整個程序中的每個調用Contact.email的地方都需要進行相應的修改。

下面這個類的情況與其相反:


class Contact {
    private int age;
    private String name;
    private String email;
    Contact(int age, String name, String email) {
        this.age = age;
        this.name = name;
        this.email = email;
    }
    public int getAge {
        return age;
    }
    public String getName {
        return name;
    }
    public String getEmail {
        return address;
    }
}
  

在上面這個版本的Contact類中,使用了private訪問修飾符,它不允許直接訪問類的成員變量。提供了public類型的getter方法,使用方使用這些方法來得到所需要的Contact對象的name、age和email地址。例如,可以將email地址保存在對像內部,正如前一個例子那樣,也可以使用username和hostname組合,只要這種方式對於給定的應用更便捷即可。在類的內部,成員變量age可以是int類型或integer類型。這個版本的Contact類可以做到支持多個email地址,而不需要對客戶端有任何修改。

Java允許對成員變量直接引用,而它和某些語言不同,它不支持對getter和setter方法中的成員變量的引用進行封裝。為了防止封裝,必須自己定義每個訪問方法。大多數IDE會提供代碼生成功能,可以快速準確地完成這個功能。

通過getter和setter這種封裝方法可以提供靈活性,因為直接的成員變量訪問意味著如果某個成員變量的類型發生變化,則使用該成員變量的所有代碼都需要進行修改。getter和setter方法代表的是一種簡單的對象封裝機制。一個良好的慣例是建議所有的成員變量都定義為private類型或final類型。編寫良好的Java程序會使用getter和setter方法,以及一些其他更複雜的封裝方式,從而保證在複雜程序中的靈活性。

使用匿名類

有UI開發經驗的開發人員對於回調函數會很熟悉:當UI發生變化時,需要能夠通知你的應用程序。可能是按下了某個按鈕,應用模型需要進行相應的狀態變化;也可能是在網絡中有新的數據,需要對它進行顯示。需要在框架中添加一個代碼塊,以便於自己後期執行它。

儘管Java語言確實提供了傳遞代碼塊的機制,但有點怪異的是,代碼塊或方法都不是類對象。在Java中,無論是代碼塊還是方法,都無法直接使用它。

可以創建一個類的實例的引用。在Java中,不支持代碼塊或方法的傳遞,能夠傳遞的是定義了所需要的代碼的整個類,需要使用的代碼塊和方法是這個類的一個方法。提供回調函數API的服務會使用接口來定義其協議。服務客戶端定義該接口的實現,並把它傳遞給應用框架,如Android中實現用戶按鍵響應的機制。在Android中,類View定義了接口OnKeyListener,該接口又定義了方法onKey。如果在你的代碼中,把方法OnKeyListener的實現傳遞給類View,那麼每當類View得到一個新的按鍵事件時,就會調用其onKey方法。

其代碼看起來如下所示:


public class MyDataModel {
    // Callback class
    private class KeyHandler implements View.OnKeyListener {
        public boolean onKey(View v, int keyCode, KeyEvent event) {
            handleKey(v, keyCode, event)
        }
    }
    /** @param view the view we model */
    public MyDataModel(View view) { view.setOnKeyListener(new KeyHandler) }
    /** Handle a key event */
    void handleKey(View v, int keyCode, KeyEvent event) {
       // key handling code goes here...
    }
}
  

當創建一個新的MyDataModel類時,需要在其構造函數中以參數的形式告訴類所依附的view。構造函數會創建回調類新的實例KeyHandler,並在view中裝載這個實例。後續的所有按鍵事件都和模型實例的handleKey方法關聯起來。

雖然這種方式是可行的,但看起來很醜陋,尤其當你的模型類需要處理多個view的多種事件時!程序執行一段時間後,所有這些類型定義都混雜在最上方。定義和使用方式可能有很大區別,如果你考慮這個問題,它們一點用處都沒有。

Java提供了一種簡化它的方式,即使用匿名類(anonymous class)。下面這段代碼類似於之前給出的,其區別在於用的是匿名類:


public class MyDataModel {
    /** @param view the view we model */
    public MyDataModel(View view) {
        view.setOnKeyListener(
            // this is an anonymous class!!
            new View.OnKeyListener {
                public boolean onKey(View v, int keyCode, KeyEvent event) {
                    handleKey(v, keyCode, event)
                } } );
    }
    /** Handle a key event */
    void handleKey(View v, int keyCode, KeyEvent event) {
        // key handling code goes here...
    }
}
  

除瞭解析可能需要更多一些時間之外,這塊代碼和前面給出的例子幾乎完全相同。在調用中,它把新創建的子類的實例View.OnKeyListener作為參數傳遞給view.setOnKeyListener。然而,在這個例子中,view.setOnKeyListener的參數包含特殊的語義,它定義了接口View.OnKeyListener的新子類,並在語句中對它進行了實例化。新的實例是沒有名字的類的實例:它是匿名類,其定義只存在於對它執行初始化的那條語句中。

匿名類是一個非常便捷的工具,而且是Java實現多種代碼塊的習慣用法。使用匿名類創建的對象是頂級對象,可以用於任何具有相同類型的其他對像中。舉個例子,可以按照下述方式進行賦值:


public class MyDataModel {
    /** @param view the view we model */
    public MyDataModel(View view1, View view2) {
        // get a reference to the anonymous class
        View.OnKeyListener keyHdlr = new View.OnKeyListener {
            public boolean onKey(View v, int keyCode, KeyEvent event) {
                handleKey(v, keyCode, event)
            } };
        // use the class to relay for two views
        view1.setOnKeyListener(keyHdlr);
        view2.setOnKeyListener(keyHdlr);
    }
    /** Handle a key event */
    void handleKey(View v, int keyCode, KeyEvent event) {
        // key handling code goes here...
    }
}
  

你可能會覺得奇怪,在這個例子中,為什麼匿名類要把其實際實現(handelKey方法)委託(delegate)給其所在的類(containing class)呢?沒有什麼規則約束匿名類的內容:它絕對也可以包含全部的實現。但是,良好的、慣用的方式是把改變對像狀態的代碼放到對像類中。如果實現放在包含匿名類的類中,它就可以用於其他方法和調用。匿名類只是起到中間作用,而這正是希望它做的。

關於作用域中變量的使用,Java確實在匿名類內包含一些強約束(任何在塊內定義的)。特別是,匿名類只能指向從作用域繼承的聲明為final類型的變量。例如,以下代碼片段將無法編譯:


/** Create a key handler that matches the passed key */
public View.OnKeyListener curry(int keyToMatch) {
    return new View.OnKeyListener {
        public boolean onKey(View v, int keyCode, KeyEvent event) {
           if (keyToMatch == keyCode) { foundMatch; } // ERROR!!
        } };
}
  

其解決方法是把參數curry聲明為final類型。當然,聲明為final類型意味著它在匿名類中不會發生變化,但是如下所示是這種情形的一種簡單、慣用的方式:


/** Create a key handler that increments and matches the passed key */
public View.OnKeyListener curry(final int keyToMatch) {
    return new View.OnKeyListener {
        private int matchTarget = keyToMatch;
        public boolean onKey(View v, int keyCode, KeyEvent event) {
            matchTarget++;
            if (matchTarget == keyCode) { foundMatch; }
        } };
}
  

Java中的模塊化編程

雖然Java中的類擴展為開發人員提供了很大的靈活性,便於重新定義在不同上下文中使用的對象,但是要想利用好類和接口,還需要相當多的經驗。理想情況下,開發人員致力於創建能夠經受得住時間考驗的代碼塊,且盡可能在很多不同的上下文中能重用,甚至作為庫在多個應用中使用。這種編程方式可以減少代碼中的bug,並能縮短應用開發的時間。模塊化編程、封裝和關注點切分都是在最大程序上增強代碼可重用性和穩定性的關鍵策略。

在面向對象的開發中,委託(delegate)或繼承是重用已有代碼的基礎設計思路。下面給出的這一系列例子,顯示了多種組織結構,這些結構可以用來描述汽車類遊戲應用中的各種組件。每個例子就是一種模塊化方式。

開發人員首先創建一個汽車類,它包含了所有的汽車邏輯及每種類型的引擎的所有邏輯,如下所示:


// Naive code!
public class MonolithicVehicle {
    private int vehicleType;
    // fields for an electric engine
    // fields for a gas engine
    // fields for a hybrid engine
    // fields for a steam engine
    public MonolithicVehicle(int vehicleType) {
        vehicleType = vehicleType;
    }
    // other methods for implementing vehicles and engine types.    
    void start {
        // code for an electric engine
        // code for a gas engine
        // code for a hybrid engine
        // code for a steam engine
    }
}
  

這段代碼很簡單。雖然它可能能夠正常工作,但它把一些不相關的實現混合在了一起(如所有類型的汽車引擎),可能難以擴展。例如,修改實現以適應新的引擎類型(nuclear類型)。各種引擎的代碼相互之間都是可見的。一種引擎的某個漏洞,可能會意外地影響到另外一個完全不相關的引擎。一個引擎的變化也可能意外地導致另外一個引擎發生變化。一台使用電動引擎的汽車必須巡閱一遍已有的各種類型的引擎。今後要使用MonolithicVehicle類的開發人員必須理解清楚所有複雜的交互,才能夠對代碼進行修改。這種代碼不具備可擴展性。

如何改進這個實現呢?一種較常見的方法是使用子類(subclassing)。可以按照下面這段代碼中的方式來實現不同的機動車類型,每個類型的機動車都和其引擎類型綁定:


public abstract class TightlyBoundVehicle {
    // has no engine field
    // each subclass must override this method to
    // implement its own way of starting the vehicle
    protected abstract void startEngine;
    public final void start { startEngine; }
}  
public class ElectricVehicle extends TightlyBoundVehicle {
    protected void startEngine {
        // implementation for engine start electric
    }                   
public class GasVehicle extends TightlyBoundVehicle {
    protected void startEngine {
        // implementation for engine start gas
    }
}                    
public void anInstantiatingMethod {
    TightlyBoundVehicle vehicle = new ElectricVehicle;
    TightlyBoundVehicle vehicle = new GasVehicle;
    TightlyBoundVehicle vehicle = new HybridVehicle;
    TightlyBoundVehicle vehicle = new SteamVehicle;
}
  

相對上一段代碼而言,這段代碼顯然是個改進。每種類型的引擎的代碼封裝在一個獨立的類中,相互之間不會干擾。可以對每種汽車類型進行擴展,而不會影響到其他類型。在很多情況下,這是一種理想的實現方式。

另一方面,如果把TightlyBoundGasVehicle轉換成biodiesel,會發生什麼情況呢?在這個實現中,car和engine是同一個對象,不能分開。如果在現實情況中需要分別考慮,那麼架構需要更鬆散一些:

在這個架構中,vehicle類把所有和引擎相關的行為委託給了它的引擎對象。這種方式有時被稱之為has-a,這種方式和前面的子類例子中的is-a是有區別的。has-a的方式更靈活,因為它把引擎真正的工作方式方面的信息封裝起來了。每個vehicle把這些工作委託給了鬆散耦合的引擎類型,而不關心該引擎具體會如何實現這些行為。has-a這種方式中使用的是可重用的DelegatingVehicle類,當需要使用一種新的引擎時,該類一點都不需要改變。vehicle可以使用任何類型的引擎接口實現。此外,還可以創建不同的vehicle類型,如SUV、簡約型和豪華型等,每個都可以使用多種不同的引擎類型。

使用委託模式最小化兩個對像之間的相互依賴,最大化後期對它們進行修改的靈活性。委託模式和繼承模式相比,更易於對代碼進行後期的擴展和改進。使用接口來定義對象及其委託之間的關係,開發人員能夠確保委託會按照期望的行為運轉。

Java多線程並發編程基礎

Java語言支持多線程並發運行的執行方式。不同線程中的語句是按序執行,但是不同線程中的語句之間不存在順序關係。Java中並發執行的基礎單元封裝在類java.lang.Thread中。對線程進行擴展的推薦方式是使用接口java.lang.Runnable的實現,如下例子所示:


// program that interleaves messages from two threads
public class ConcurrentTask implements Runnable {
    public void run {
       while (true) {
            System.out.println("Message from spawned thread");
       }
    }
}
public void spawnThread {
    (new Thread(new ConcurrentTask)).start;
    while (true) {
          System.out.println("Message from main thread");
    }
}
  

在前面這個例子中,方法spawnThread創建了一個新的線程,它把新的ConcurrentTask實例傳遞給了線程的構造函數,然後該方法對新的線程調用了start方法。當調用線程的start方法時,底層虛擬機(VM)會創建新的執行線程,該線程又會執行Runnable接口的run方法,和擴展的線程並發執行。在這一點,VM是啟動兩個獨立的進程來運行:一個線程中的代碼執行的順序和時間與另一個線程相互獨立,完全無關。

Thread類不是final類型。可以通過實現Thread子類的方式來定義一個新的並發任務,並重寫其run方法,然而這種實現方式沒有什麼優勢。實際上,使用Runnable接口的靈活性更高。因為Runnable是一個接口,從傳遞給Thread構造函數的Runnable接口可以擴展出一些其他有用的類。

同步和線程安全

當兩個或多個線程都能夠訪問同一組變量時,某些線程可能會修改這些變量,導致數據不一致,這會破壞其他線程的邏輯。這種無意的並發訪問bug稱為「線程安全破壞」(thread safety violations)。這種問題復現的難度較大,難以找到,也難以測試。

Java沒有明確要求對多個線程都會訪問的變量進行強制的訪問限制。相反,Java為支持線程安全所提供的主要機制是通過synchronized這個關鍵字。該關鍵字序列化訪問其控制的代碼塊,而且更重要的是,它會對兩個線程之間的可見狀態進行同步。使用Java的並發功能時,很容易忘記同步機制同時控制了訪問和可見性。例如下面的程序:

可能有人會認為:「好了,這裡不需要同步變量shouldStop。當然,主線程和派生線程在訪問該變量時可能會發生衝突。那又有什麼關係呢?派生線程總是很快就把這個變量值設置為true。布爾寫操作是原子性的。如果主線程這次訪問該變量時值為false,那麼下一次它訪問時應該就是true。」這種思考方式是非常危險的,而且是錯誤的。它沒有考慮到編譯器優化和處理器的緩存機制!事實上,該程序可能永遠都不會停止。這兩個線程很可能只會使用自己的那份shouldStop數據副本,該副本只存在於某個本地處理器緩存中。由於在兩個線程之間不存在同步,緩存副本可能永遠都不會對外發佈,因此派生線程生成的數據值在主線程中可能是始終不可見的。

在Java中,有一個簡單的實現線程安全的規則:當兩個不同的線程訪問同一個可變的狀態(變量)時,對該狀態的所有訪問都必須持有鎖才可以執行。

有些開發人員可能會違反這個規則,對其程序中共享狀態的行為進行分析,嘗試優化代碼。因為目前在Android平台上實現的很多設備實際上並不能真正提供並發執行功能(相反,只是在多個線程之間序列化共享一個處理器),這些程序有可能會正確執行。然而,不可避免地,當移動設備中裝備了多核處理器及大量的多層處理器緩存,這種帶有潛在bug的程序就有可能會出現錯誤,而且這種錯誤很嚴重,是間歇性的,定位特別困難。

在Java中實現並發時,最佳的方式是使用強大的java.util.concurrent庫。在這個庫中,幾乎可以找到需要的所有並髮結構,它們的實現都經過了良好的優化和測試。在Java中,為了實現雙向鏈表,開發人員實在沒有什麼理由不使用java.util.concurrent庫中所提供的雙向鏈表而選擇使用底層的並發構造實現一個自己的版本。

關鍵字synchronized可以用於3種場合:創建代碼塊、動態方法或靜態方法。當synchronized用於定義一個代碼塊時,該關鍵字使用一個對象的引用作為參數,也就是信號量。所有對象都可以作為信號量,但基礎數據類型不可以。

當synchronized作為動態方法的修飾符時,該關鍵字的行為類似於該方法的內容包在一個同步塊中,其鎖就是實例本身。下面這個例子就是對這個特點的說明:


class SynchronizationExample {
    public synchronized void aSynchronizedMethod {
        // a thread executing this method holds
        // the lock on "this". Any other thread attempting
        // to use this or any other method synchronized on
        // "this" will be queued until this thread
        // releases the lock
    }
    public void equivalentSynchronization {
        synchronized (this) {
            // this is exactly equivalent to using the
            // synchronized keyword in the method def.
        }
    }
    private Object lock = new Object;
    public void containsSynchronizedBlock {
        synchronized (lock) {
        // A thread executing this method holds
        // the lock on "lock", not "this".
        // Threads attempting to seize "this"
        // may succeed. Only those attempting to
        // seize "lock" will be blocked
    }
}
  

這種實現方式非常便捷,但是必須慎重使用。一個包含多個綜合方法的複雜的類,使用這種方式實現同步時,可能會自己導致不同方法之間的鎖競爭。如果多個外部線程同時嘗試訪問不相關的數據片段,則最好使用多個不同的鎖分別保護這些數據塊。

如果在靜態方法中使用synchronized這個關鍵字,則它的行為表現是,方法的內容似乎是包在基於對象的類的同步代碼塊中。一個給定類的所有實例的所有靜態同步方法會競爭該類本身的那個單獨鎖。

最後,值得注意的是,在Java中對像鎖是可重入的(reentrant)。以下代碼非常安全,不會導致任何死鎖:


class SafeSeizure {
    private Object lock = new Object;
    public void method1 {
        synchronized (lock) {
                // do stuff
            method2;
        }
    }
    public void method2 {
        synchronized (lock) {
            // do stuff
        }
    }
}
  

使用了wait和notify方法的線程控制

類java.lang.Object定義了方法wait和notify,作為每個對象的鎖協議的一部分。因為Java中的所有類都是從類Object派生的,所有對象實例都支持通過這些方法來控制和實例相關的鎖。

關於Java並發的底層機制的全面探討超出了本書的討論範疇。對這方面感興趣的開發人員可參考Brian Goetz的優秀書籍《Java Concurrency in Practice》(Addison-Wesley Professional出版社出版)。下面這個例子說明了支持兩個線程執行的必要基礎元素。當一個線程在完成其需要執行的任務時,另一個線程暫停執行:

實際上,大多數開發人員不會用到如wait和notify這樣的底層工具,通常使用到的是java.util.concurrent包中所提供的更高級的工具。

同步和數據結構

Android支持功能豐富的Java標準版的Java集合庫。如果仔細研讀Java集合庫,會發現大多數集合都包含兩個版本:List和Vector、HashMap和Hashtable等。Java在1.3版本中引入了全新的集合框架。這個新的集合框架完全取代了老的集合框架。然而,為了確保向後兼容,老版本還是可用的。

相比於老版本的集合庫,應該盡量使用新版本的集合庫。在新版本的集合庫中,其API更統一,有更好的工具支持等。但是,可能最重要的是,遺留版本的集合庫都是同步的。這聽起來是件好事,但是正如以下例子所示,也有其不足之處:

雖然對Vector的每次使用都是完全同步的,並且每次調用其方法都可以確保是原子性的,但是該程序在執行過程中還是會被中斷掉。對Vector的完全同步是不夠的,當然,由於代碼通過size方法保留了該Vector的大小,即使在使用中其他線程改變了該Vector的大小,它還是會使用原來該Vector的大小。

由於只是對集合對象的一個方法進行同步往往是不夠的,因此,Java集合庫在新的框架中沒有做任何同步。如果調用該集合庫的代碼本身會進行同步,則在集合庫內部再進行同步完全是多餘的。