“進行面向對象的設計時,一項基本的考慮是:如何將發生變化的東西與保持不變的東西分隔開。”
這一點對於庫來說是特別重要的。那個庫的用戶(客戶程序員)必須能依賴自己使用的那一部分,並知道一旦新版本的庫出臺,自己不需要改寫代碼。而與此相反,庫的創建者必須能自由地進行修改與改進,同時保證客戶程序員代碼不會受到那些變動的影響。
為達到這個目的,需遵守一定的約定或規則。例如,庫程序員在修改庫內的一個類時,必須保證不刪除已有的方法,因為那樣做會造成客戶程序員代碼出現斷點。然而,相反的情況卻是令人痛苦的。對於一個數據成員,庫的創建者怎樣才能知道哪些數據成員已受到客戶程序員的訪問呢?若方法屬於某個類唯一的一部分,而且並不一定由客戶程序員直接使用,那麼這種痛苦的情況同樣是真實的。如果庫的創建者想刪除一種舊有的實現方案,並置入新代碼,此時又該怎麼辦呢?對那些成員進行的任何改動都可能中斷客戶程序員的代碼。所以庫創建者處在一個尷尬的境地,似乎根本動彈不得。
為解決這個問題,Java推出了“訪問指示符”的概念,允許庫創建者聲明哪些東西是客戶程序員可以使用的,哪些是不可使用的。這種訪問控制的級別在“最大訪問”和“最小訪問”的範圍之間,分別包括:public,“友好的”(無關鍵字),protected以及private。根據前一段的描述,大家或許已總結出作為一名庫設計者,應將所有東西都儘可能保持為private(私有),並只展示出那些想讓客戶程序員使用的方法。這種思路是完全正確的,儘管它有點兒違背那些用其他語言(特別是C)編程的人的直覺,那些人習慣於在沒有任何限制的情況下訪問所有東西。到這一章結束時,大家應該可以深刻體會到Java訪問控制的價值。
然而,組件庫以及控制誰能訪問那個庫的組件的概念現在仍不是完整的。仍存在這樣一個問題:如何將組件綁定到單獨一個統一的庫單元裡。這是通過Java的package(打包)關鍵字來實現的,而且訪問指示符要受到類在相同的包還是在不同的包裡的影響。所以在本章的開頭,大家首先要學習庫組件如何置入包裡。這樣才能理解訪問指示符的完整含義。
我們用import關鍵字導入一個完整的庫時,就會獲得“包”(Package)。例如:
import java.util.*;
它的作用是導入完整的實用工具(Utility)庫,該庫屬於標準Java開發工具包的一部分。由於Vector位於java.util裡,所以現在要麼指定完整名稱java.util.Vector(可省略import語句),要麼簡單地指定一個Vector(因為import是默認的)。
若想導入單獨一個類,可在import語句裡指定那個類的名字:
import java.util.Vector;
現在,我們可以自由地使用Vector。然而,java.util中的其他任何類仍是不可使用的。
之所以要進行這樣的導入,是為了提供一種特殊的機制,以便管理“命名空間”(Name Space)。我們所有類成員的名字相互間都會隔離起來。位於類A內的一個方法f()不會與位於類B內的、擁有相同“簽名”(參數列表)的f()發生衝突。但類名會不會衝突呢?假設創建一個stack類,將它安裝到已有一個stack類(由其他人編寫)的機器上,這時會出現什麼情況呢?對於因特網中的Java應用,這種情況會在用戶毫不知曉的時候發生,因為類會在運行一個Java程序的時候自動下載。
正是由於存在名字潛在的衝突,所以特別有必要對Java中的命名空間進行完整的控制,而且需要創建一個完全獨一無二的名字,無論因特網存在什麼樣的限制。
迄今為止,本書的大多數例子都僅存在於單個文件中,而且設計成局部(本地)使用,沒有同包名發生衝突(在這種情況下,類名置於“默認包”內)。這是一種有效的做法,而且考慮到問題的簡化,本書剩下的部分也將盡可能地採用它。然而,若計劃創建一個“對因特網友好”或者說“適合在因特網使用”的程序,必須考慮如何防止類名的重複。
為Java創建一個源碼文件的時候,它通常叫作一個“編輯單元”(有時也叫作“翻譯單元”)。每個編譯單元都必須有一個以.java結尾的名字。而且在編譯單元的內部,可以有一個公共(public)類,它必須擁有與文件相同的名字(包括大小寫形式,但排除.java文件擴展名)。如果不這樣做,編譯器就會報告出錯。每個編譯單元內都只能有一個public類(同樣地,否則編譯器會報告出錯)。那個編譯單元剩下的類(如果有的話)可在那個包外面的世界面前隱藏起來,因為它們並非“公共”的(非public),而且它們由用於主public類的“支撐”類組成。
編譯一個.java文件時,我們會獲得一個名字完全相同的輸出文件;但對於.java文件中的每個類,它們都有一個.class擴展名。因此,我們最終從少量的.java文件裡有可能獲得數量眾多的.class文件。如以前用一種彙編語言寫過程序,那麼可能已習慣編譯器先分割出一種過渡形式(通常是一個.obj文件),再用一個鏈接器將其與其他東西封裝到一起(生成一個可執行文件),或者與一個庫封裝到一起(生成一個庫)。但那並不是Java的工作方式。一個有效的程序就是一系列.class文件,它們可以封裝和壓縮到一個JAR文件裡(使用Java 1.1提供的jar工具)。Java解釋器負責對這些文件的尋找、裝載和解釋(註釋①)。
①:Java並沒有強制一定要使用解釋器。一些固有代碼的Java編譯器可生成單獨的可執行文件。
“庫”也由一系列類文件構成。每個文件都有一個public類(並沒強迫使用一個public類,但這種情況最很典型的),所以每個文件都有一個組件。如果想將所有這些組件(它們在各自獨立的.java和.class文件裡)都歸納到一起,那麼package關鍵字就可以發揮作用)。
若在一個文件的開頭使用下述代碼:
package mypackage;
那麼package語句必須作為文件的第一個非註釋語句出現。該語句的作用是指出這個編譯單元屬於名為mypackage的一個庫的一部分。或者換句話說,它表明這個編譯單元內的public類名位於mypackage這個名字的下面。如果其他人想使用這個名字,要麼指出完整的名字,要麼與mypackage聯合使用import關鍵字(使用前面給出的選項)。注意根據Java包(封裝)的約定,名字內的所有字母都應小寫,甚至那些中間單詞亦要如此。
例如,假定文件名是MyClass.java。它意味著在那個文件有一個、而且只能有一個public類。而且那個類的名字必須是MyClass(包括大小寫形式):
package mypackage;
public class MyClass {
// . . .
現在,如果有人想使用MyClass,或者想使用mypackage內的其他任何public類,他們必須用import關鍵字激活mypackage內的名字,使它們能夠使用。另一個辦法則是指定完整的名稱:
mypackage.MyClass m = new mypackage.MyClass();
import關鍵字則可將其變得簡潔得多:
import mypackage.*;
// . . .
MyClass m = new MyClass();
作為一名庫設計者,一定要記住package和import關鍵字允許我們做的事情就是分割單個全局命名空間,保證我們不會遇到名字的衝突——無論有多少人使用因特網,也無論多少人用Java編寫自己的類。
大家或許已注意到這樣一個事實:由於一個包永遠不會真的“封裝”到單獨一個文件裡面,它可由多個.class文件構成,所以局面可能稍微有些混亂。為避免這個問題,最合理的一種做法就是將某個特定包使用的所有.class文件都置入單個目錄裡。也就是說,我們要利用操作系統的分級文件結構避免出現混亂局面。這正是Java所採取的方法。
它同時也解決了另兩個問題:創建獨一無二的包名以及找出那些可能深藏於目錄結構某處的類。正如我們在第2章講述的那樣,為達到這個目的,需要將.class文件的位置路徑編碼到package的名字裡。但根據約定,編譯器強迫package名的第一部分是類創建者的因特網域名。由於因特網域名肯定是獨一無二的(由InterNIC保證——註釋②,它控制著域名的分配),所以假如按這一約定行事,package的名稱就肯定不會重複,所以永遠不會遇到名稱衝突的問題。換句話說,除非將自己的域名轉讓給其他人,而且對方也按照相同的路徑名編寫Java代碼,否則名字的衝突是永遠不會出現的。當然,如果你沒有自己的域名,那麼必須創造一個非常生僻的包名(例如自己的英文姓名),以便盡最大可能創建一個獨一無二的包名。如決定發行自己的Java代碼,那麼強烈推薦去申請自己的域名,它所需的費用是非常低廉的。
②:ftp://ftp.internic.net
這個技巧的另一部分是將package名解析成自己機器上的一個目錄。這樣一來,Java程序運行並需要裝載.class文件的時候(這是動態進行的,在程序需要創建屬於那個類的一個對象,或者首次訪問那個類的一個static成員時),它就可以找到.class文件駐留的那個目錄。
Java解釋器的工作程序如下:首先,它找到環境變量CLASSPATH(將Java或者具有Java解釋能力的工具——如瀏覽器——安裝到機器中時,通過操作系統進行設定)。CLASSPATH包含了一個或多個目錄,它們作為一種特殊的“根”使用,從這裡展開對.class文件的搜索。從那個根開始,解釋器會尋找包名,並將每個點號(句點)替換成一個斜槓,從而生成從CLASSPATH根開始的一個路徑名(所以package foo.bar.baz會變成foo\bar\baz或者foo/bar/baz;具體是正斜槓還是反斜槓由操作系統決定)。隨後將它們連接到一起,成為CLASSPATH內的各個條目(入口)。以後搜索.class文件時,就可從這些地方開始查找與準備創建的類名對應的名字。此外,它也會搜索一些標準目錄——這些目錄與Java解釋器駐留的地方有關。
為進一步理解這個問題,下面以我自己的域名為例,它是bruceeckel.com。將其反轉過來後,com.bruceeckel就為我的類創建了獨一無二的全局名稱(com,edu,org,net等擴展名以前在Java包中都是大寫的,但自Java 1.2以來,這種情況已發生了變化。現在整個包名都是小寫的)。由於決定創建一個名為util的庫,我可以進一步地分割它,所以最後得到的包名如下:
package com.bruceeckel.util;
現在,可將這個包名作為下述兩個文件的“命名空間”使用:
//: Vector.java
// Creating a package
package com.bruceeckel.util;
public class Vector {
public Vector() {
System.out.println(
"com.bruceeckel.util.Vector");
}
} ///:~
創建自己的包時,要求package語句必須是文件中的第一個“非註釋”代碼。第二個文件表面看起來是類似的:
//: List.java
// Creating a package
package com.bruceeckel.util;
public class List {
public List() {
System.out.println(
"com.bruceeckel.util.List");
}
} ///:~
這兩個文件都置於我自己系統的一個子目錄中:
C:\DOC\JavaT\com\bruceeckel\util
若通過它往回走,就會發現包名com.bruceeckel.util,但路徑的第一部分又是什麼呢?這是由CLASSPATH環境變量決定的。在我的機器上,它是:
CLASSPATH=.;D:\JAVA\LIB;C:\DOC\JavaT
可以看出,CLASSPATH裡能包含大量備用的搜索路徑。然而,使用JAR文件時要注意一個問題:必須將JAR文件的名字置於類路徑裡,而不僅僅是它所在的路徑。所以對一個名為grape.jar的JAR文件來說,我們的類路徑需要包括:
CLASSPATH=.;D:\JAVA\LIB;C:\flavors\grape.jar
正確設置好類路徑後,可將下面這個文件置於任何目錄裡(若在執行該程序時遇到麻煩,請參見第3章的3.1.2小節“賦值”):
//: LibTest.java
// Uses the library
package c05;
import com.bruceeckel.util.*;
public class LibTest {
public static void main(String[] args) {
Vector v = new Vector();
List l = new List();
}
} ///:~
編譯器遇到import語句後,它會搜索由CLASSPATH指定的目錄,查找子目錄com\bruceeckel\util,然後查找名稱適當的已編譯文件(對於Vector是Vector.class,對於List則是List.class)。注意Vector和List內無論類還是需要的方法都必須設為public。
(1) 自動編譯
為導入的類首次創建一個對象時(或者訪問一個類的static成員時),編譯器會在適當的目錄裡尋找同名的.class文件(所以如果創建類X的一個對象,就應該是X.class)。若只發現X.class,它就是必須使用的那一個類。然而,如果它在相同的目錄中還發現了一個X.java,編譯器就會比較兩個文件的日期標記。如果X.java比X.class新,就會自動編譯X.java,生成一個最新的X.class。
對於一個特定的類,或在與它同名的.java文件中沒有找到它,就會對那個類採取上述的處理。
(2) 衝突
若通過 * 導入了兩個庫,而且它們包括相同的名字,這時會出現什麼情況呢?例如,假定一個程序使用了下述導入語句:
import com.bruceeckel.util.*;
import java.util.*;
由於 java.util.* 也包含了一個Vector類,所以這會造成潛在的衝突。然而,只要衝突並不真的發生,那麼就不會產生任何問題——這當然是最理想的情況,因為否則的話,就需要進行大量編程工作,防範那些可能可能永遠也不會發生的衝突。
如現在試著生成一個Vector,就肯定會發生衝突。如下所示:
Vector v = new Vector();
它引用的到底是哪個Vector類呢?編譯器對這個問題沒有答案,讀者也不可能知道。所以編譯器會報告一個錯誤,強迫我們進行明確的說明。例如,假設我想使用標準的Java Vector,那麼必須象下面這樣編程:
java.util.Vector v = new java.util.Vector();
由於它(與CLASSPATH一起)完整指定了那個Vector的位置,所以不再需要 import java.util.* 語句,除非還想使用來自java.util的其他東西。
掌握前述的知識後,接下來就可以開始創建自己的工具庫,以便減少或者完全消除重複的代碼。例如,可為System.out.println()創建一個別名,減少重複鍵入的代碼量。它可以是名為tools的一個包(package)的一部分:
//: P.java
// The P.rint & P.rintln shorthand
package com.bruceeckel.tools;
public class P {
public static void rint(Object obj) {
System.out.print(obj);
}
public static void rint(String s) {
System.out.print(s);
}
public static void rint(char[] s) {
System.out.print(s);
}
public static void rint(char c) {
System.out.print(c);
}
public static void rint(int i) {
System.out.print(i);
}
public static void rint(long l) {
System.out.print(l);
}
public static void rint(float f) {
System.out.print(f);
}
public static void rint(double d) {
System.out.print(d);
}
public static void rint(boolean b) {
System.out.print(b);
}
public static void rintln() {
System.out.println();
}
public static void rintln(Object obj) {
System.out.println(obj);
}
public static void rintln(String s) {
System.out.println(s);
}
public static void rintln(char[] s) {
System.out.println(s);
}
public static void rintln(char c) {
System.out.println(c);
}
public static void rintln(int i) {
System.out.println(i);
}
public static void rintln(long l) {
System.out.println(l);
}
public static void rintln(float f) {
System.out.println(f);
}
public static void rintln(double d) {
System.out.println(d);
}
public static void rintln(boolean b) {
System.out.println(b);
}
} ///:~
所有不同的數據類型現在都可以在一個新行輸出(P.rintln()),或者不在一個新行輸出(P.rint())。
大家可能會猜想這個文件所在的目錄必須從某個CLASSPATH位置開始,然後繼續com/bruceeckel/tools。編譯完畢後,利用一個import語句,即可在自己系統的任何地方使用P.class文件。如下所示:
ToolTest.java
所以從現在開始,無論什麼時候只要做出了一個有用的新工具,就可將其加入tools目錄(或者自己的個人util或tools目錄)。
(1) CLASSPATH的陷阱
P.java文件存在一個非常有趣的陷阱。特別是對於早期的Java實現方案來說,類路徑的正確設定通常都是很困難的一項工作。編寫這本書的時候,我引入了P.java文件,它最初看起來似乎工作很正常。但在某些情況下,卻開始出現中斷。在很長的時間裡,我都確信這是Java或其他什麼在實現時一個錯誤。但最後,我終於發現在一個地方引入了一個程序(即第17章要說明的CodePackager.java),它使用了一個不同的類P。由於它作為一個工具使用,所以有時候會進入類路徑裡;另一些時候則不會這樣。但只要它進入類路徑,那麼假若執行的程序需要尋找com.bruceeckel.tools中的類,Java首先發現的就是CodePackager.java中的P。此時,編譯器會報告一個特定的方法沒有找到。這當然是非常令人頭疼的,因為我們在前面的類P裡明明看到了這個方法,而且根本沒有更多的診斷報告可為我們提供一條線索,讓我們知道找到的是一個完全不同的類(那甚至不是public的)。
乍一看來,這似乎是編譯器的一個錯誤,但假若考察import語句,就會發現它只是說:“在這裡可能發現了P”。然而,我們假定的是編譯器搜索自己類路徑的任何地方,所以一旦它發現一個P,就會使用它;若在搜索過程中發現了“錯誤的”一個,它就會停止搜索。這與我們在前面表述的稍微有些區別,因為存在一些討厭的類,它們都位於包內。而這裡有一個不在包內的P,但仍可在常規的類路徑搜索過程中找到。
如果您遇到象這樣的情況,請務必保證對於類路徑的每個地方,每個名字都僅存在一個類。
Java已取消的一種特性是C的“條件編譯”,它允許我們改變參數,獲得不同的行為,同時不改變其他任何代碼。Java之所以拋棄了這一特性,可能是由於該特性經常在C裡用於解決跨平臺問題:代碼的不同部分根據具體的平臺進行編譯,否則不能在特定的平臺上運行。由於Java的設計思想是成為一種自動跨平臺的語言,所以這種特性是沒有必要的。
然而,條件編譯還有另一些非常有價值的用途。一種很常見的用途就是調試代碼。調試特性可在開發過程中使用,但在發行的產品中卻無此功能。Alen Holub(www.holub.com)提出了利用包(package)來模仿條件編譯的概念。根據這一概念,它創建了C“斷定機制”一個非常有用的Java版本。之所以叫作“斷定機制”,是由於我們可以說“它應該為真”或者“它應該為假”。如果語句不同意你的斷定,就可以發現相關的情況。這種工具在調試過程中是特別有用的。
可用下面這個類進行程序調試:
//: Assert.java
// Assertion tool for debugging
package com.bruceeckel.tools.debug;
public class Assert {
private static void perr(String msg) {
System.err.println(msg);
}
public final static void is_true(boolean exp) {
if(!exp) perr("Assertion failed");
}
public final static void is_false(boolean exp){
if(exp) perr("Assertion failed");
}
public final static void
is_true(boolean exp, String msg) {
if(!exp) perr("Assertion failed: " + msg);
}
public final static void
is_false(boolean exp, String msg) {
if(exp) perr("Assertion failed: " + msg);
}
} ///:~
這個類只是簡單地封裝了布爾測試。如果失敗,就顯示出出錯消息。在第9章,大家還會學習一個更高級的錯誤控制工具,名為“異常控制”。但在目前這種情況下,perr()方法已經可以很好地工作。
如果想使用這個類,可在自己的程序中加入下面這一行:
import com.bruceeckel.tools.debug.*;
如欲清除斷定機制,以便自己能發行最終的代碼,我們創建了第二個Assert類,但卻是在一個不同的包裡:
//: Assert.java
// Turning off the assertion output
// so you can ship the program.
package com.bruceeckel.tools;
public class Assert {
public final static void is_true(boolean exp){}
public final static void is_false(boolean exp){}
public final static void
is_true(boolean exp, String msg) {}
public final static void
is_false(boolean exp, String msg) {}
} ///:~
現在,假如將前一個import語句變成下面這個樣子:
import com.bruceeckel.tools.*;
程序便不再顯示出斷言。下面是個例子:
//: TestAssert.java
// Demonstrating the assertion tool
package c05;
// Comment the following, and uncomment the
// subsequent line to change assertion behavior:
import com.bruceeckel.tools.debug.*;
// import com.bruceeckel.tools.*;
public class TestAssert {
public static void main(String[] args) {
Assert.is_true((2 + 2) == 5);
Assert.is_false((1 + 1) == 2);
Assert.is_true((2 + 2) == 5, "2 + 2 == 5");
Assert.is_false((1 + 1) == 2, "1 +1 != 2");
}
} ///:~
通過改變導入的package,我們可將自己的代碼從調試版本變成最終的發行版本。這種技術可應用於任何種類的條件代碼。
大家應注意這樣一個問題:每次創建一個包後,都在為包取名時間接地指定了一個目錄結構。這個包必須存在(駐留)於由它的名字規定的目錄內。而且這個目錄必須能從CLASSPATH開始搜索並發現。最開始的時候,package關鍵字的運用可能會令人迷惑,因為除非堅持遵守根據目錄路徑指定包名的規則,否則就會在運行期獲得大量莫名其妙的消息,指出找不到一個特定的類——即使那個類明明就在相同的目錄中。若得到象這樣的一條消息,請試著將package語句作為註釋標記出去。如果這樣做行得通,就可知道問題到底出在哪兒。
針對類內每個成員的每個定義,Java訪問指示符public,protected以及private都置於它們的最前面——無論它們是一個數據成員,還是一個方法。每個訪問指示符都只控制著對那個特定定義的訪問。這與C++存在著顯著不同。在C++中,訪問指示符控制著它後面的所有定義,直到又一個訪問指示符加入為止。
通過千絲萬縷的聯繫,程序為所有東西都指定了某種形式的訪問。在後面的小節裡,大家要學習與各類訪問有關的所有知識。首次從默認訪問開始。
如果根本不指定訪問指示符,就象本章之前的所有例子那樣,這時會出現什麼情況呢?默認的訪問沒有關鍵字,但它通常稱為“友好”(Friendly)訪問。這意味著當前包內的其他所有類都能訪問“友好的”成員,但對包外的所有類來說,這些成員卻是“私有”(Private)的,外界不得訪問。由於一個編譯單元(一個文件)只能從屬於單個包,所以單個編譯單元內的所有類相互間都是自動“友好”的。因此,我們也說友好元素擁有“包訪問”權限。
友好訪問允許我們將相關的類都組合到一個包裡,使它們相互間方便地進行溝通。將類組合到一個包內以後(這樣便允許友好成員的相互訪問,亦即讓它們“交朋友”),我們便“擁有”了那個包內的代碼。只有我們已經擁有的代碼才能友好地訪問自己擁有的其他代碼。我們可認為友好訪問使類在一個包內的組合顯得有意義,或者說前者是後者的原因。在許多語言中,我們在文件內組織定義的方式往往顯得有些牽強。但在Java中,卻強制用一種頗有意義的形式進行組織。除此以外,我們有時可能想排除一些類,不想讓它們訪問當前包內定義的類。
對於任何關係,一個非常重要的問題是“誰能訪問我們的‘私有’或private代碼”。類控制著哪些代碼能夠訪問自己的成員。沒有任何祕訣可以“闖入”。另一個包內推薦可以聲明一個新類,然後說:“嗨,我是Bob的朋友!”,並指望看到Bob的protected(受到保護的)、友好的以及private(私有)的成員。為獲得對一個訪問權限,唯一的方法就是:
(1) 使成員成為public(公共的)。這樣所有人從任何地方都可以訪問它。
(2) 變成一個“友好”成員,方法是捨棄所有訪問指示符,並將其類置於相同的包內。這樣一來,其他類就可以訪問成員。
(3) 正如以後引入“繼承”概念後大家會知道的那樣,一個繼承的類既可以訪問一個protected成員,也可以訪問一個public成員(但不可訪問private成員)。只有在兩個類位於相同的包內時,它才可以訪問友好成員。但現在不必關心這方面的問題。
(4) 提供“訪問器/變化器”方法(亦稱為“獲取/設置”方法),以便讀取和修改值。這是OOP環境中最正規的一種方法,也是Java Beans的基礎——具體情況會在第13章介紹。
使用public關鍵字時,它意味著緊隨在public後面的成員聲明適用於所有人,特別是適用於使用庫的客戶程序員。假定我們定義了一個名為dessert的包,其中包含下述單元(若執行該程序時遇到困難,請參考第3章3.1.2小節“賦值”):
//: Cookie.java
// Creates a library
package c05.dessert;
public class Cookie {
public Cookie() {
System.out.println("Cookie constructor");
}
void foo() { System.out.println("foo"); }
} ///:~
請記住,Cookie.java必須駐留在名為dessert的一個子目錄內,而這個子目錄又必須位於由CLASSPATH指定的C05目錄下面(C05代表本書的第5章)。不要錯誤地以為Java無論如何都會將當前目錄作為搜索的起點看待。如果不將一個.作為CLASSPATH的一部分使用,Java就不會考慮當前目錄。
現在,假若創建使用了Cookie的一個程序,如下所示:
//: Dinner.java
// Uses the library
import c05.dessert.*;
public class Dinner {
public Dinner() {
System.out.println("Dinner constructor");
}
public static void main(String[] args) {
Cookie x = new Cookie();
//! x.foo(); // Can't access
}
} ///:~
就可以創建一個Cookie對象,因為它的構造器是public的,而且類也是public的(公共類的概念稍後還會進行更詳細的講述)。然而,foo()成員不可在Dinner.java內訪問,因為foo()只有在dessert包內才是“友好”的。
(1) 默認包
大家可能會驚訝地發現下面這些代碼得以順利編譯——儘管它看起來似乎已違背了規則:
//: Cake.java
// Accesses a class in a separate
// compilation unit.
class Cake {
public static void main(String[] args) {
Pie x = new Pie();
x.f();
}
} ///:~
在位於相同目錄的第二個文件裡:
//: Pie.java
// The other class
class Pie {
void f() { System.out.println("Pie.f()"); }
} ///:~
最初可能會把它們看作完全不相干的文件,然而Cake能創建一個Pie對象,並能調用它的f()方法!通常的想法會認為Pie和f()是“友好的”,所以不適用於Cake。它們確實是友好的——這部分結論非常正確。但它們之所以仍能在Cake.java中使用,是由於它們位於相同的目錄中,而且沒有明確的包名。Java把象這樣的文件看作那個目錄“默認包”的一部分,所以它們對於目錄內的其他文件來說是“友好”的。
private關鍵字意味著除非那個特定的類,而且從那個類的方法裡,否則沒有人能訪問那個成員。同一個包內的其他成員不能訪問private成員,這使其顯得似乎將類與我們自己都隔離起來。另一方面,也不能由幾個合作的人創建一個包。所以private允許我們自由地改變那個成員,同時毋需關心它是否會影響同一個包內的另一個類。默認的“友好”包訪問通常已經是一種適當的隱藏方法;請記住,對於包的用戶來說,是不能訪問一個“友好”成員的。這種效果往往能令人滿意,因為默認訪問是我們通常採用的方法。對於希望變成public(公共)的成員,我們通常明確地指出,令其可由客戶程序員自由調用。而且作為一個結果,最開始的時候通常會認為自己不必頻繁使用private關鍵字,因為完全可以在不用它的前提下發布自己的代碼(這與C++是個鮮明的對比)。然而,隨著學習的深入,大家就會發現private仍然有非常重要的用途,特別是在涉及多線程處理的時候(詳情見第14章)。
下面是應用了private的一個例子:
//: IceCream.java
// Demonstrates "private" keyword
class Sundae {
private Sundae() {}
static Sundae makeASundae() {
return new Sundae();
}
}
public class IceCream {
public static void main(String[] args) {
//! Sundae x = new Sundae();
Sundae x = Sundae.makeASundae();
}
} ///:~
這個例子向我們證明了使用private的方便:有時可能想控制對象的創建方式,並防止有人直接訪問一個特定的構造器(或者所有構造器)。在上面的例子中,我們不可通過它的構造器創建一個Sundae對象;相反,必須調用makeASundae()方法來實現(註釋③)。
③:此時還會產生另一個影響:由於默認構造器是唯一獲得定義的,而且它的屬性是private,所以可防止對這個類的繼承(這是第6章要重點講述的主題)。
若確定一個類只有一個“助手”方法,那麼對於任何方法來說,都可以把它們設為private,從而保證自己不會誤在包內其他地方使用它,防止自己更改或刪除方法。將一個方法的屬性設為private後,可保證自己一直保持這一選項(然而,若一個引用被設為private,並不表明其他對象不能擁有指向同一個對象的public引用。有關“別名”的問題將在第12章詳述)。
protected(受到保護的)訪問指示符要求大家提前有所認識。首先應注意這樣一個事實:為繼續學習本書一直到繼承那一章之前的內容,並不一定需要先理解本小節的內容。但為了保持內容的完整,這兒仍然要對此進行簡要說明,並提供相關的例子。
protected關鍵字為我們引入了一種名為“繼承”的概念,它以現有的類為基礎,並在其中加入新的成員,同時不會對現有的類產生影響——我們將這種現有的類稱為“基類”或者“基本類”(Base Class)。亦可改變那個類現有成員的行為。對於從一個現有類的繼承,我們說自己的新類“擴展”(extends)了那個現有的類。如下所示:
class Foo extends Bar {
類定義剩餘的部分看起來是完全相同的。
若新建一個包,並從另一個包內的某個類裡繼承,則唯一能夠訪問的成員就是原來那個包的public成員。當然,如果在相同的包裡進行繼承,那麼繼承獲得的包能夠訪問所有“友好”的成員。有些時候,基類的創建者喜歡提供一個特殊的成員,並允許訪問派生類。這正是protected的工作。若往回引用5.2.2小節“public:接口訪問”的那個Cookie.java文件,則下面這個類就不能訪問“友好”的成員:
//: ChocolateChip.java
// Can't access friendly member
// in another class
import c05.dessert.*;
public class ChocolateChip extends Cookie {
public ChocolateChip() {
System.out.println(
"ChocolateChip constructor");
}
public static void main(String[] args) {
ChocolateChip x = new ChocolateChip();
//! x.foo(); // Can't access foo
}
} ///:~
對於繼承,值得注意的一件有趣的事情是倘若方法foo()存在於類Cookie中,那麼它也會存在於從Cookie繼承的所有類中。但由於foo()在外部的包裡是“友好”的,所以我們不能使用它。當然,亦可將其變成public。但這樣一來,由於所有人都能自由訪問它,所以可能並非我們所希望的局面。若象下面這樣修改類Cookie:
public class Cookie {
public Cookie() {
System.out.println("Cookie constructor");
}
protected void foo() {
System.out.println("foo");
}
}
那麼仍然能在包dessert裡“友好”地訪問foo(),但從Cookie繼承的其他東西亦可自由地訪問它。然而,它並非公共的(public)。
我們通常認為訪問控制是“隱藏實現細節”的一種方式。將數據和方法封裝到類內後,可生成一種數據類型,它具有自己的特徵與行為。但由於兩方面重要的原因,訪問為那個數據類型加上了自己的邊界。第一個原因是規定客戶程序員哪些能夠使用,哪些不能。我們可在結構裡構建自己的內部機制,不用擔心客戶程序員將其當作接口的一部分,從而自由地使用或者“濫用”。
這個原因直接導致了第二個原因:我們需要將接口同實現細節分離開。若結構在一系列程序中使用,但用戶除了將消息發給public接口之外,不能做其他任何事情,我們就可以改變不屬於public的所有東西(如“友好的”、protected以及private),同時不要求用戶對他們的代碼作任何修改。
我們現在是在一個面向對象的編程環境中,其中的一個類(class)實際是指“一類對象”,就象我們說“魚類”或“鳥類”那樣。從屬於這個類的所有對象都共享這些特徵與行為。“類”是對屬於這一類的所有對象的外觀及行為進行的一種描述。
在一些早期OOP語言中,如Simula-67,關鍵字class的作用是描述一種新的數據類型。同樣的關鍵字在大多數面向對象的編程語言裡都得到了應用。它其實是整個語言的焦點:需要新建數據類型的場合比那些用於容納數據和方法的“容器”多得多。
在Java中,類是最基本的OOP概念。它是本書未採用粗體印刷的關鍵字之一——由於數量太多,所以會造成頁面排版的嚴重混亂。
為清楚起見,可考慮用特殊的樣式創建一個類:將public成員置於最開頭,後面跟隨protected、友好以及private成員。這樣做的好處是類的使用者可從上向下依次閱讀,並首先看到對自己來說最重要的內容(即public成員,因為它們可從文件的外部訪問),並在遇到非公共成員後停止閱讀,後者已經屬於內部實現細節的一部分了。然而,利用由javadoc提供支持的註釋文檔(已在第2章介紹),代碼的可讀性問題已在很大程度上得到了解決。
public class X {
public void pub1( ) { /* . . . */ }
public void pub2( ) { /* . . . */ }
public void pub3( ) { /* . . . */ }
private void priv1( ) { /* . . . */ }
private void priv2( ) { /* . . . */ }
private void priv3( ) { /* . . . */ }
private int i;
// . . .
}
由於接口和實現細節仍然混合在一起,所以只是部分容易閱讀。也就是說,仍然能夠看到源碼——實現的細節,因為它們需要保存在類裡面。向一個類的消費者顯示出接口實際是“類瀏覽器”的工作。這種工具能查找所有可用的類,總結出可對它們採取的全部操作(比如可以使用哪些成員等),並用一種清爽悅目的形式顯示出來。到大家讀到這本書的時候,所有優秀的Java開發工具都應推出了自己的瀏覽器。
在Java中,亦可用訪問指示符判斷出一個庫內的哪些類可由那個庫的用戶使用。若想一個類能由客戶程序員調用,可在類主體的起始花括號前面某處放置一個public關鍵字。它控制著客戶程序員是否能夠創建屬於這個類的一個對象。
為控制一個類的訪問,指示符必須在關鍵字class之前出現。所以我們能夠使用:
public class Widget {
也就是說,假若我們的庫名是mylib,那麼所有客戶程序員都能訪問Widget——通過下述語句:
import mylib.Widget;
或者
import mylib.*;
然而,我們同時還要注意到一些額外的限制:
(1) 每個編譯單元(文件)都只能有一個public類。每個編譯單元有一個公共接口的概念是由那個公共類表達出來的。根據自己的需要,它可擁有任意多個提供支撐的“友好”類。但若在一個編譯單元裡使用了多個public類,編譯器就會向我們提示一條出錯消息。
(2) public類的名字必須與包含了編譯單元的那個文件的名字完全相符,甚至包括它的大小寫形式。所以對於Widget來說,文件的名字必須是Widget.java,而不應是widget.java或者WIDGET.java。同樣地,如果出現不符,就會報告一個編譯期錯誤。
(3) 可能(但並常見)有一個編譯單元根本沒有任何公共類。此時,可按自己的意願任意指定文件名。
如果已經獲得了mylib內部的一個類,準備用它完成由Widget或者mylib內部的其他某些public類執行的任務,此時又會出現什麼情況呢?我們不希望花費力氣為客戶程序員編制文檔,並感覺以後某個時候也許會進行大手筆的修改,並將自己的類一起刪掉,換成另一個不同的類。為獲得這種靈活處理的能力,需要保證沒有客戶程序員能夠依賴自己隱藏於mylib內部的特定實現細節。為達到這個目的,只需將public關鍵字從類中剔除即可,這樣便把類變成了“友好的”(類僅能在包內使用)。
注意不可將類設成private(那樣會使除類之外的其他東西都不能訪問它),也不能設成protected(註釋④)。因此,我們現在對於類的訪問只有兩個選擇:“友好的”或者public。若不願其他任何人訪問那個類,可將所有構造器設為private。這樣一來,在類的一個static成員內部,除自己之外的其他所有人都無法創建屬於那個類的一個對象(註釋⑤)。如下例所示:
//: Lunch.java
// Demonstrates class access specifiers.
// Make a class effectively private
// with private constructors:
class Soup {
private Soup() {}
// (1) Allow creation via static method:
public static Soup makeSoup() {
return new Soup();
}
// (2) Create a static object and
// return a reference upon request.
// (The "Singleton" pattern):
private static Soup ps1 = new Soup();
public static Soup access() {
return ps1;
}
public void f() {}
}
class Sandwich { // Uses Lunch
void f() { new Lunch(); }
}
// Only one public class allowed per file:
public class Lunch {
void test() {
// Can't do this! Private constructor:
//! Soup priv1 = new Soup();
Soup priv2 = Soup.makeSoup();
Sandwich f1 = new Sandwich();
Soup.access().f();
}
} ///:~
④:實際上,Java 1.1內部類既可以是“受到保護的”,也可以是“私有的”,但那屬於特別情況。第7章會詳細解釋這個問題。
⑤:亦可通過從那個類繼承來實現。
迄今為止,我們創建過的大多數方法都是要麼返回void,要麼返回一個基本數據類型。所以對下述定義來說:
public static Soup access() {
return psl;
}
它最開始多少會使人有些迷惑。位於方法名(access)前的單詞指出方法到底返回什麼。在這之前,我們看到的都是void,它意味著“什麼也不返回”(void在英語裡是“虛無”的意思。但亦可返回指向一個對象的引用,此時出現的就是這個情況。該方法返回一個引用,它指向類Soup的一個對象。
Soup類向我們展示出如何通過將所有構造器都設為private,從而防止直接創建一個類。請記住,假若不明確地至少創建一個構造器,就會自動創建默認構造器(沒有參數)。若自己編寫默認構造器,它就不會自動創建。把它變成private後,就沒人能為那個類創建一個對象。但別人怎樣使用這個類呢?上面的例子為我們揭示出了兩個選擇。第一個選擇,我們可創建一個static方法,再通過它創建一個新的Soup,然後返回指向它的一個引用。如果想在返回之前對Soup進行一些額外的操作,或者想了解準備創建多少個Soup對象(可能是為了限制它們的個數),這種方案無疑是特別有用的。
第二個選擇是採用“設計模式”(Design Pattern)技術,本書後面會對此進行詳細介紹。通常方案叫作“單例”,因為它僅允許創建一個對象。類Soup的對象被創建成Soup的一個static private成員,所以有一個而且只能有一個。除非通過public方法access(),否則根本無法訪問它。
正如早先指出的那樣,如果不針對類的訪問設置一個訪問指示符,那麼它會自動默認為“友好的”。這意味著那個類的對象可由包內的其他類創建,但不能由包外創建。請記住,對於相同目錄內的所有文件,如果沒有明確地進行package聲明,那麼它們都默認為那個目錄的默認包的一部分。然而,假若那個類一個static成員的屬性是public,那麼客戶程序員仍然能夠訪問那個static成員——即使它們不能創建屬於那個類的一個對象。
對於任何關係,最重要的一點都是規定好所有方面都必須遵守的界限或規則。創建一個庫時,相當於建立了同那個庫的用戶(即“客戶程序員”)的一種關係——那些用戶屬於另外的程序員,可能用我們的庫自行構建一個應用程序,或者用我們的庫構建一個更大的庫。
如果不制訂規則,客戶程序員就可以隨心所欲地操作一個類的所有成員,無論我們本來願不願意其中的一些成員被直接操作。所有東西都在別人面前都暴露無遺。
本章講述瞭如何構建類,從而製作出理想的庫。首先,我們講述如何將一組類封裝到一個庫裡。其次,我們講述類如何控制對自己成員的訪問。
一般情況下,一個C程序項目會在50K到100K行代碼之間的某個地方開始中斷。這是由於C僅有一個“命名空間”,所以名字會開始互相抵觸,從而造成額外的管理開銷。而在Java中,package關鍵字、包命名方案以及import關鍵字為我們提供對名字的完全控制,所以命名衝突的問題可以很輕易地得到避免。
有兩方面的原因要求我們控制對成員的訪問。第一個是防止用戶接觸那些他們不應碰的工具。對於數據類型的內部機制,那些工具是必需的。但它們並不屬於用戶接口的一部分,用戶不必用它來解決自己的特定問題。所以將方法和字段變成“私有”(private)後,可極大方便用戶。因為他們能輕易看出哪些對於自己來說是最重要的,以及哪些是自己需要忽略的。這樣便簡化了用戶對一個類的理解。
進行訪問控制的第二個、也是最重要的一個原因是:允許庫設計者改變類的內部工作機制,同時不必擔心它會對客戶程序員產生什麼影響。最開始的時候,可用一種方法構建一個類,後來發現需要重新構建代碼,以便達到更快的速度。如接口和實現細節早已進行了明確的分隔與保護,就可以輕鬆地達到自己的目的,不要求用戶改寫他們的代碼。
利用Java中的訪問指示符,可有效控制類的創建者。那個類的用戶可確切知道哪些是自己能夠使用的,哪些則是可以忽略的。但更重要的一點是,它可確保沒有任何用戶能依賴一個類的基礎實現機制的任何部分。作為一個類的創建者,我們可自由修改基礎的實現細節,這一改變不會對客戶程序員產生任何影響,因為他們不能訪問類的那一部分。
有能力改變基礎的實現細節後,除了能在以後改進自己的設置之外,也同時擁有了“犯錯誤”的自由。無論當初計劃與設計時有多麼仔細,仍然有可能出現一些失誤。由於知道自己能相當安全地犯下這種錯誤,所以可以放心大膽地進行更多、更自由的試驗。這對自己編程水平的提高是很有幫助的,使整個項目最終能更快、更好地完成。
一個類的公共接口是所有用戶都能看見的,所以在進行分析與設計的時候,這是應儘量保證其準確性的最重要的一個部分。但也不必過於緊張,少許的誤差仍然是允許的。若最初設計的接口存在少許問題,可考慮添加更多的方法,只要保證不刪除客戶程序員已在他們的代碼裡使用的東西。
(1) 用public、private、protected以及“友好的”數據成員及方法成員創建一個類。創建屬於這個類的一個對象,並觀察在試圖訪問所有類成員時會獲得哪種類型的編譯器錯誤提示。注意同一個目錄內的類屬於“默認”包的一部分。
(2) 用protected數據創建一個類。在相同的文件裡創建第二個類,用一個方法操縱第一個類裡的protected數據。
(3) 新建一個目錄,並編輯自己的CLASSPATH,以便包括那個新目錄。將P.class文件複製到自己的新目錄,然後改變文件名、P類以及方法名(亦可考慮添加額外的輸出,觀察它的運行過程)。在一個不同的目錄裡創建另一個程序,令其使用自己的新類。
(4) 在c05目錄(假定在自己的CLASSPATH裡)創建下述文件:
214頁程序
然後在c05之外的另一個目錄裡創建下述文件:
214-215頁程序
解釋編譯器為什麼會產生一個錯誤。將Foreign(外部)類作為c05包的一部分改變了什麼東西嗎?