對 象 與 類
4.1 面向物件程式設計概述
面向物件程式設計(簡稱 OOP) 是當今主流的程式設計範型, 它已經取代了 20 世紀 70 年代的“ 結構化” 過程化程式設計開發技術。Java 是完全面向物件的, 必須熟悉 OOP 才能 夠編寫 Java 程式。
面向物件的程式是由物件組成的, 每個物件包含對使用者公開的特定功能部分和隱藏的實現部分。程式中的很多物件來自標準庫,還有一些是自定義的。究竟是自己構造物件, 還是從外界購買物件完全取決於開發專案的預算和時間。但是, 從根本上說, 只要物件能夠滿足要求,就不必關心其功能的具體實現過程。在 OOP 中, 不必關心物件的具體實現,只要能夠滿足使用者的需求即可。
傳統的結構化程式設計通過設計一系列的過程(即演算法)來求解問題。一旦確定了這些過程, 就要開始考慮儲存資料的方式。這就是 Pascal 語言的設計者 Niklaus Wirth 將其著作命名為《演算法 + 資料結構 = 程式 (Algorithms + Data Structures = Programs, Prentice Hall, 1975 ) 的原因。需要注意的是,在 Wirth 命名的書名中, 演算法是第一位的,資料結構是第二位的,這就明確地表述了程式設計師的工作方式。 首先要確定如何操作資料, 然後再決定如何組織資料, 以便於資料操作。 而 OOP 卻調換了這個次序, 將資料放在第一位,然後再考慮操作資料的演算法。
對於一些規模較小的問題, 將其分解為過程的開發方式比較理想。而面向物件更加適用於解決規模較大的問題。耍想實現一個簡單的 Web 瀏覽器可能需要大約 2000 個過程,這些過程可能需要對一組全域性資料進行操作。採用面向物件的設計風格, 可能只需要大約 100 個 類,每個類平均包含 20 個方法(如圖 4-1所示。) 後者更易於程式設計師掌握, 也容易找到 bug 假設給定物件的資料出錯了,在訪問過這個資料項的 20 個方法中查詢錯誤要比在 2000 個過 程中查詢容易得多。
4.1.1 類
類( class) 是構造物件的模板或藍圖。我們可以將類想象成製作小甜餅的切割機,將物件想象為小甜餅。由類構造(construct) 物件的過程稱為建立類的例項 (instance )。
正如前面所看到的, 用 Java 編寫的所有程式碼都位於某個類的內部。標準的 Java 庫提供 了幾千個類,可以用於使用者介面設計、日期、 日曆和網路程式設計。儘管如此,還是需要在 Java 程式中建立一些自己的類, 以便描述應用程式所對應的問題域中的物件。
封裝( encapsulation , 有時稱為資料隱藏) 是與物件有關的一個重要概念。從形式上看, 封裝不過是將資料和行為組合在一個包中, 並對物件的使用者隱藏了資料的實現方式。物件中的資料稱為例項域( instance field ), 操縱資料的過程稱為方法( method )。 對於每個特定的 類例項(物件)都有一組特定的例項域值。這些值的集合就是這個物件的當前狀態( state )。 無論何時,只要向物件傳送一個訊息,它的狀態就有可能發生改變。
實現封裝的關鍵在於絕對不能讓類中的方法直接地訪問其他類的例項域。程式僅通過物件的方法與物件資料進行互動。封裝給物件賦予了“ 黑盒” 特徵, 這是提高重用性和可靠性 的關鍵。 這意味著一個類可以全面地改變儲存資料的方式,只要仍舊使用同樣的方法操作資料, 其他物件就不會知道或介意所發生的變化。
OOP 的另一個原則會讓使用者自定義 Java 類變得輕而易舉,這就是:可以通過擴充套件一個 類來建立另外一個新的類。事實上, 在 Java 中, 所有的類都源自於一個“ 神通廣大的超類”, 它就是 Object。在下一章中, 讀者將可以看到有關 Object 類的詳細介紹。
在擴充套件一個已有的類時, 這個擴充套件後的新類具有所擴充套件的類的全部屬性和方法。在新類 中,只需提供適用於這個新類的新方法和資料域就可以了。通過擴充套件一個類來建立另外一個 類的過程稱為繼承(inheritance) ,有關繼承的詳細內容請參看下一章。
4.1.2 物件
要想使用 OOP, —定要清楚物件的三個主要特性:
•物件的行為(behavior) —可以對物件施加哪些操作,或可以對物件施加哪些方法?
•物件的狀態(state ) —當施加那些方法時,物件如何響應?
•物件標識(identity ) —如何辨別具有相同行為與狀態的不同物件?
同一個類的所有物件例項, 由於支援相同的行為而具有家族式的相似性。物件的行為是用可呼叫的方法定義的。
此外,每個物件都儲存著描述當前特徵的資訊。這就是物件的狀態。物件的狀態可能會隨著時間而發生改變,但這種改變不會是自發的。物件狀態的改變必須通過呼叫方法實現 (如果不經過方法呼叫就可以改變物件狀態,只能說明封裝性遭到了破壞。
但是,物件的狀態並不能完全描述一個物件。每個物件都有一個唯一的身份( identity) 。例 如,在一個訂單處理系統中, 任何兩個訂單都存在著不同之處 ’ 即使所訂購的貨物完全相同也是 如此。需要注意 ,作為一個類的例項, 每個物件的標識永遠是不同的,狀態常常也存在著差異。
物件的這些關鍵特性在彼此之間相互影響著。例如, 物件的狀態影響它的行為(如果一 個訂單“ 已送貨” 或“ 已付款”, 就應該拒絕呼叫具有增刪訂單中條目的方法。反過來, 如 果訂單是“ 空的”,即還沒有加人預訂的物品,這個訂單就不應該進人“ 已送貨” 狀態。
4.1.3 識別類
傳統的過程化程式設計, 必須從頂部的 main 函式開始編寫程式。在面向物件程式設計時沒有所謂的“ 頂部”。對於學習OOP 的初學者來說常常會感覺無從下手。答案是:首先從設計類開始,然後再往每個類中新增方法。
識別類的簡單規則是在分析問題的過程中尋找名詞,而方法對應著動詞。 例如, 在訂單處理系統中,有這樣一些名詞:
•商品(Item)
•訂單(Order)
•送貨地址(Shippingaddress)
•付 款 ( Payment )
•賬戶(Account)
這些名詞很可能成為類 Item、 Order 等。
接下來, 檢視動詞:商品被新增到訂單中, 訂單被髮送或取消, 訂單貨款被支付。對於每一個動詞如:“ 新增”、“ 傳送”、“ 取消” 以及“ 支付”, 都要標識出主要負責完成相應動作 的物件。例如,當一個新的商品新增到訂單中時, 那個訂單物件就是被指定的物件, 因為它 知道如何儲存商品以及如何對商品進行排序。也就是說,add 應該是 Order 類的一個方法, 而 Item 物件是一個引數。
當然, 所謂“ 找名詞與動詞” 原則只是一種經驗,在建立類的時候, 哪些名詞和動詞是重要的完全取決於個人的開發經驗。
4.1.4 類之間的關係
在類之間, 最常見的關係有
•依賴(“ uses-a”)
•聚合(“ has-a”)
•繼承(“ is-a”)
依賴( dependence ), 即“ uses-a” 關係, 是一種最明顯的、 最常見的關係。例如,Order 類使用 Account 類是因為 Order 物件需要訪問 Account 物件檢視信用狀態。但是 Item 類不依 賴於 Account 類, 這是因為 Item 物件與客戶賬戶無關。因此, 如果一個類的方法操縱另一個 類的物件,我們就說一個類依賴於另一個類。
應該儘可能地將相互依賴的類減至最少。如果類 A 不知道 B 的存在, 它就不會關心 B 的任何改變(這意味著 B 的改變不會導致 A 產生任何 bug )。用軟體工程的術語來說,就是 讓類之間的耦合度最小。
聚合(aggregation ), 即“ has-a ” 關係, 是一種具體且易於理解的關係。例如, 一個 Order 物件包含一些 Item 物件。聚合關係意味著類 A 的物件包含類 B 的物件。
註釋: 有些方法學家不喜歡聚合這個概念,而更加喜歡使用“ 關聯” 這個術語。從建模 的角度看, 這是可以理解的。但對於程式設計師來說,“ has-a” 顯得更加形象。喜歡使用聚合的另一個理由是關聯的標準符號不易區分, 請參看表 4-1。
繼承( inheritance ), 即“ is-a” 關係, 是一種用於表示特殊與一般關係的。例如,Rush Order類由 Order 類繼承而來。在具有特殊性的 RushOrder 類中包含了一些用於優先處理的特殊方法, 以及一個計算運費的不同方法;而其他的方法, 如新增商品、 生成賬單等都是從 Order 類繼承來的。一般而言, 如果類 A 擴充套件類 B, 類 A 不但包含從類 B 繼承的方法,還會 擁有一些額外的功能(下一章將詳細討論繼承,其中會用較多的篇幅講述這個重要的概念)。
很多程式設計師採用 UML ( Unified Modeling Language , 統一建模語言)繪製類圖,用來描述類之間的關係。圖 4-2 就是這樣一個例子。類用矩形表示,類之間的關係用帶有各種修飾 的箭頭表示。表 4-1 給出了 UML 中最常見的箭頭樣式。
4.2 使用預定義類
在 Java 中, 沒有類就無法做任何事情, 我們前面曾經接觸過幾個類。然而,並不是所有的類都具有面向物件特徵。例如,Math 類。在程式中,可以使用 Math 類的方法, 如 Math, random, 並只需要知道方法名和引數(如果有的話,) 而不必瞭解它的具體實現過程。這正是 封裝的關鍵所在,當然所有類都是這樣。但遺憾的是,Math 類只封裝了功能,它不需要也不必隱藏資料。由於沒有資料,因此也不必擔心生成物件以及初始化例項域。
下一節將會給出一個更典型的類—Date 類,從中可以看到如何構造物件, 以及如何調 用類的方法。
4.2.1 物件與物件變數
要想使用物件,就必須首先構造物件, 並指定其初始狀態。然後,對物件應用方法。
在 Java 程式設計語言中, 使用構造器(constructor ) 構造新例項。構造器是一種特殊的方法, 用來構造並初始化物件。下面看一個例子。 在標準 Java 庫中包含一個 Date 類。它的 物件將描述一個時間點, 例如:“ December 31, 1999, 23:59:59 GMT”。
註釋: 你可能會感到奇怪: 為什麼用類描述時間, 而不像其他語言那樣用一個內建 的 ( built-in) 型別? 例如, 在 Visual Basic 中有一個內建的 date 型別, 程式設計師可以採用 #6/1/1995# 格式指定日期。從表面上看, 這似乎很方便, 因為程式設計師使用內建的 date 型別, 而不必為設計類而操心。但實際上, Visual Basic 這樣設計的適應性如何呢? 在有些地區日期表示為月 / 日 / 年, 而另一些地區表示為日 / 月 / 年。語言設計者是否能夠預見這些問題 呢? 如果沒有處理好這類問題,語言就有可能陷入混亂, 對此感到不滿的程式設計師也會喪失使用這種語言的熱情。如果使用類, 這些設計任務就交給了類庫的設計者。如果類設計的不完善, 其他的操作員可以很容易地編寫自己的類, 以便增強或替代( replace) 系統提供的類(作為這個問題的印證:Java 的日期類庫有些混亂, 已經重新設計了兩次)。
構造器的名字應該與類名相同。因此 Date 類的構造器名為 Date。要想構造一個 Date 對 象, 需要在構造器前面加上 new 操作符,如下所示:
new Date()
這個表示式構造了一個新物件。這個物件被初始化為當前的日期和時間。
這個表示式構造了一個新物件。這個物件被初始化為當前的日期和時間。 如果需要的話, 也可以將這個物件傳遞給一個方法:
System.out.printTn(new Date());
或者, 也可以將一個方法應用於剛剛建立的物件。Date 類中有一個 toString 方法。這 個方法將返回日期的字串描述。下面的語句可以說明如何將 toString 方法應用於新構造的 Date 物件上。
String s = new Date().toString();
在這兩個例子中, 構造的物件僅使用了一次。通常, 希望構造的物件可以多次使用, 因 此,需要將物件存放在一個變數中:
Date birthday = new Date();
圖 4-3顯示了引用新構造的物件變數 birthday。
在物件與物件變數之間存在著一個重要的區別。例如, 語句
Date deadline; // deadline doesn't refer to any object
定義了一個物件變數 deadline, 它 可 以 引 用 Date 型別的物件。但是,一定要認識到: 變數 deadline 不是一個物件, 實際上也沒有引用物件。此時,不能將任何 Date 方法應用於這個變數上。語句
s = deadline.toStringO; // not yet
將產生編譯錯誤。 必須首先初始化變數 deadline, 這裡有兩個選擇。當然,可以用新構造的物件初始化這 個變數:
deadline = new Date();
也讓這個變數引用一個已存在的物件:
deadline = birthday;
現在,這兩個變數引用同一個物件(請參見圖 4-4 )。
一定要認識到: 一個物件變數並沒有實際包含一個物件,而僅僅引用一個物件。
在 Java 中,任何物件變數的值都是對儲存在另外一個地方的一個物件的引用。new 操作符的返回值也是一個引用。下列語句:
Date deadline = new Date();
有兩個部分。表示式 new Date() 構造了一個 Date 型別的物件, 並且它的值是對新建立物件的 引用。這個引用儲存在變數 deadline 中。
可以顯式地將物件變數設定為 null, 表明這個物件變數目前沒有引用任何物件。
deadline = null;
...
if (deadline != null)
System.out.println(deadline);
如果將一個方法應用於一個值為 null 的物件上,那麼就會產生執行時錯誤。
birthday = null;
String s = birthday.toString(); // runtime error!
區域性變數不會自動地初始化為 null,而必須通過呼叫 new 或將它們設定為 null 進行初始化。
C++ 註釋:很多人錯誤地認為 Java 物件變數與 C++ 的引用類似。然而,在 C++ 中沒有 空引用, 並且引用不能被賦值。可以將 Java 的物件變數看作 C++ 的物件指標。
例如,
Date birthday; // Java
實際上,等同於
Date* birthday; // C++
一旦理解了這一點, 一切問題就迎刃而解了。 當然,一個 Date* 指標只能通過呼叫 new 進行初始化。就這一點而言,C++與 Java 的語法幾乎是一樣的
Date* birthday = new Date(); // C++
如果把一個變數的值賦給另一個變數, 兩個變數就指向同一個日期,即它們是同一 個物件的指標。 在 Java 中的 null 引用對應 C++ 中的 NULL 指標。
所有的 Java 物件都儲存在堆中。 當一個物件包含另一個物件變數時, 這個變數依然包含著指向另一個堆物件的指標。
在 C++ 中, 指標十分令人頭疼, 並常常導致程式錯誤。稍不小心就會建立一個錯誤 的指標,或者造成記憶體溢位。在 Java 語言中,這些問題都不復存在。 如果使用一個沒有 初始化的指標, 執行系統將會產生一個執行時錯誤, 而不是生成一個隨機的結果, 同時, 不必擔心記憶體管理問題,垃圾收集器將會處理相關的事宜。
C++ 確實做了很大的努力, 它通過拷貝型構造器和複製操作符來實現物件的自動拷貝。 例如, 一個連結串列( linked list) 拷貝的結果將會得到一個新連結串列, 其內容與原始連結串列 相同, 但卻是一組獨立的連結。這使得將同樣的拷貝行為內建在類中成為可能。在 Java 中,必須使用 clone 方法獲得物件的完整拷貝 „
4.2.2 Java 類庫中的 LocalDate 類
在前面的例子中, 已經使用了 Java 標準類庫中的 Date 類。Date 類的例項有一個狀態, 即特定的時間點。
儘管在使用 Date 類時不必知道這一點,但時間是用距離一個固定時間點的毫秒數(可正 可負) 表示的, 這個點就是所謂的紀元( epoch), 它 是 UTC 時間 1970 年 1 月 1 日 00:00:00。 UTC 是 Coordinated Universal Time 的縮寫,與大家熟悉的 GMT ( 即 Greenwich Mean Time, 格林威治時間)一樣,是一種具有實踐意義的科學標準時間。
但是,Date 類所提供的日期處理並沒有太大的用途。Java 類庫的設計者認為: 像“December 31, 1999, 23:59:59" 這樣的日期表示法只是陽曆的固有習慣。這種特定的描述法 遵循了世界上大多數地區使用的 Gregorian 陽曆表示法。但是, 同一時間點採用中國的農曆 表示和採用希伯來的陰曆表示就很不一樣,對於火星曆來說就更不可想象了。
註釋: 有史以來,人類的文明與曆法的設計緊緊地相連, 日曆給日期命名、 給太陽和月亮的週期排列次序。有關世界上各種日曆的有趣解釋, 從法國革命的日曆到瑪雅人計算 曰期的方法等, 請參看 Nachum Dershowitz 和 Edward M. Reingold 編寫的《 Calendrical Calculations》第 3 版(劍橋大學出版社,2007 年)。
類庫設計者決定將儲存時間與給時間點命名分開。所以標準 Java 類庫分別包含了兩個類: 一個是用來表示時間點的 Date 類;另一個是用來表示大家熟悉的日曆表示法的 LocalDate 類。 Java SE 8引入了另外一些類來處理日期和時間的不同方面一有關內容參見卷 II 第 6 章。
將時間與日曆分開是一種很好的面向物件設計。通常,最好使用不同的類表示不同的概念。
不要使用構造器來構造 LocalDate 類的物件。實際上,應當使用靜態工廠方法 (factory method) 代表你呼叫構造器。下面的表示式
Local Date.now()
會構造一個新物件,表示構造這個物件時的日期。
可以提供年、 月和日來構造對應一個特定日期的物件:
LocalDate.of(1999, 12, 31)
當然, 通常都希望將構造的物件儲存在一個物件變數中:
LocalDate newYearsEve = Local Date.of(1999, 12, 31);
一旦有 了一個 LocalDate 物件, 可以用方法 getYear、 getMonthValue 和 getDayOfMonth 得到年、月和日:
int year = newYearsEve.getYearO; // 1999
int month = newYearsEve.getMonthValueO; // 12
int day = newYearsEve.getDayOfMonth(); // 31
看起來這似乎沒有多大的意義, 因為這正是構造物件時使用的那些值。不過,有時可能 某個日期是計算得到的,你希望呼叫這些方法來得到更多資訊。例如, plusDays 方法會得到 一個新的 LocalDate, 如果把應用這個方法的物件稱為當前物件,這個新日期物件則是距當 前物件指定天數的一個新日期:
LocalDate aThousandDaysLater = newYearsEve.piusDays(1000):
year = aThousandDaysLater.getYearO;// 2002
month = aThousandDaysLater.getMonthValueO; // 09
day = aThousandDaysLater.getDayOfMonth(); // 26
LocalDate 類封裝了例項域來維護所設定的日期。如果不檢視原始碼, 就不可能知道類內部的日期表示。當然, 封裝的意義在於,這一點並不重要, 重要的是類對外提供的方法。
註釋:實際上,Date 類還有 getDay、getMonth 以及 getYear 等方法, 然而並不推薦使用這些方法。 當類庫設計者意識到某個方法不應該存在時, 就把它標記為不鼓勵使用。
類庫設計者意識到應當單獨提供類來處理日曆,不過在此之前這些方法已經是 Date 類的一部分了。Java 1.1 中引入較早的一組日曆類時,Date 方法被標為廢棄不用。 雖然 仍然可以在程式中使用這些方法,不過如果這樣做, 編譯時會出現警告。 最好還是不要 使用這些廢棄不用的方法, 因為將來的某個類庫版本很有可能將它們完全刪除。
4.2.2 更改器方法與訪問器方法
再來看上一節中的 plusDays 方法呼叫:
LocalDate aThousandDaysLater = newYearsEve.plusDays(1000);
這個呼叫之後 newYeareEve 會有什麼變化? 它會改為 1000 天之後的日期嗎? 事實上,並沒 有。plusDays 方法會生成一個新的 LocalDate 物件,然後把這個新物件賦給 aThousandDaysLater 變數。原來的物件不做任何改動。 我們說 plusDays 方法沒有更改呼叫這個方法的物件。(這類 似於第 3章中見過的 String 類的 toUpperCase 方法。在一個字串上呼叫 toUpperCase 時,這 個字串仍保持不變,會返回一個將字元大寫的新字串。)
Java 庫的一個較早版本曾經有另一個類來處理日曆,名為 GregorianCalendar。 可以如下 為這個類表示的一個日期增加 1000 天:
Cregori anCalendar someDay = new CregorianCalendar(1999, 11, 31);
//Odd feature of that class: month numbers go from 0 to 11
someDay.add(Calendar.DAY_0F_M0NTH, 1000);
與 LocalDate.plusDays 方法不同,GregorianCalendar.add 方法是一個更改器方法 ( mutator method ) 呼叫這個方法後,someDay 物件的狀態會改變。可以如下査看新狀態:
year = someDay.get(Calendar.YEAR); // 2002
month = someDay.get(Calendar.MONTH)+ 1; // 09
day = someDay.get(Ca1endar.DAY_0F_M0NTH); // 26
正是因為這個原因,我們將變數命名為 someDay 而不是 newYearsEve 呼叫這個更改 器方法之後,它不再是新年前夜。
相反, 只 訪 問 對 象 而 不 修 改 對 象 的 方 法 有 時 稱 為 訪 問 器 方 法 。例 如, LocalDate.getYear 和 GregorianCalendar.get 就是訪問器方法。
C++ 註釋: 在 C++ 中, 帶有 const 字尾的方法是訪問器方法;預設為更改器方法。但是, 在 Java 語言中, 訪問器方法與更改器方法在語法上沒有明顯的區別。
下面用一個應用 LocalDate 類的程式來結束本節內容的論述。這個程式將顯示當前月的日曆,其格式為:
Mon Tue Wed Thu Fri Sat Sun
1 2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26* 27 28 29
30
當前的日用一個 * 號標記。可以看到,這個程式需要解決如何計算某月份的天數以及一個給 定日期相應是星期幾。
下面看一下這個程式的關鍵步驟。首先, 構造了一個日曆物件, 並用當前的日期和時間 進行初始化。
LocalDate date = LocalDate.now ();
下面獲得當前的月和日。
int month = date.getMonthValue ();
int today = date.getDayOfMonth();
然後, 將 date 設定為這個月的第一天, 並得到這一天為星期幾。
date = date.minusDays (today - 1); // Set to start of month
DayOfWeek weekday = date.getDayOfWeek();
int value = weekday .getValue(); // 1 = Monday , .. . 7 = Sunday
變數 weekday 設定為 DayOfWeek 型別的物件。我們呼叫這個物件的 getValue 方法來得 到星期幾的一個數值。這會得到一個整數, 這裡遵循國際慣例, 即週末是一週的末尾, 星期 一就返冋 1, 星期二返回 2, 依此類推。星期日則返回 7。
注意,日曆的第一行是縮排的, 使得月份的第一天指向相應的星期幾。下面的程式碼會打 印表頭和第一行的縮排:
System . out.println("Mon Tue Wed Thu Fri Sat Sun");
for (int i = 1; i < value ; i++)
System,out .print(" ");
現在我們來列印日曆的主體。進入一個迴圈, 其中 date 遍歷一個月中的每一天。
每次迭代時, 列印 日期值。 如果 date 是當前日期, 這個日期則用一個 *標記。接下來, 把 date 推進到下一天。如果到達新的一週的第一天, 則換行列印:
while (date .getMonthValueO == month)
{
System.out.printf("%3d" , date.getDayOfMonth());
if (date.getDayOfMonth() == today)
System.out.print("*");
else
System.out.print(" ");
date = date.plusDays(l);
if (date.getDayOfWeekQ .getValueQ = 1) System.out.println();
}
什麼時候結束呢? 我們不知道這個月有幾天, 是 31 天、30 天、29 天還是 28 天。實際上, 只要 date 還在當月就要繼續迭代。
可以看到, 利用 LocalDate 類可以編寫一個口歷程序, 能處理星期幾以及各月天數不同 等複雜問題。你並不需要知道 LocalDate 類如何計算月和星期幾。只需要使用這個類的介面, 如 plusDays 和 getDayOfWeek 等方法。
這個示例程式的重點是向你展示如何使用一個類的介面來完成相當複雜的任務, 而無需瞭解實現細節。
API java.time.LocalDate 8
• static Local Time now( ) 構造一個表示當前日期的物件。
• static LocalTime of(int year, int month , int day ) 構造一個表示給定日期的物件。
• int getYear( )
• int getMonthValue( )
• int getDayOfMonth( )
得到當前日期的年、 月和曰。
• DayOfWeek getDayOfWeek
得到當前日期是星期幾, 作為 DayOfWeek 類的一個例項返回。 呼叫 getValue 來得到 1 ~ 7 之間的一個數, 表示這是星期幾, 1 表示星期一, 7 表示星期日。
• Local Date piusDays(int n )
• Local Date minusDays(int n)
生成當前日期之後或之前 n 天的日期。
4.3 使用者自定義類
在第 3 章中, 已經開始編寫了一些簡單的類。但是, 那些類都只包含一個簡單的 main 方法。現在開始學習如何設計複雜應用程式所需要的各種主力類( workhorse class) 。通常, 這些類沒有 main 方法, 卻有自己的例項域和例項方法。 要想建立一個完整的程式, 應該將若干類組合在一起, 其中只有一個類有 main 方法。
4.3.1 Employee類
在 Java 中, 最簡單的類定義形式為:
class ClassName
{
field1
field2
constructor1
constructor2
...
method1
method2
...
}
下面看一個非常簡單的 Employee 類。 在編寫薪金管理系統時可能會用到。
class Employee
{
// instance fields
private String name ;
private double salary;
private Local Date hireDay; // constructor
public Employee(String n , double s, int year, int month , int day)
{
name = n;
salary = s;
hireDay = Local Date.of(year, month, day);
} // a method
public String getNameO
{
return name;
}
// more methods
...
}
這裡將這個類的實現細節分成以下幾個部分, 並分別在稍後的幾節中給予介紹。 下面先看看 程式清單 4-2, 這個程式顯示了一個 Employee 類的實際使用。(略過)
在這個程式中,構造了一個 Employee 陣列, 並填入了三個僱員物件:
Employee [] staff = new Employee[3];
staff[0] = new Employee("Carl Cracker", ...);
staff[1] = new Employee("Harry Hacker", . . .);
staff[2] = new Employee("Tony Tester", . . .);
接下來,利用 Employee 類的 raiseSalary方法將每個僱員的薪水提高 5%:
for (Employee e : staff)
e.raiseSalary(5);
最後,呼叫 getName 方法、getSalary方法和 getHireDay 方法將每個僱員的資訊打印出來:
for (Employee e : staff)
System.out.println("name=" + e.getName()
+ ",salary=" + e.getSalary()
+ ",hireDay=" + e.getHireDay());
注意,在這個示例程式中包含兩個類:Employee類和帶有 public 訪問修飾符的 EmployeeTest 類。EmployeeTest 類包含了 main 方法,其中使用了前面介紹的指令。
原始檔名是 EmployeeTest.java,這是因為檔名必須與 public 類的名字相匹配。在一個 原始檔中, 只能有一個公有類,但可以有任意數目的非公有類。
接下來,當編譯這段原始碼的時候, 編譯器將在目錄下建立兩個類檔案:EmployeeTest. class 和 Employee.class。
將程式中包含 main方法的類名提供給位元組碼直譯器, 以便啟動這個程式:
java EmployeeTest
位元組碼直譯器開始執行 EmployeeTest 類的 main方法中的程式碼。在這段程式碼中,先後構 造了三個新 Employee 物件, 並顯示它們的狀態。
4.3.2 多個原始檔的使用
在程式清單 4-2 中, 一個原始檔包含了兩個類。許多程式設計師習慣於將每一個類存在一個 單獨的原始檔中。例如,將 Employee 類存放在檔案 Employee.java 中, 將 EmployeeTest 類存 放在檔案 EmployeeTest.java 中。
如果喜歡這樣組織檔案, 將可以有兩種編譯源程式的方法。一種是使用萬用字元呼叫 Java編譯器:
javac Employee*.java
於是,所有與萬用字元匹配的原始檔都將被編譯成類檔案。或者鍵人下列命令:
javac EmployeeTest.java
讀者可能會感到驚訝,使用第二種方式,並沒有顯式地編譯 Employee.java。 然而,當 Java 編 譯器發現 EmployeeTest.java 使用了 Employee 類時會查詢名為 Employee.class 的檔案。如果沒有找 到這個檔案, 就會自動地搜尋 Employee.java, 然後,對它進行編譯。更重要的是:如果 Employee. java 版本較已有的 Employee.dass 檔案版本新,Java 編譯器就會自動地重新編譯這個檔案。
註釋: 如果熟悉 UNIX 的“ make” 工具(或者是 Windows 中的“ nmake” 等工具,) 可 以認為 Java 編譯器內建了“ make” 功能。
4.3.3 剖析 Employee 類
下面對 Employee 類進行剖析。首先從這個類的方法開始。 通過檢視原始碼會發現,這 個類包含一個構造器和 4 個方法:
public Employee(String n , double s, int year, int month , int day)
public String getName()
public double getSalary()
public Local Date getHi reDay()
public void raiseSalary(double byPercent)
這個類的所有方法都被標記為 public。 關鍵字 public 意味著任何類的任何方法都可以呼叫這 些方法(共有 4 種訪問級別, 將在本章稍後和下一章中介紹)。
接下來,需要注意在 Employee 類的例項中有三個例項域用來存放將要操作的資料:
private String name;
private double salary;
private LocalDate hireDay;
關鍵字 private 確保只有 Employee 類自身的方法能夠訪問這些例項域, 而其他類的方法不能 夠讀寫這些域。
註釋: 可以用 public 標記例項域, 但這是一種極為不提倡的做法 . , public 資料域允許程 序中的任何方法對其進行讀取和修改。, 這就完全破壞了封裝。 任何類的任何方法都可以 修改 public 域, 從我們的經驗來看, 某些程式碼將使用這種存取許可權, 而這並不我們所希望的, 因此, 這裡強烈建議將例項域標記為 private。
最後, 請注意, 有兩個例項域本身就是物件: name 域是 String 類物件, hireDay 域是 LocalDate 類物件。這種情形十分常見:類通常包括型別屬於某個類型別的例項域。
4.3.4 從構造器開始
下面先看看 Employee 類的構造器:
public Employee(String n, double s, int year, int month, int day)
{
name = n;
salary = s;
LocalDate hireDay = Local Date.of(year, month, day);
}
可以看到, 構造器與類同名。在構造 Employee 類的物件時, 構造器會執行,以便將例項域 初始化為所希望的狀態。
例如, 當使用下面這條程式碼建立 Employee 類例項時:
new Eraployee("James Bond", 100000, 1950, 1, 1)
將會把例項域設定為:
name = "James Bond";
salary = 100000;
hireDay = LocalDate.of(1950, 1, 1); // January 1, 1950
構造器與其他的方法有一個重要的不同。構造器總是伴隨著 new 操作符的執行被呼叫, 而不能對一個已經存在的物件呼叫構造器來達到重新設定例項域的目的。例如,
janes.Employee("James Bond", 250000, 1950, 1, 1) // ERROR
將產生編譯錯誤。
本章稍後還會更加詳細地介紹有關構造器的內容。現在只需要記住:
• 構造器與類同名
• 每個類可以有一個以上的構造器
• 構造器可以有 0 個、1 個或多個引數
• 構造器沒有返回值
• 構造器總是伴隨著 new 操作一起呼叫
C++ 註釋:Java 構造器的工作方式與 C++ —樣。但是, 要記住所有的 Java 物件都是在堆中構造的, 構造器總是伴隨著 new 操作符一起使用。C++ 程式設計師最易犯的錯誤就是忘記 new 操作符:
Employee number007("James Bond", 100000, 1950, 1, 1);
// C++, not Java
這條語句在 C++ 中能夠正常執行,但在 Java 中卻不行。
警告: 請注意, 不要在構造器中定義與例項域重名的區域性變數。例如, 下面的構造器將 無法設定 salary。
public Employee(String n, double s, ...)
{
String name = n; // Error
double salary = s; // Error
}
這個構造器聲明瞭區域性變數 name 和 salary。這些變數只能在構造器內部訪問。這些變數遮蔽了同名的例項域。有些程式設計者(例如, 本書的作者)常常不假思索地寫出 這類程式碼, 因為他們已經習慣增加這類資料型別。這種錯誤很難被檢查出來, 因此, 必須注意在所有的方法中不要命名與例項域同名的變數。
4.3.5 隱式引數與顯式引數
方法用於操作物件以及存取它們的例項域。例如,方法:
public void raiseSalary(double byPercent)
{
double raise = salary * byPercent / 100;
salary += raise;
}
將呼叫這個方法的物件的 salary 例項域設定為新值。看看下面這個呼叫:
number007. raiseSalary(5);
它的結果將 number007.salary 域的值增加 5%。具體地說,這個呼叫將執行下列指令:
double raise = nuaber007.salary * 5 / 100;
nuiber007.salary += raise;
raiseSalary 方法有兩個引數。 第一個引數稱為隱式 ( implicit ) 引數, 是出現在方法名前的 Employee 類物件。第二個引數位於方法名後面括號中的數值,這是一個顯式 ( explicit) 參 數 ( 有些人把隱式引數稱為方法呼叫的目標或接收者。)
可以看到,顯式引數是明顯地列在方法宣告中的, 例如 double byPercent。隱式引數沒有出現在方法宣告中。
在每一個方法中, 關鍵字 this 表示隱式引數。 如果需要的話,可以用下列方式編寫 raiseSalary 方法:
public void raiseSalary(double byPercent)
{
double raise = this.salary * byPercent / 100;
this.salary += raise;
}
有些程式設計師更偏愛這樣的風格,因為這樣可以將例項域與區域性變數明顯地區分開來。
C++ 註釋:在 C++ 中, 通常在類的外面定義方法:
void Employee::raiseSalary(double byPercent) // C++, not Java
{
...
}
如果在類的內部定義方法, 這個方法將自動地成為內聯(inline) 方法
class Employee
{
...
int getNameQ { return name; } // inline in C++
}
在 Java 中, 所有的方法都必須在類的內部定義, 但並不表示它們是內聯方法。是否將某個方法設定為內聯方法是 Java 虛擬機器的任務。即時編譯器會監視呼叫那些簡潔、經常被呼叫、 沒有被過載以及可優化的方法。
4.3.6 封裝的優點
最後,再仔細地看一下非常簡單的 getName 方法、 getSalary 方法和 getHireDay 方法。
public String getName()
{
return name;
}
public double getSalaryO
{
return salary;
}
public LocalDate getHireDay()
{
return hireDay;
}
這些都是典型的訪問器方法。由於它們只返回例項域值, 因此又稱為域訪問器。
將 name、 salary 和 hireDay 域標記為 public , 以此來取代獨立的訪問器方法會不會更容 易些呢?
關鍵在於 name 是一個只讀域。一旦在構造器中設定完畢,就沒有任何一個辦法可以對它進行修改,這樣來確保 name 域不會受到外界的破壞。
雖然 salary 不是隻讀域,但是它只能用 raiseSalary 方法修改。特別是一旦這個域值出現了錯誤, 只要除錯這個方法就可以了。如果 salary 域是 public 的,破壞這個域值的搗亂者有可能會出沒在任何地方。
在有些時候, 需要獲得或設定例項域的值。因此,應該提供下面三項內容:
• 一 個私有的資料域;
• 一 個公有的域訪問器方法;
• 一個公有的域更改器方法。
這樣做要比提供一個簡單的公有資料域複雜些, 但是卻有著下列明顯的好處:
首先, 可以改變內部實現,除了該類的方法之外,不會影響其他程式碼。例如,如果將儲存名字的域改為:
String firstName;
String lastName;
那麼 getName 方法可以改為返回
firstName + " " + 1astName
對於這點改變, 程式的其他部分完全不可見。
當然, 為了進行新舊資料表示之間的轉換,訪問器方法和更改器方法有可能需要做許多工作。但是, 這將為我們帶來了第二點好處:更改器方法可以執行錯誤檢查, 然而直接對域進行賦值將不會進行這些處理。例如, setSalary 方法可以檢查薪金是否小於 0。
警告: 注意不要編寫返回引用可變物件的訪問器方法。在 Employee 類中就違反了這個設 計原則, 其中的 getHireDay 方法返回了一個 Date 類物件:
class Employee
{
private Date hireDay ;
...
public Date getHireDayO
{
return hireDay; // Bad
}
...
}
LocalDate 類沒有更改器方法, 與之不同, Date 類有一個更改器方法 setTime, 可以 在這裡設定毫秒數。
Date 物件是可變的, 這一點就破壞了封裝性! 請看下面這段程式碼:
Employee harry = . .
Date d = harry.getHireDayO ;
double tenYearsInMilliSeconds = 10 * 365.25 * 24 * 60 * 60 * 1000;
d.setTime(d.getTime() - (long) tenYearsInMilliSeconds);
// let's give Harry ten years of added seniority
出錯的原因很微妙。d 和 harry.hireDay 引用同一個物件(請參見圖 4-5 )。 更改器方法就可以自動地改變這個僱員物件的私有狀態!
如果需要返回一個可變物件的引用, 應該首先對它進行克隆(clone )。物件 clone 是 指存放在另一個位置上的物件副本。 有關物件 clone 的詳細內容將在第 6 章中討論。 下 面是修改後的程式碼:
class Employee
{
...
public Date getHireDay()
{
return (Date) hireDay.clone(); // Ok
}
...
}
憑經驗可知, 如果需要返回一個可變資料域的拷貝,就應該使用 clone。
4.3.7 基於類的訪問許可權
從前面已經知道,方法可以訪問所呼叫物件的私有資料。一個方法可以訪問所屬類的所有物件的私有資料,這令很多人感到奇怪!例如,下面看一下用來比較兩個僱員的 equals 方法。
class Employee
{
...
public boolean equals(Employee other)
{
return name.equals(other.name);
}
}
典型的呼叫方式是
if (harry,equals(boss)) . . .
這個方法訪問 harry 的私有域, 這點並不會讓人奇怪,然而, 它還訪問了 boss 的私有域。這是合法的, 其原因是 boss 是 Employee 類物件, 而 Employee 類的方法可以訪問 Employee 類 的任何一個物件的私有域。
C++ 註釋:C++ 也有同樣的原則。方法可以訪問所屬類的私有特性( feature ), 而不僅限於訪問隱式引數的私有特性。
4.3.8 私有方法
在實現一個類時,由於公有資料非常危險, 所以應該將所有的資料域都設定為私有的。然 而,方法又應該如何設計呢? 儘管絕大多數方法都被設計為公有的,但在某些特殊情況下,也可能將它們設計為私有的。有時,可能希望將一個計算程式碼劃分成若干個獨立的輔助方法。通 常, 這些輔助方法不應該成為公有介面的一部分,這是由於它們往往與當前的實現機制非常緊 密, 或者需要一個特別的協議以及一個特別的呼叫次序。最好將這樣的方法設計為 private 的。
在 Java 中,為了實現一個私有的方法, 只需將關鍵字 public 改為 private 即可。
對於私有方法, 如果改用其他方法實現相應的操作, 則不必保留原有的方法。如果資料的表達方式發生了變化,這個方法可能會變得難以實現, 或者不再需要。然而,只要方法是私有的,類的設計者就可以確信:它不會被外部的其他類操作呼叫,可以將其刪去。如果方法是公有的, 就不能將其刪去,因為其他的程式碼很可能依賴它。
4.3.9 final例項域
可以將例項域定義為 final。 構建物件時必須初始化這樣的域。也就是說, 必須確保在每 一個構造器執行之後,這個域的值被設定, 並且在後面的操作中, 不能夠再對它進行修改。 例如,可以將 Employee 類中的 name 域宣告為 final, 因為在物件構建之後,這個值不會再被修改, 即沒有 setName 方法。
class Employee
{
private final String name;
…
}
final 修飾符大都應用於基本 (primitive ) 型別域,或不可變(immutable) 類的域(如果類中的每個方法都不會改變其物件, 這種類就是不可變的類。例如,String類就是一個不可變的類)。
對於可變的類, 使用 final 修飾符可能會對讀者造成混亂。例如,
private final StringBuiIcier evaluations;
在 Employee 構造器中會初始化為
evaluations = new StringBuilder();
final 關鍵字只是表示儲存在 evaluations 變數中的物件引用不會再指示其他 StringBuilder 物件。不過這個物件可以更改:
public void giveGoldStar()
{
evaluations.append(LocalDate.now() + ": Gold star!\n");
}
4.4 靜態域與靜態方法
在前面給出的示例程式中,main 方法都被標記為 static 修飾符。下面討論一下這個修飾 符的含義。
4.4.1 靜態域
如果將域定義為 static, 每個類中只有一個這樣的域。而每一個物件對於所有的例項域 卻都有自己的一份拷貝。例如, 假定需要給每一個僱員賦予唯一的標識碼。這裡給 Employee 類新增一個例項域 id 和一個靜態域 nextld:
class Employee
{
private static int nextld = 1;
private int id;
...
}
現在, 每一個僱員物件都有一個自己的 id 域, 但這個類的所有例項將共享一個 nextld 域。換句話說, 如果有 1000 個 Employee 類的物件, 則有 1000 個例項域 id。但是, 只有一 個靜態域 nextld。即使沒有一個僱員物件, 靜態域 nextld 也存在。它屬於類,而不屬於任何獨立的物件。
註釋:在絕大多數的面向物件程式設計語言中, 靜態域被稱為類域。術語“ static” 只是 沿用了 C++ 的叫法, 並無實際意義。
下面實現一個簡單的方法:
public void setld()
{
id = nextld;
nextld++;
}
假定為 harry 設定僱員標識碼:
harry.setId();
harry 的 id 域被設定為靜態域 nextld 當前的值,並且靜態域 nextld 的值加 1:
harry.id = Employee.nextld;
Eip1oyee.nextId++;
4.4.2 靜態常量
靜態變數使用得比較少,但靜態常量卻使用得比較多。例如, 在 Math 類中定義了一個 靜態常量:
public class Math
{
...
public static final double PI = 3.14159265358979323846;
...
}
在程式中,可以採用 Math.PI 的形式獲得這個常量。
如果關鍵字 static 被省略, PI 就變成了 Math 類的一個例項域。需要通過 Math 類的物件 訪問 PI,並且每一個 Math 物件都有它自己的一份 PI 拷貝。
另一個多次使用的靜態常量是 System.out。它在 System 類中宣告:
public class System
{
...
public static final PrintStream out = . . .;
...
}
前面曾經提到過,由於每個類物件都可以對公有域進行修改,所以,最好不要將域設計為 public。然而, 公有常量(即 final 域)卻沒問題。因為 out 被宣告為 final, 所以,不允許再將其他列印流賦給它:
System.out = new PrintStrean(. . .); // Error out is fina
註釋: 如果檢視一下 System 類, 就會發現有一個 setOut 方法, 它可以將 System.out 設 置為不同的流。 讀者可能會感到奇怪, 為什麼這個方法可以修改 final 變數的值。原因在 於, setOut 方法是一個本地方法, 而不是用 Java 語言實現的。本地方法可以繞過 Java 語 言的存取控制機制。這是一種特殊的方法, 在自己編寫程式時, 不應該這樣處理。
4.4.3 靜態方法
靜態方法是一種不能向物件實施操作的方法 。例如 Math 類的 pow 方法就是一個靜態方法。表示式Math.pow(x, a)
計算冪x的a次方。在運算時,不使用任何Math物件。換句話說,沒有隱式的引數。
可以認為靜態方法是沒有 this 引數的方法(在一個非靜態的方法中,this引數標識這個方法的隱式引數,參見4.3.5節)
Employee 類的靜態方法不能訪問Id例項域,因為它不能操作物件。但是,靜態方法可以訪問自身類中的靜態域。下面是使用這種靜態方法的一個示例:
public static int getNextld()
{
return nextld; // returns static field
}
可以通過類名呼叫這個方法:
int n = Employee.getNextldO;
這個方法可以省略關鍵字static嗎? 答案是肯定的。但是,需要通過 Employee 類物件的引用呼叫這個方法。
註釋: 可以使用物件呼叫靜態方法。例如, 如果 harry 是一個 Employee 物件, 可以用 harry.getNextId( ) 代替 Employee.getNextId( ) 。不過,這種方式很容易造成混淆,其原因 是 getNextld 方法計算的結果與 harry 毫無關係。我們建議使用類名, 而不是物件來呼叫 靜態方法。
在下面兩種情況下使用靜態方法 :
• 一 個方法不需要訪問物件狀態,其所需引數都是通過顯式引數提供(例如:Math.pow)。
• 一個方法只需要訪問類的靜態域(例如:Employee.getNextld)。
C++ 註釋:Java 中的靜態域與靜態方法在功能上與 C++ 相同。但是, 語法書寫上卻稍有所不同。在 C++ 中, 使用::操作符訪問自身作用域之外的靜態域和靜態方法, 如 Math::PI 。
術語“ static” 有一段不尋常的歷史。起初,C 引入關鍵字 static 是為了表示退出一 個塊後依然存在的區域性變數在這種情況下, 術語“ static” 是有意義的: 變數一直存在,當再次進入該塊時仍然存在。隨後, static 在 C 中有了第二種含義, 表示不能被其他檔案 訪問的全域性變數和函式。 為了避免引入一個新的關鍵字, 關鍵字 static 被重用了。最後, C++ 第三次重用了這個關鍵字,與前面賦予的含義完全不一樣, 這裡將其解釋為:屬於類且不屬於類物件的變數和函式。這個含義與 Java 相同。
4.4.4 工廠方法
靜態方法還有另外一種常見的用途。類似 LocalDate 和 NumberFormat 的類使用靜態工廠方法 (factory method) 來構造物件。你已經見過工廠方法 LocalDate.now 和 LocalDate.of。 NumberFormat 類如下使用工廠方法生成不同風格的格式化物件:
NumberFormat currencyFormatter = NumberFormat.getCurrencylnstance();
NumberFormat percentFormatter = NumberFormat.getPercentlnstance();
double x = 0.1;
System.out.println(currencyFormatter.format(x)); // prints SO.10
System.out.println(percentFomatter.format(x)); // prints 10%
為什麼 NumberFormat 類不利用構造器完成這些操作呢? 這主要有兩個原因:
• 無法命名構造器。構造器的名字必須與類名相同。但是, 這裡希望將得到的貨幣例項 和百分比例項採用不用的名字。
• 當使用構造器時,無法改變所構造的物件型別。而 Factory 方法將返回一個 DecimalFormat 類物件,這是 NumberFormat 的子類(有關繼承的詳細內容請參看第 5 章)。
4.4.5 main方法
需要注意,不需要使用物件呼叫靜態方法。例如,不需要構造 Math 類物件就可以呼叫 Math.pow。
同理, main 方法也是一個靜態方法。
public class Application
{
public static void main(StringD args)
{
// construct objects here
...
}
}
main 方法不對任何物件進行操作。事實上,在啟動程式時還沒有任何一個物件。靜態的 main 方法將執行並建立程式所需要的物件。
提示: 每一個類可以有一個 main 方法。這是一個常用於對類進行單元測試的技巧。例 如, 可以在 Employee 類中新增一個 main 方法:
class Employee
{
public Employee(String n, double s, int year, int month, int day)
{
name = n;
salary = s;
LocalDate hireDay = Local Date.now(year, month , day) ;
}
...
public static void main(String[] args) // unit test
{
Employee e = new Employee("Romeo", 50000, 2003, 3, 31);
e .raiseSalary(lO);
System.out.println(e .getName() + " " + e.getSalary());
}
...
}
如果想要獨立地測試 Employee 類, 只需要執行: java Employee
如果 Employee 類是一個更大型應用程式的一部分, 就可以使用下面這條語句執行程式: java Application
4.5 方法引數
首先回顧一下在程式設計語言中有關將引數傳遞給方法(或函式)的一些專業術語。按值呼叫 (call by value) 表示方法接收的是呼叫者提供的值。而按引用呼叫 ( call by reference) 表示方法接收的是呼叫者提供的變數地址。一個方法可以修改傳遞引用所對應的變數值,而不能修改傳遞值呼叫所對應的變數值。“ 按… … 呼叫”(call by) 是一個標準的計算機科學術語, 它用來描述各種程式設計語言(不只是 Java ) 中方法引數的傳遞方式(事實上,以前還有按 名呼叫 ( call by name ), Algol 程式設計語言是最古老的高階程式設計語言之一, 它使用的就 是這種引數傳遞方式。不過,對於今天, 這種傳遞方式已經成為歷史)。
Java 程式設計語言總是採用按值呼叫。也就是說, 方法得到的是所有引數值的一個拷貝,特別是,方法不能修改傳遞給它的任何引數變數的內容。
例如, 考慮下面的呼叫:
double percent = 10;
harry.raiseSalary(percent):
不必理睬這個方法的具體實現, 在方法呼叫之後, percent 的值還是 10。
下面再仔細地研究一下這種情況。假定一個方法試圖將一個引數值增加至 3 倍:
public static void tripieValue(double x) // doesn't work
{
x = 3 *x;
}
然後呼叫這個方法:
double percent = 10;
tripieValue(percent);
不過,並沒有做到這一點。呼叫這個方法之後,percent 的值還是 10。下面看一下具體的執行 過程:
1 ) x 被初始化為 percent 值的一個拷貝(也就 是 10 ) 。
2 ) x 被乘以 3後等於 30。 但是 percent 仍然 是 10 (如圖 4-6 所示)。
3 ) 這個方法結束之後,引數變數 x 不再使用。
然而,方法引數共有兩種型別:
• 基本資料型別(數字、布林值)。
• 物件引用。
讀者已經看到,一個方法不可能修改一個基本資料型別的引數。而物件引用作為引數就不同了,可以很容易地利用下面這個方法實現將 一個僱員的薪金提高兩倍的操作:
public static void tripleSalary (Employee x) // works
{
x.raiseSa1ary(200);
}
當呼叫
harry = new Employee(. . .);
tripleSalary(harry);
時,具體的執行過程為:
1 ) x 被初始化為 harry 值的拷貝,這裡是一個物件的引用。
2 ) raiseSalary 方法應用於這個物件引用。x 和 harry 同時引用的那個 Employee 物件的薪金提高了 200%。
3 ) 方法結束後,引數變數 x 不再使用。當然,物件變數 harry 繼續引用那個薪金增至 3 倍的僱員物件(如圖 4-7 所示)。
讀者已經看到,實現一個改變物件引數狀態的方法並不是一件難事。理由很簡單, 方法 得到的是物件引用的拷貝,物件引用及其他的拷貝同時引用同一個物件。
很多程式設計語言(特別是, C++ 和 Pascal) 提供了兩種引數傳遞的方式:值呼叫和引用呼叫。有些程式設計師(甚至本書的作者)認為 Java 程式設計語言對物件採用的是引用呼叫, 實際上, 這種理解是不對的。由於這種誤解具有一定的普遍性,所以下面給出一個反例來詳細地闡述一下這個問題。
首先,編寫一個交換兩個僱員物件的方法:
public static void swap(Employee x , Employee y) // doesn't work
{
Employee temp = x;
x = y;
y = temp;
}
如果 Java 對物件採用的是按引用呼叫,那麼這個方法就應該能夠實現交換資料的效果:
Employee a = new Employee("Alice", . . .);
Employee b = new Employee("Bob", . . .);
swap(a, b);
// does a now refer to Bob, b to Alice?
但是,方法並沒有改變儲存在變數 a 和 b 中的物件引用。swap 方法的引數 x 和 y 被初始 化為兩個物件引用的拷貝,這個方法交換的是這兩個拷貝。
// x refers to Alice, y to Bob
Employee temp = x;
x = y;
y = temp;
// now x refers to Bob, y to Alice
最終,白費力氣。在方法結束時引數變數 x 和 y 被丟棄了。原來的變數 a 和 b 仍然引用 這個方法呼叫之前所引用的物件(如圖 4-8 所示)。
這個過程說明:Java 程式設計語言對物件採用的不是引用呼叫,實際上, 物件引用是按 值傳遞的。
下面總結一下 Java 中方法引數的使用情況:
• 一個方法不能修改一個基本資料型別的引數(即數值型或布林型)。
• 一個方法可以改變一個物件引數的狀態。
• 一個方法不能讓物件引數引用一個新的物件。
程式清單 4-4 中的程式給出了相應的演示。在這個程式中, 首先試圖將一個值引數的值 提高兩倍,但沒有成功:
Testing tripleValue:
Before: percent=10.0
End of method: x:30.0
After: percent=10.0
隨後, 成功地將一個僱員的薪金提高了兩倍:
Testing tripleSalary:
Before: salary=50000.0
End of method: salary=150000.0
After: salary=150000.0
方法結束之後, harry 引用的物件狀態發生了改變。這是因為這個方法可以通過物件引用 的拷貝修改所引用的物件狀態。
最後,程式演示了 swap 方法的失敗效果:
Testing swap:
Before: a=Alice
Before: b=Bob
End of method: x=Bob
End of method: y=Alice
After: a=Alice
After: b=Bob
可以看出, 引數變數 X 和 y 交換了, 但是變數 a 和 b 沒有受到影響。
C++ 註釋:C++ 有值呼叫和引用呼叫。 引用引數標有 & 符號。 例如, 可以輕鬆地實現 void tripleValue(double& x) 方法或 void swap(Employee& x, Employee& y) 方法實現修改 它們的引用引數的目的。
4.6 物件構造
前面已經學習了編寫簡單的構造器,可以定義物件的初始狀態。但是,由於物件構造非常重要,所以 Java 提供了多種編寫構造器的機制。下面將詳細地介紹這些機制。
4.6.1 過載
有些類有多個構造器。例如, 可以如下構造一個空的 StringBuilder 物件:
StringBuilder messages = new StringBuilderO;
或者, 可以指定一個初始字串:
StringBuilder todoList = new StringBuilder('To do:\n");
這種特徵叫做過載( overloading。) 如果多個方法(比如, StringBuilder 構造器方法)有相同的名字、 不同的引數,便產生了過載。編譯器必須挑選出具體執行哪個方法,它通過用 各個方法給出的引數型別與特定方法呼叫所使用的值型別進行匹配來挑選出相應的方法。如果編譯器找不到匹配的引數, 就會產生編譯時錯誤,因為根本不存在匹配, 或者沒有一個比其他的更好。(這個過程被稱為過載解析(overloading resolution)。)
註釋:Java 允許過載任何方法, 而不只是構造器方法。因此,要完整地描述一個方法, 需要指出方法名以及引數型別。這叫做方法的簽名(signature)。例如, String 類有 4 個 稱為 indexOf 的公有方法。它們的簽名是
ndexOf(int)
indexOf(int, int)
indexOf(String)
indexOf(String, int)
返回型別不是方法簽名的一部分。也就是說, 不能有兩個名字相同、 引數型別也相 同卻返回不同型別值的方法。
4.6.2 預設域初始化
如果在構造器中沒有顯式地給域賦予初值,那麼就會被自動地賦為預設值: 數值為 0、 布林值為 false、 物件引用為 null。然而,只有缺少程式設計經驗的人才會這樣做。確實, 如果不明確地對域進行初始化,就會影響程式程式碼的可讀性。
註釋: 這是域與區域性變數的主要不同點。 必須明確地初始化方法中的區域性變數。但是, 如果沒有初始化類中的域, 將會被自動初始化為預設值( 0、false 或 null )。
例如, 仔細看一下 Employee 類。 假定沒有在構造器中對某些域進行初始化, 就會預設 地將 salary 域初始化為 0, 將 name 和 hireDay 域初始化為 null。 但是,這並不是一種良好的程式設計習慣。 如果此時呼叫 getName 方法或 getHireDay 方法, 則會得到一個 null 引用,這應該不是我們所希望的結果:
LocalDate h = harry.getHireDay();
int year = h.getYearQ;// throws exception if h is null
4.6.3 無引數的構造器
很多類都包含一個無引數的建構函式,物件由無引數建構函式建立時, 其狀態會設定為適當的預設值。 例如, 以下是 Employee 類的無引數建構函式:
public Employee0
{
name =
salary = 0;
hireDay = LocalDate,now();
}
如果在編寫一個類時沒有編寫構造器, 那麼系統就會提供一個無引數構造器。這個構造器將所有的例項域設定為預設值。於是, 例項域中的數值型資料設定為 0、 布林型資料設定 為 false、 所有物件變數將設定為 null。 如果類中提供了至少一個構造器, 但是沒有提供無引數的構造器, 則在構造物件時如果 沒有提供引數就會被視為不合法。例如, 在程式清單 4-2 中的 Employee 類提供了一個簡單 的構造器:
Employee(String name, double salary, int y, int ra , int d)
對於這個類,構造預設的僱員屬於不合法。也就是, 呼叫e = new Eraployee() ; 將會產生錯誤。
警告: 請記住,僅當類沒有提供任何構造器的時候, 系統才會提供一個預設的構造器。如果在編寫類的時候, 給出了一個構造器, 哪怕是很簡單的, 要想讓這個類的使用者能夠採用下列方式構造例項:
new ClassName()
就必須提供一個預設的構造器 ( 即不帶引數的構造器)。 當然, 如果希望所有域被賦 預設值, 可以採用下列格式:
public ClassName()
{
)
4.6.4 顯式域初始化
通過過載類的構造器方法,可以採用多種形式設定類的例項域的初始狀態。確保不管怎 樣呼叫構造器,每個例項域都可以被設定為一個有意義的初值,這是一種很好的設計習慣。
可以在類定義中, 直接將一個值賦給任何域。例如:
class Employee
{
private String name ="";
...
}
在執行構造器之前,先執行賦值操作。當一個類的所有構造器都希望把相同的值賦予某個特定的例項域時,這種方式特別有用。
初始值不一定是常量值。在下面的例子中, 可以呼叫方法對域進行初始化。仔細看一下 Employee 類,其中每個僱員有一個 id 域。可以使用下列方式進行初始化:
class Employee
{
private static int nextld;
private int id = assignld();
private static int assignId()
{
int r = nextld;
nextld++;
return r;
}
...
}
C++ 註釋:在 C++ 中, 不能直接初始化類的例項域。所有的域必須在構造器中設定。但 是,有一個特殊的初始化器列表語法,如下所示:
Employee::Employee(String n,double s, int y, int m, int d) // C++
: name(n),
salary(s),
hireDay(y,m, d)
{
}
C++ 使用這種特殊的語法來呼叫域構造器。在 Java 中沒有這種必要, 因為物件沒有子物件, 只有指向其他物件的指標。
4.6.5 引數名
在編寫很小的構造器時(這是十分常見的,) ,常常在引數命名上出現錯誤。
通常, 引數用單個字元命名:
public Employee(String n, double s)
{
name=n;
salary=s;
}
但這樣做有一個缺陷:只有閱讀程式碼才能夠了解引數 n 和引數 s 的含義。
有些程式設計師在每個引數前面加上一個字首“ a”:
public Employee(String aNaie, double aSalary)
{
name = aName ;
salary = aSalary;
}
這樣很清晰。每一個讀者一眼就能夠看懂引數的含義。
還一種常用的技巧,它基於這樣的事實:引數變數用同樣的名字將例項域遮蔽起來。 例 如,如果將引數命名為 salary, salary 將引用這個引數, 而不是例項域。 但是,可以採用 this. salary 的形式訪問例項域。回想一下,this 指示隱式引數, 也就是所構造的物件。下面是一個示例:
public Employee(String name, double salary)
{
this.name = name;
this,salary = salary;
}
C++ 註釋:在 C++ 中, 經常用下劃線或某個固定的字母(一般選用 m 或 x ) 作為例項域 的字首例如,salary 域可能被命名為 salary、mSalary 或 xSalary Java 程式設計師通常不 這樣做,
4.6.6 呼叫另一個構造器
關鍵字 this 引用方法的隱式引數。然而,這個關鍵字還有另外一個含義。
如果構造器的第一個語句形如 this(...), 這個構造器將呼叫同一個類的另一個構造器。下 面是一個典型的例子:
public Employee(double s)
{
// calls Employee(String, double)
this("Employee #" + nextld, s);
nextld++;
}
當呼叫 new Employee(60000) 時, Employee(double) 構造器將呼叫 Employee(String,double) 構造器。
採用這種方式使用 this 關鍵字非常有用, 這樣對公共的構造器程式碼部分只編寫一次 即可。
C++ 註釋: 在 Java 中, this 引用等價於 C++ 的 this 指標。但是, 在 C++ 中, 一個構造 器不能呼叫另一個構造器。 在 C++ 中, 必須將抽取出的公共初始化程式碼編寫成一個獨立 的方法。
4.6.7 初始化塊
前面已經講過兩種初始化資料域的方法:
• 在構造器中設定值
• 在宣告中賦值
實際上,Java 還有第三種機制, 稱為初始化塊(initializationblock)。在一個類的宣告中, 可以包含多個程式碼塊。只要構造類的物件,這些塊就會被執行。例如,
class Employee
{
private static int nextld; private int id;
private String name;
private double salary; // object initialization block
{
id = nextld;
nextld++;
} public Employee(String n, double s)
{
name=n;
salary = s;
}
public Employee()
{
name ="";
salary = 0;
}
...
}
在這個示例中,無論使用哪個構造器構造物件,id 域都在物件初始化塊中被初始化。首先執行初始化塊,然後才執行構造器的主體部分。
這種機制不是必需的,也不常見。通常會直接將初始化程式碼放在構造器中。
註釋: 即使在類的後面定義, 仍然可以在初始化塊中設定域。但是, 為了避免迴圈定義, 不要讀取在後面初始化的域。 具體的規則請參看 Java 語言規範的 8.3.2.3 節 ( http://docs. oracle.com/javase/specs) 。 這個規則的複雜度足以使編譯器的實現者頭疼, 因此建議將初始化塊放在域定義之後。
由於初始化資料域有多種途徑,所以列出構造過程的所有路徑可能相當混亂。下面是呼叫構造器的具體處理步驟:
1 ) 所有資料域被初始化為預設值(0、false 或 null)。
2 ) 按照在類宣告中出現的次序, 依次執行所有域初始化語句和初始化塊。
3 ) 如果構造器第一行呼叫了第二個構造器,則執行第二個構造器主體。
4 ) 執行這個構造器的主體。
當然,應該精心地組織好初始化程式碼,這樣有利於其他程式設計師的理解。 例如, 如果讓類 的構造器行為依賴於資料域宣告的順序, 那就會顯得很奇怪並且容易引起錯誤。
可以通過提供一個初始化值, 或者使用一個靜態的初始化塊來對靜態域進行初始化。前 面已經介紹過第一種機制:
private static int nextld = 1;
如果對類的靜態域進行初始化的程式碼比較複雜,那麼可以使用靜態的初始化塊。 將程式碼放在一個塊中,並標記關鍵字 static。下面是一個示例。其功能是將僱員 ID 的起 始值賦予一個小於 10 000 的隨機整數。
// static initialization block
static
{
Random generator = new Random();
nextld = generator.nextlnt(10000);
}
在類第一次載入的時候, 將會進行靜態域的初始化。與例項域一樣,除非將它們顯式地 設定成其他值, 否則預設的初始值是 0、 false 或 null。 所有的靜態初始化語句以及靜態初始化塊都將依照類定義的順序執行。
註釋:讓人驚訝的是, 在 JDK 6 之前, 都可以用 Java 編寫一個沒有 main 方法的“ Hello, World” 程式。
public class Hello
{
static
{
System.out.println("Hel1o, World");
}
}
當用 java Hello 呼叫這個類時, 就會載入這個類, 靜態初始化塊將會列印“ Hello, World" 。在此之後,會顯示一個訊息指出 main 未定義。 從 Java SE 7 以後,java 程式首先會檢查是否有一個 main 方法。
本節討論的很多特性:
•過載構造器
•用 this(...) 呼叫另一個構造器
•無引數構造器
•物件初始化塊
•靜態初始化塊
•例項域初始化
API java.util.Random 1.0
•Random( )
構造一個新的隨機數生成器。
• int nextlnt(int n)1.2
返回一個 0 ~ n-1之間的隨機數。
4.6.8 物件析構與 finalize 方法
有些面向物件的程式設計語言,特別是 C++, 有顯式的析構器方法,其中放置一些當物件不再使用時需要執行的清理程式碼。在析構器中, 最常見的操作是回收分配給物件的儲存空間。由於 Java 有自動的垃圾回收器,不需要人工回收記憶體, 所以 Java 不支援析構器。
當然,某些物件使用了記憶體之外的其他資源, 例如,檔案或使用了系統資源的另一個物件的控制代碼。在這種情況下,當資源不再需要時, 將其回收和再利用將顯得十分重要。
可以為任何一個類新增 finalize 方法。finalize 方法將在垃圾回收器清除物件之前呼叫。 在實際應用中,不要依賴於使用 finalize 方法回收任何短缺的資源, 這是因為很難知道這個方法什麼時候才能夠呼叫。
註釋: 有個名為 System.runFinalizersOnExit(true) 的方法能夠確保 finalizer 方法在 Java 關閉前被呼叫。不過,這個方法並不安全,也不鼓勵大家使用。有一種代替的方法是使用方法 Runtime.addShutdownHook 新增“ 關閉釣” (shutdown hook), 詳細內容請參看 API 文件。
如果某個資源需要在使用完畢後立刻被關閉, 那麼就需要由人工來管理。物件用完時, 可以應用一個 close 方法來完成相應的清理操作。7.2.5 節會介紹如何確保這個方法自動得到 呼叫。
4.7 包
Java 允許使用包( package)將類組織起來。藉助於包可以方便地組織自己的程式碼,並將 自己的程式碼與別人提供的程式碼庫分開管理。
標準的 Java 類庫分佈在多個包中,包括 java.lang、java.util 和java.net 等。標準的 Java 包具有一個層次結構。如同硬碟的目錄巢狀一樣,也可以使用巢狀層次組織包。所有標準的 Java 包都處於java 和 javax 包層次中。
使用包的主要原因是確保類名的唯一性。假如兩個程式設計師不約而同地建立了 Employee 類。只要將這些類放置在不同的包中, 就不會產生衝突。事實上,為了保證包名的絕對 唯一性, Sun 公司建議將公司的因特網域名(這顯然是獨一無二的) 以逆序的形式作為包名,並且對於不同的專案使用不同的子包。例如, horstmann.com 是本書作者之一註冊的域 名。逆序形式為 com.horstmann。這個包還可以被進一步地劃分成子包, 如 com.horstmann. corejava。
從編譯器的角度來看, 巢狀的包之間沒有任何關係。例如,java.util 包與java.util.jar 包 毫無關係。每一個都擁有獨立的類集合。
4.7.1 類的匯入
一個類可以使用所屬包中的所有類, 以及其他包中的公有類( public class) 。我們可以 採用兩種方式訪問另一個包中的公有類。第一種方式是在每個類名之前新增完整的包名。 例如:
java.tiie.LocalDate today = java.tine.Local Date.now();
這顯然很繁瑣。更簡單且更常用的方式是使用 import 語句。import 語句是一種引用包含 在包中的類的簡明描述。一旦使用了 import 語句,在使用類時,就不必寫出包的全名了。
可以使用 import 語句匯入一個特定的類或者整個包。import 語句應該位於原始檔的頂部 (但位於 package 語句的後面)。例如, 可以使用下面這條語句導人 java.util 包中所有的類。
import java.util .*;
然後, 就可以使用
LocalDate today = Local Date.now();
而無須在前面加上包字首。還可以導人一個包中的特定類:
import java.time.LocalDate;
java.time.* 的語法比較簡單,對程式碼的大小也沒有任何負面影響。當然, 如果能夠明確 地指出所匯入的類, 將會使程式碼的讀者更加準確地知道載入了哪些類。
提示:在Eclipse中,可以使用選單選項Source->Organize Imports。Package語句,如import java.util.*,將會自動地擴充套件指定的匯入列表,如:
import java.util .ArrayList;
import java.util .Date;
這是一個十分便捷的特性。
但是, 需要注意的是, 只能使用星號(*) 匯入一個包, 而不能使用 import java.* 或 import java.*.* 匯入以 java 為字首的所有包。
在大多數情況下, 只匯入所需的包, 並不必過多地理睬它們。 但在發生命名衝突的時 候,就不能不注意包的名字了。例如,java.util 和 java.sql 包都有日期( Date) 類。如果在程式中匯入了這兩個包:
import java.util .*;
import java.sql .*;
在程式使用 Date 類的時候, 就會出現一個編譯錯誤:
Date today; // Error java.util.Date or java.sql .Date?
此時編譯器無法確定程式使用的是哪一個 Date 類。可以採用增加一個特定的 import 語句來 解決這個問題:
import java.util .*;
import java.sql .*;
import java.util .Date;
如果這兩個 Date 類都需要使用,又該怎麼辦呢? 答案是,在每個類名的前面加上完整的包名。
java.util .Date deadline = new java.util .Date();
java.sql .Date today = new java.sql .Date(...);
在包中定位類是編譯器 ( compiler) 的工作。 類檔案中的位元組碼肯定使用完整的包名來引用其他類。
4.7.2 靜態匯入
import 語句不僅可以匯入類,還增加了匯入靜態方法和靜態域的功能。
例如,如果在原始檔的頂部, 新增一條指令:
import static java.lang.System.*;
就可以使用 System 類的靜態方法和靜態域,而不必加類名字首:
out.println("Goodbye, World!"); // i.e., System.out
exit(0); //i.e., System.exit
另外,還可以匯入特定的方法或域
import static java.lang.System.out;
實際上,是否有更多的程式設計師採用 System.out 或 System.exit 的簡寫形式,似乎是一件值 得懷疑的事情。這種編寫形式不利於程式碼的清晰度。不過,
sqrt(pow(x, 2) + pow(y, 2))
看起來比
Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2))
清晰得多。
4.7.3 將類放入包中
要想將一個類放入包中, 就必須將包的名字放在原始檔的開頭,包中定義類的程式碼之前。例如,程式清單 4-7中的檔案 Employee.java 開頭是這樣的:
package com.horstmann.corejava;
public class Employee
{
...
}
如果沒有在原始檔中放置 package 語句, 這個原始檔中的類就被放置在一個預設包 ( defaulf package ) 中。預設包是一個沒有名字的包。在此之前,我們定義的所有類都在預設包中。
將包中的檔案放到與完整的包名匹配的子目錄中。例如,com.horstmann.corejava 包 中的所有原始檔應該被放置在子目錄 com/horstmann/corejava ( Windows 中 com\horstmann\ corejava) 中。編譯器將類檔案也放在相同的目錄結構中。
程式清單 4-6 和程式清單 4-7中的程式分放在兩個包中: PackageTest 類放置在預設包中; Employee 類放置在 com.horstmann.corejava 包中。因此, Employee.java 檔案必須包含在子目 錄 com/horstmann/ corejava 中。換句話說, 目錄結構如下所示:
要想編譯這個程式, 只需改變基目錄,並執行命令
javac PackageTest.java
編譯器就會自動地查詢檔案 com/horstmann/corejava/Employee.java 並進行編譯。
下面看一個更加實際的例子。在這裡不使用預設包, 而是將類分別放在不同的包中 ( com. horstmann.corejava 和 com.mycompany) 。
在這種情況下,仍然要從基目錄編譯和執行類,即包含 com 目錄:
javac com/myconipany/Payrol1App.java
java com.mycompany.PayrollApp
需要注意,編譯器對檔案 (帶有檔案分隔符和副檔名 .java 的檔案)進行操作。而 Java 直譯器載入類(帶有 . 分隔符 )。
提示:從下一章開始, 我們將對原始碼使用包。這樣一來,就可以為各章建立一個IDE 工程, 而不是各小節分別建立工程„
警告: 編譯器在編譯原始檔的時候不檢查目錄結構。例如,假定有一個原始檔開頭有下列語句:
package com.mycompany;
即使這個原始檔沒有在子目錄 com/mycompany 下, 也可以進行編譯。如果它不依賴於其他包, 就不會出現編譯錯誤。但是, 最終的程式將無法執行, 除非先將所有類檔案移到正確的位置上。 如果包與目錄不匹配, 虛擬機器就找不到類。
4.7.4 包作用域
前面已經接觸過訪問修飾符 public 和 private。標記為 public 的部分可以被任意的類使 用;標記為 private 的部分只能被定義它們的類使用。如果沒有指定 public 或 private , 這 個 部 分(類、方法或變數)可以被同一個包中的所有方法訪問。
下面再仔細地看一下程式清單 4-2。在這個程式中, 沒有將 Employee 類定義為公有類, 因此只有在同一個包(在此是預設包)中的其他類可以訪問,例如 EmployeeTest。對於類來 說,這種預設是合乎情理的。但是, 對於變數來說就有些不適宜了, 因此變數必須顯式地標 記為 private, 不然的話將預設為包可見。顯然, 這樣做會破壞封裝性。問題主要出於人們經 常忘記鍵人關鍵字 private。 在 java.awt 包中的 Window 類就是一個典型的示例。java.awt 包 是 JDK 提供的部分原始碼:
public class Window extends Container
{
String warningString;
...
}
請注意,這裡的 wamingString 變數不是 private ! 這 意 味 著 java.awt 包中的所有類的方 法都可以訪問該變數, 並將它設定為任意值(例如,“ Trust me!”)。實際上,只有 Window 類的方法訪問它,因此應該將它設定為私有變數。我們猜測可能是程式設計師匆忙之中忘記鍵入 private 修飾符了(為防止程式設計師內疚, 我們沒有說出他的名字, 感興趣的話,可以檢視一下 原始碼)。
註釋: 奇怪的是, 這個問題至今還沒有得到糾正,即使我們在這本書的 9 個版本中已經指出了這一點。很明顯,類庫的實現者並沒有讀這本書。不僅如此,這個類還增加了一 些新域, 其中大約一半也不是私有的。
這真的會成為一個問題嗎? 答案是:視具體情況而定。在預設情況下,包不是一個封閉 的實體。也就是說, 任何人都可以向包中新增更多的類。當然,有敵意或低水平的程式設計師很可能利用包的可見性新增一些具有修改變數功能的程式碼。例如,在 Java 程式設計語言的早期 版本中, 只需要將下列這條語句放在類檔案的開頭,就可以很容易地將其他類混入 java.awt 包中:
package java.awt;
然後 把結果類檔案放置在類路徑某處的 java/awt 子目錄下,就可以訪問 java.awt 包的內部了。使用這一 手段, 可以對警告框進行設定(如圖 4-9 所示)。 從 1.2 版開始, JDK 的實現者修改了類載入器, 明確地禁止載入使用者自定義的、 包名以“ java.” 開始的 類!當然,使用者自定義的類無法從這種保護中受益。然 而,可以通過包密封 ( package sealing) 機制來解決將 各種包混雜在一起的問題。如果將一個包密封起來, 就 不能再向這個包新增類了。在第 9 章中,將介紹製作包含密封包的 JAR 檔案的方法。
4.8 類路徑
在前面已經看到,類儲存在檔案系統的子目錄中。類的路徑必須與包名匹配。
另外, 類檔案也可以儲存在 JAR(Java 歸檔)檔案中。在一個 JAR 檔案中, 可以包含 多個壓縮形式的類檔案和子目錄, 這樣既可以節省又可以改善效能。在程式中用到第三方 ( third-party ) 的庫檔案時,通常會給出一個或多個需要包含的 JAR 檔案。JDK 也提供了許多 的 JAR 檔案, 例如,在 jre/lib/rt.jar 中包含數千個類庫檔案。有關建立 JAR 檔案的詳細內容 將在第 9 章中討論。
提示:JAR 檔案使用 ZIP 格式組織檔案和子目錄。可以使用所有 ZIP 實用程式檢視內部 的 rt.jar 以及其他的 JAR 檔案。
為了使類能夠被多個程式共享,需要做到下面幾點:
1 ) 把類放到一個目錄中, 例如 /home/user/classdir。需要注意, 這個目錄是包樹狀結構 的基目錄。如果希望將 com.horstmann.corejava.Employee 類新增到其中,這個 Employee.class 類檔案就必須位於子目錄 /home/user/classdir/com/horstmann/corejava 中。
2 ) 將 JAR 檔案放在一個目錄中,例如:/home/user/archives。
3 ) 設定類路徑(classpath)。類路徑是所有包含類檔案的路徑的集合。
在 UNIX 環境中, 類路徑中的不同專案之間採用冒號 (:) 分隔:
/home/user/classdir:.:/home/use r/archives/archive.jar
而在 Windows 環境中,則以分號(;)分隔:
c:\classdir;.;c:\archi»es\archive.jar
在上述兩種情況中, 句點(.)表示當前目錄。
類路徑包括:
•基目錄 /home/user/classdiir或 c:\classes;
•當前目錄 (.)
•JAR 檔案 /home/user/archives/archive.jar或 c:\archives\archive.jar。
從 Java SE 6 開始,可以在 JAR 檔案目錄中指定萬用字元,如下:
/home/user/dassdir:.:/home/user/archives/'*'
或者
c:\classdir;.;c:\archives\*
但在 UNIX 中,禁止使用 * 以防止 shell 命令進一步擴充套件。
在 archives 目錄中的所有 JAR 檔案(但不包括 .class 檔案)都包含在類路徑中。
由於執行時庫檔案( rt.jar 和在 jre/lib 與 jre/lib/ext 目錄下的一些其他的 JAR 檔案) 會被 自動地搜尋, 所以不必將它們顯式地列在類路徑中。
警告:javac 編譯器總是在當前的目錄中查詢檔案, 但 Java 虛擬機器僅在類路徑中有目錄的時候才檢視當前目錄。如果沒有設定類路徑, 那也並不會產生什麼問題, 預設的 類 路 徑 包 含 目 錄 . 然 而 如 果 設 置 了 類 路 徑 卻 忘 記 了 包 含 目 錄, 則 程 序 仍 然 可 以通過編譯, 但不能執行。
類路徑所列出的目錄和歸檔檔案是搜尋類的起始點。下面看一個類路徑示例:
/home/user/classdir:.:/home/user/archives/archive.jar
假定虛擬機器要搜尋 com.horstmann.corejava.Employee 類檔案。它首先要檢視儲存在 jre/ lib 和jre/lib/ext 目錄下的歸檔檔案中所存放的系統類檔案。顯然,在那裡找不到相應的類檔案,然後再檢視類路徑。然後查詢以下檔案:
•/home/user/classdir/com/horstmann/corejava/Employee.class
•com/horstmann/corejava/Employee.class 從當前目錄開始
•com/horstmann/corejava/Employee.class inside /home/user/archives/archive.jar
編譯器定位檔案要比虛擬機器複雜得多。如果引用了一個類,而沒有指出這個類所在的包, 那麼編譯器將首先查詢包含這個類的包,並詢查所有的 import 指令,確定其中是否包含了被引用的類。例如, 假定原始檔包含指令:
import java.util.*;
import com.horstmann.corejava.*;
並且原始碼引用了 Employee 類。 編譯器將試圖查詢 java.lang.Employee (因為java.lang 包被 預設匯入)、java.util.Employee、 com.horstmann.corejava.Employee 和當前包中的 Employee。對這個類路徑的所有位置中所列出的每一個類進行逐一檢視。如果找到了一個以上的類, 就 會產生編譯錯誤(因為類必須是唯一的,而 import 語句的次序卻無關緊要)。
編譯器的任務不止這些,它還要檢視原始檔( Source files) 是否比類檔案新。如果是這 樣的話,那麼原始檔就會被自動地重新編譯。在前面已經知道,僅可以匯入其他包中的公有 類。一個原始檔只能包含一個公有類,並且檔名必須與公有類匹配。因此, 編譯器很容易定位公有類所在的原始檔。當然, 也可以從當前包中匯入非公有類。這些類有可能定義在與 類名不同的原始檔中。如果從當前包中匯入一個類, 編譯器就要搜尋當前包中的所有原始檔, 以便確定哪個原始檔定義了這個類。
4.8.1 設定類路徑
最好採用 -classpath (或 -cp) 選項指定類路徑:
java -classpath /home/user/dassdir: .:/home/user/archives/archive.jar MyProg
或者
java -classpath c:\classdir; .;c:\archives\archive.jar MyProg
整個指令應該書寫在一行中。將這樣一個長的命令列放在一個 shell 指令碼或一個批處理檔案中是一個不錯的主意。
利用 -dasspath 選項設定類路徑是首選的方法, 也可以通過設定 CLASSPATH 環境變數完成這個操作。其詳細情況依賴於所使用的 shell。在 Bourne Again shell ( bash) 中, 命令格 式如下:
export CLASSPATH=/home/user/classdir:.:/ home/user/archives/archive.jar
在 Windows shell, 命令格式如下:
set CLASSPATH=c:\classdir;.;c:\archives\archive.jar
直到退出 shell 為止,類路徑設定均有效。
警告: 有人建議將 CLASSPATH 環境變數設定為永久不變的值。 總的來說這是一個很糟糕的主意。人們有可能會忘記全域性設定, 因此, 當使用的類沒有正確地載入進來時,會 感到很奇怪。一個應該受到譴責的示例是 Windows 中 Apple 的 QuickTime 安裝程式。它 進行了全域性設定, CLASSPATH 指向一個所需要的 JAR 檔案, 但並沒有在類路徑上包含當前路徑。 因此, 當程式編譯後卻不能執行時, 眾多的 Java 程式設計師花費了很多精力去解決這個問題。
警告: 有人建議繞開類路徑, 將所有的檔案放在 jre/lib/ext 路徑。這是一個極壞的主意, 其原因主要有兩個: 當手工地載入其他的類檔案時, 如果將它們存放在擴充套件路徑上, 則 不能正常地工作(有關類載入器的詳細資訊, 請參看卷 2 第 9 章)。此外, 程式設計師經常會 忘記 3 個月前所存放檔案的位置。 當類載入器忽略了曾經仔細設計的類路徑時, 程式設計師 會毫無頭緒地在標頭檔案中查詢。事實上,載入的是擴充套件路徑上已長時間遺忘的類。
4.9 文件註釋
JDK 包含一個很有用的工具,叫做javadoc, 它可以由原始檔生成一個 HTML 文件。事 實上, 在第 3 章講述的聯機 API 文件就是通過對標準 Java 類庫的原始碼執行 javadoc 生 成的。
如果在原始碼中新增以專用的定界符 /**開始的註釋, 那麼可以很容易地生成一個看上 去具有專業水準的文件。這是一種很好的方式,因為這種方式可以將程式碼與註釋儲存在一個 地方。如果將文件存入一個獨立的檔案中, 就有可能會隨著時間的推移, 出現程式碼和註釋不 一致的問題。然而,由於文件註釋與原始碼在同一個檔案中,在修改原始碼的同時, 重新運 行 javadoc 就可以輕而易舉地保持兩者的一致性。
4.9.1 註釋的插入
javadoc 實用程式(utility) 從下面幾個特性中抽取資訊:
•包
•公有類與介面
•公有的和受保護的構造器及方法
•公有的和受保護的域
在第 5 章中將介紹受保護特性, 在第 6 章將介紹介面。
應該為上面幾部分編寫註釋、 註釋應該放置在所描述特性的前面。註釋以 /**開始,並 以 */ 結束。 每個 /** . . . */ 文件註釋在標記之後緊跟著自由格式文字( free-form text )。標記由 @開始, 如@author 或@param。
自由格式文字的第一句應該是一個概要性的句子。javadoc 實用程式自動地將這些句子抽取出來形成概要頁。
在自由格式文字中,可以使用 HTML 修飾符, 例如,用於強調的<em>...</em>、用於著重強調的<strong>...</strong>以及包含影象的<img...>等。不過,一定不要使用<h1>或<hr>,因為它們會與文件的格式產生衝突。若要鍵入等寬程式碼、需使用{@code...}而不是<code>...</code>---這樣一來,就不用操心對程式碼中的<字元轉義了。
註釋: 如果文件中有到其他檔案的連結, 例如, 影象檔案(使用者介面的元件的圖表或影象等), 就應該將這些檔案放到子目錄 doc-files 中。javadoc 實用程式將從源目錄拷貝這 些目錄及其中的檔案到文件目錄中。在連結中需要使用 doc-files 目錄, 例如:<img src="doc-files/uml.png" alt="UML diagram"> 。
4.9.2 類註釋
類註釋必須放在 import 語句之後,類定義之前。
下面是一個類註釋的例子:
/**
* A {code Card} object represents a playing card , such
* as "Queen of Hearts". A card has a suit (Diamond , Heart ,
* Spade or Club) and a value (1 = Ace , 2 . . . 10, 11 = Jack,
* 12 = Queen , 13 = King)
*/
public class Card
{
...
}
註釋:沒有必要在每一行的開始用星號 *, 例如, 以下注釋同樣是合法的:
/**
A <code>Card< /code> object represents a playing card , such
as "Queen of Hearts". A card has a suit (Diamond, Heart ,
Spade or Club) and a value (1 = Ace , 2 . . . 10, 11 = jack,
12 = Queen, 13 = King) .
*/
然而, 大部分 IDE 提供了自動新增星號 *, 並且當註釋行改變時, 自動重新排列這 些星號的功能。
4.9.3 方法註釋
每一個方法註釋必須放在所描述的方法之前。除了通用標記之外, 還可以使用下面的標記:
• @param 變數描述
這個標記將對當前方法的“ param” (引數)部分新增一個條目。這個描述可以佔據多行, 並可以使用 HTML 標記。一個方法的所有 @param 標記必須放在一起。
• @return 描述
這個標記將對當前方法新增“ return” (返回)部分。這個描述可以跨越多行, 並可以 使用 HTML 標記。
• throws 類描述
這個標記將新增一個註釋, 用於表示這個方法有可能丟擲異常。 有關異常的詳細內容 將在第 10 章中討論。
下面是一個方法註釋的示例:
/**
* Raises the salary of an employee.
* @param byPercent the percentage by which to raise the salary (e.g. 10 means 10%)
* @return the amount of the raise
*/
public double raiseSal ary(double byPercent)
{
double raise = salary * byPercent / 100;
salary += raise;
return raise;
}
4.9.3 域註釋
只需要對公有域(通常指的是靜態常量)建立文件。 例如,
/**
* The "Hearts" card suit
*/
public static final int HEARTS = 1;
4.9.5 通用註釋
下面的標記可以用在類文件的註釋中。
•@author 姓名
這個標記將產生一個 ** author" (作者)條目。可以使用多個 @author 標記,每個 @ author 標記對應一個作者。
•@version 文字
這個標記將產生一個“ version”(版本)條目。這裡的文字可以是對當前版本的任何描 述。
下面的標記可以用於所有的文件註釋中。
•@since 文字
這個標記將產生一個“ since” (始於)條目。這裡的 text 可以是對引入特性的版本描述。例如, @since version 1.7.10
•@deprecated 文字
這個標記將對類、 方法或變數新增一個不再使用的註釋。文字中給出了取代的建議。 例如,
@deprecated Use setVIsible(true)
instead
通過 @see 和@link標記,可以使用超級連結, 連結到 javadoc 文件的相關部分或外 部文件。
•@see 引用
這個標記將在“ see also” 部分增加一個超級連結。它可以用於類中,也可以用於方法中。這裡的引用可以選擇下列情形之一:
package.class#feature label
<a href="...">label</a>
"text"
第一種情況是最常見的。只要提供類、 方法或變數的名字,javadoc 就在文件中插入 一個超連結。例如,
@see com.horstmann.corejava.Employee#raiseSalary(double)
建立一個連結到 com.horstmann.corejava.Employee 類的 raiseSalary(double) 方法的超 連結。 可以省略包名, 甚至把包名和類名都省去,此時,連結將定位於當前包或當前類。
需要注意,一定要使用井號(#,) 而不要使用句號(.)分隔類名與方法名,或類 名與變數名。Java 編譯器本身可以熟練地斷定句點在分隔包、 子包、 類、內部類與方 法和變數時的不同含義。但是 javadoc 實用程式就沒有這麼聰明瞭,因此必須對它提 供幫助。
如果 @see 標記後面有一個 < 字元,就需要指定一個超連結。可以超連結到任何 URL。例如:
@see <a href="www.horstmann.com/corejava. html">The Core ]ava home page</a>
在上述各種情況下, 都可以指定一個可選的標籤( label ) 作為連結錨(link anchor)。 如果省略了 label , 使用者看到的錨的名稱就是目的碼名或 URL。
如果@see 標記後面有一個雙引號(")字元,文字就會顯示在 “ see also” 部分。 例如,
@see "Core Java 2 volume 2"
可以為一個特性新增多個 @see 標記,但必須將它們放在一起。
•如果願意的話, 還可以在註釋中的任何位置放置指向其他類或方法的超級連結, 以及 插入一個專用的標記, 例如,
{@link package,class#feature label ]
這裡的特性描述規則與@see 標記規則一樣。
4.9.6 包與概述註釋
可以直接將類、 方法和變數的註釋放置在 Java 原始檔中, 只要用 /** . . . */ 文件註釋界 定就可以了。但是, 要想產生包註釋,就需要在每一個包目錄中新增一個單獨的檔案。可以 有如下兩個選擇:
1 ) 提供一個以 package.html 命名的 HTML 檔案。在標記<body>...</body>之間的所有文字都會被抽取出來。
2 ) 提供一個以 package-info.java 命名的 Java 檔案。這個檔案必須包含一個初始的以 /** 和 */ 界定的 Javadoc 註釋, 跟隨在一個包語句之後。它不應該包含更多的程式碼或註釋。
還可以為所有的原始檔提供一個概述性的註釋。這個註釋將被放置在一個名為 overview. html 的檔案中,這個檔案位於包含所有原始檔的父目錄中。標記<body>...</body>之間的所有文字將被抽取出來。當用戶從導航欄中選擇“ Overview” 時,就會顯示出這些註釋內容。
4.9.7 註釋的抽取
這裡,假設 HTML 檔案將被存放在目錄 docDirectory 下。執行以下步驟:
1 ) 切換到包含想要生成文件的原始檔目錄。 如果有巢狀的包要生成文件, 例如 com. horstmann.corejava, 就必須切換到包含子目錄 com 的目錄(如果存在 overview.html 檔案的 話, 這也是它的所在目錄)。
2 ) 如果是一個包,應該執行命令:
javadoc -d docDirectory nameOfPackage
或對於多個包生成文件,執行:
javadoc -d docDirectory nameOfPackage\ nameOfPackage . . .
如果檔案在預設包中, 就應該執行:
javadoc -d docDirectory *.java
如果省略了 -d docDirectory 選項, 那 HTML 檔案就會被提取到當前目錄下。這樣有可能 會帶來混亂,因此不提倡這種做法。
可以使用多種形式的命令列選項對 javadoc 程式進行調整。例如, 可以使用 -author 和 -version 選項在文件中包含@author 和@version 標記(預設情況下,這些標記會被省 略)。另一個很有用的選項是 -link, 用來為標準類新增超連結。例如, 如果使用命令
javadoc -link http://docs.oracle.eom/:javase/8/docs/api *.java
那麼,所有的標準類庫類都會自動地連結到 Oracle 網站的文件。
如果使用 -linksource 選項,則每個原始檔被轉換為 HTML (不對程式碼著色,但包含行編 號,) 。並且每個類和方法名將轉變為指向原始碼的超連結。 有關其他的選項, 請查閱 javadoc 實用程式的聯機文件,http://docs.orade.com/javase /8/ docs/guides/javadoc。
註釋: 如果需要進一步的定製,例如, 生成非 HTML 格式的文件, 可以提供自定義的 doclet, 以便生成想要的任何輸出形式。顯然, 這是一種特殊的需求, 有關細節內容請查閱 http://docs.oracle.com/javase/8/docs/guides/javadoc/doclet/overview.html 的聯機文件。
4.10 類設計技巧
我們不會面面俱到, 也不希望過於沉悶, 所以這一章結束之前, 簡單地介紹幾點技巧。 應用這些技巧可以使得設計出來的類更具有 OOP 的專業水準。
1. 一定要保證資料私有
這是最重要的;絕對不要破壞封裝性。有時候, 需要編寫一個訪問器方法或更改器方法, 但是最好還是保持例項域的私有性。很多慘痛的經驗告訴我們, 資料的表示形式很可能會改 變, 但它們的使用方式卻不會經常發生變化。當資料保持私有時, 它們的表示形式的變化不 會對類的使用者產生影響, 即使出現 bug 也易於檢測。
2. 一定要對資料初始化
Java 不對區域性變數進行初始化, 但是會對物件的例項域進行初始化。最好不要依賴於系 統的預設值, 而是應該顯式地初始化所有的資料, 具體的初始化方式可以是提供預設值, 也 可以是在所有構造器中設定預設值。
3. 不要在類中使用過多的基本型別
就是說,用其他的類代替多個相關的基本型別的使用。這樣會使類更加易於理解且易於 修改。例如, 用一個稱為 Address 的新的類替換一個 Customer 類中以下的例項域:
private String street;
private String city;
private String state;
private int zip;
這樣, 可以很容易處理地址的變化, 例如, 需要增加對國際地址的處理。
4. 不是所有的域都需要獨立的域訪問器和域更改器
或許, 需要獲得或設定僱員的薪金。 而一旦構造了僱員物件, 就應該禁止更改僱用日期,並且在物件中,常常包含一些不希望別人獲得或設定的例項域, 例如, 在 Address 類中, 存放州縮寫的陣列。
5.將職責過多的類進行分解
這樣說似乎有點含糊不清, 究竟多少算是“ 過多” ? 每個人的看法不同。但是,如果明顯地可以將一個複雜的類分解成兩個更為簡單的類,就應該將其分解(但另一方面,也不要 走極端。設計 10 個類,每個類只有一個方法,顯然有些矯枉過正了)。
6. 類名和方法名要能夠體現它們的職責
與變數應該有一個能夠反映其含義的名字一樣, 類也應該如此(在標準類庫中, 也存在 著一些含義不明確的例子,如:Date 類實際上是一個用於描述時間的類)。 命名類名的良好習慣是採用一個名詞(Order )、 前面有形容詞修飾的名詞( RushOrder ) 或動名詞(有“ -ing” 字尾)修飾名詞(例如, BillingAddress)。對於方法來說,習慣是訪問器方法用小寫 get 開頭 ( getSalary ), 更改器方法用小寫的 set 開頭(setSalary )
7.優先使用不可變的類
LocalDate 類以及 java.time 包中的其他類是不可變的---沒有方法能修改物件的狀態。 類似 plusDays 的方法並不是更改物件,而是返回狀態已修改的新物件。
更改物件的問題在於, 如果多個執行緒試圖同時更新一個物件,就會發生併發更改。其結 果是不可預料的。如果類是不可變的,就可以安全地在多個執行緒間共享其物件。 因此, 要儘可能讓類是不可變的, 這是一個很好的想法。對於表示值的類, 如一個字元 串或一個時間點,這尤其容易。計算會生成新值, 而不是更新原來的值。
當然,並不是所有類都應當是不可變的。如果員工加薪時讓 raiseSalary 方法返回一個新 的 Employee 物件, 這會很奇怪。
本章介紹了 Java 這種面嚮物件語言的有關物件和類的基礎知識。 為了真正做到面向對 象,程式設計語言還必須支援繼承和多型。Java 提供了對這些特性的支援, 具體內容將在下 一章中介紹。