Java學(xué)習(xí)記錄:對象與類(一)
面向?qū)ο蟪绦蛟O(shè)計(Object-Oriented Programming, OOP)是當(dāng)今的主流程序設(shè)計范型,它取代了20世紀(jì)70年代的“結(jié)構(gòu)化”或過程式編程技術(shù)。由于Java是面向?qū)ο蟮?,所以你必須熟悉OOP才能夠很好地使用Java。
面向?qū)ο蟮某绦蚴怯蓪ο蠼M成的,每個對象包含用戶公開的特定功能和隱藏的實現(xiàn)。程序中的很多對象是來自標(biāo)準(zhǔn)類庫的“成品”,還有一些是自定義的。究竟是自己構(gòu)造對象,還是從外界購買,這完全取決于開發(fā)項目的預(yù)算和時間。但是,從根本上說,只要對象能夠滿足要求,就不必關(guān)心其功能是如何實現(xiàn)的。(我不認(rèn)同這個觀點)
傳統(tǒng)的結(jié)構(gòu)化程序設(shè)計通過設(shè)計一系列的過程(即算法)來求解問題。一旦確定了這些過程,下一步往往要考慮存儲數(shù)據(jù)的適當(dāng)方式。這就是Pascal語言的設(shè)計者Niklaus Wirth將其著作命名為《算法+數(shù)據(jù)結(jié)構(gòu)=程序》(Algorithms + Data Structures = Programs, PrenticeHall, 1975)的原因。需要注意的是,在Wirth的這個書名中,算法是第一位的,數(shù)據(jù)結(jié)構(gòu)排在第二位,這也反映了當(dāng)時程序員的工作方式。首先,他們會確定操作數(shù)據(jù)的過程,然后再決定如何組織數(shù)據(jù)的結(jié)構(gòu),以便于操作數(shù)據(jù)。而OOP卻調(diào)換了這個次序,將數(shù)據(jù)放在第一位,然后再考慮操作數(shù)據(jù)的算法。
對于一些規(guī)模較小的問題,將其分解為過程的做法是合適的,而對象更適合解決規(guī)模較大的問題??紤]一個簡單的web瀏覽器,實現(xiàn)這個瀏覽器可能需要大約2000個過程,這些過程需要對一組全局?jǐn)?shù)據(jù)進行操作。采用面向?qū)ο箫L(fēng)格時,可能需要大約100個類,每個類平均包含20個方法。這種結(jié)構(gòu)更易于程序員掌握,也更容易查找bug。假設(shè)一個特定對象的數(shù)據(jù)出錯了,在訪問這個數(shù)據(jù)項的20個方法中查找“罪魁禍?zhǔn)住币仍?000個過程中查找容易得多。
?
類:
類(class)指定了如何構(gòu)造對象。可以將類想象成制作小甜餅的模具,將對象想象為小甜餅。由一個類構(gòu)造(construct)對象的過程稱為創(chuàng)建這個類的一個實例(instance)。
正如前面所看到的,用java編寫的所有代碼都在某個類中,標(biāo)準(zhǔn)Java庫提供了幾千個類,可用于各種目的,如用戶界面設(shè)計、日期和日歷,以及網(wǎng)絡(luò)編程。盡管如此,在Java中你還需要創(chuàng)建一些自己的類,來描述你的應(yīng)用相應(yīng)問題領(lǐng)域中的對象。
封裝(encapsulation,有時稱為信息隱藏)是處理對象的一個重要概念。從形式上看,封裝就是將數(shù)據(jù)和行為組合在一個包中,并對對象的使用者隱藏具體的實現(xiàn)細(xì)節(jié)。對象中的數(shù)據(jù)稱為實例字段(instance field),操作數(shù)據(jù)的過程稱為方法(method)。作為一個類的實例,一個特定對象有一組特定的實例字段值。這些值的集合就是這個對象的當(dāng)前狀態(tài)(state)。只要在對象上調(diào)用一個方法,它的狀態(tài)就有可能發(fā)生改變。
實現(xiàn)封裝的關(guān)鍵在于,絕對不能讓其他類中的方法直接訪問這個類的實例字段。程序只能通過對象的方法與對象數(shù)據(jù)進行交互。封裝為對象賦予了“黑盒”特征,這是提高重用性和可靠性的關(guān)鍵。這意味著一個類可以完全改變存儲數(shù)據(jù)的方式,只要仍舊使用同樣的方法操作數(shù)據(jù),其他對象就不會知道也不用關(guān)心這個類所發(fā)生的變化。
OOP的另一個原則會讓用戶自定義Java類變得更為容易,這就是:可以通過擴展其他類來構(gòu)建新類。事實上,Java提供了一個“神通廣大的超類”,名為Object。所有其他類都擴展自這個Object類。(之后會講Object類的內(nèi)容)
擴展一個已有的類時,這個新類具有被擴展的那個類的全部屬性和方法。你只需要在新類中提供適用于這個新類的新方法和實例字段。通過擴展一個類來得到另外一個類的概念稱為繼承(inheritance)。(之后會講繼承)
?
對象:
要想使用OOP,一定要清楚對象的三個主要特性:
·對象的行為(behavior)——可以對這個對象做哪些操作,或者可以對這個對象應(yīng)用哪些方法?
·對象的狀態(tài)(state)——調(diào)用那些方法時,對象會如何響應(yīng)?
·對象的表示(identify)——如何區(qū)分可能有相同行為和狀態(tài)的不同對象?
同一類的所有實例對象都有一種家族相似性,它們都支持相同的行為。一個對象的行為由所能調(diào)用的方法來定義。
此外,每個對象都會保存著描述當(dāng)前狀態(tài)的信息,這就是對象的狀態(tài)(實例字段的值)。對象的狀態(tài)可能會隨著時間而發(fā)生改變,但這種改變不是自發(fā)的。對象狀態(tài)的改變必然是調(diào)用方法的結(jié)果(如果不經(jīng)過方法調(diào)用就可以改變對象狀態(tài),這說明破壞了封裝性)。
但是,對象的狀態(tài)并不能完全描述一個對象,因為每個對象都有一個唯一的標(biāo)識(identify,或稱身份)。例如,在一個訂單處理系統(tǒng)中,任何兩個訂單都是不同的,即使它們訂購的商品完全相同。需要注意,作為同一個類的實例,每個對象的標(biāo)識總是不同的,狀態(tài)也通常有所不同。
對象的這些關(guān)鍵特性會彼此相互影響。例如,對象的狀態(tài)會影響它的行為(如果一個訂單“已發(fā)貨”或“已付款”,就應(yīng)該拒絕要求增刪商品的方法調(diào)用。反過來,如果訂單是“空的”,即還沒有訂購任何商品,就不應(yīng)該允許“發(fā)貨”)。
?
識別類:
傳統(tǒng)的過程式程序中,必須從最上面的main函數(shù)開始編寫程序。設(shè)計一個面向?qū)ο笙到y(tǒng)時,則沒有所謂的“最上面”因此,學(xué)習(xí)OOP的初學(xué)者常常會感覺無從下手。答案是首先從識別類開始,然后再為各個類添加方法。
識別類的一個簡單經(jīng)驗是在分析問題的過程中尋找名詞,而方法對應(yīng)動詞。
例如,在訂單處理系統(tǒng)中,有這樣一些名詞:
·商品(Item)
·訂單(Order)
·發(fā)貨地址(Shipping address)
·付款(Payment)
·賬號(Account)
從這些名詞就可以得到類Item、Order等。
接下來查找動詞。商品要添加(add)到訂單中,訂單可以發(fā)貨(ship)或取消(cancel),另外可以對訂單完成付款(apply)。對于每一個動詞,如“添加”“發(fā)貨”“取消”或者“完成付款”,要識別出負(fù)責(zé)完成相應(yīng)動作的對象。例如,當(dāng)一個新商品添加到訂單中,訂單對象就是負(fù)責(zé)的對象,因為它知道如何存儲商品以及如何對商品進行排序。也就是說,add應(yīng)該是Order類的一個方法,它接受一個Item對象作為參數(shù)。
當(dāng)然,這種“名詞與動詞”原則只是一種經(jīng)驗,在創(chuàng)建類的時候,只有經(jīng)驗?zāi)軒椭愦_定名詞和動詞重要。
?
類之間的關(guān)系:
類之間最常見的關(guān)系有:、
·依賴(“uses-a”);
·聚合(“has-a”);
·繼承(“is-a”)。
依賴(dependence),即“uses-a”關(guān)系,是一種最明顯的也最一般的關(guān)系。例如,Order類使用了Account類,因為Order對象需要訪問Account對象來查看信用狀態(tài)。但是Item類不依賴于Account類,因為Item對象不需要考慮客戶賬戶。因此,如果一個類的方法要使用或操作另一個類的對象,我們就說前一個類依賴于后一個類。
應(yīng)當(dāng)盡可能減少相互依賴的類。這里的關(guān)鍵是,如果類A不知道B的存在,它就不會關(guān)心B的任何改變(這意味著B的改變不會在A中引入bug)。用軟件工程的術(shù)語來說,就是要盡可能減少類之間的耦合(coupling)。
聚合(aggregation),即“has-a”關(guān)系,很容易理解,因為這種關(guān)系很具體。例如,一個Order對象包含一些Item對象。包含關(guān)系意味著類A的對象包含類B的對象。
【注釋:有些方法學(xué)家不喜歡聚合這個概念,而更喜歡使用更一般的“關(guān)聯(lián)”關(guān)系。從建模的角度看,這是可以理解的。但對于程序員來說,“has-a”關(guān)系更加形象。我喜歡使用聚合還有另一個原因:關(guān)聯(lián)的標(biāo)準(zhǔn)記法不是很清楚,請參見表4-1:
?
】
繼承(inheritance),即“is-a”關(guān)系,表示一個更特殊的類與一個更一般的類之間的關(guān)系。例如,RushOrder類繼承了Order類。在特殊化的RushOrder類中包含一些用于優(yōu)先處理的特殊方法,還提供了一個計算運費的不同方法;而其他的方法,如添加商品、生成賬單等都是從Order類繼承來的。一般而言,如果類D擴展了類C,類D會繼承類C的方法,另外還會有一些額外的功能(以后會詳細(xì)講繼承)。
很多程序員采用UML(Unified Modeling Language,統(tǒng)一建模語言)繪制類圖,來描述類之間的關(guān)系。圖4-2就是這樣一個例子。類用矩陣表示,類之間的關(guān)系用帶有各種修飾的箭頭表示。表4-1給出了UML中最常見的箭頭樣式。
?
?
使用預(yù)定義類:
在Java中,沒有類就無法做任何事情,我們前面曾經(jīng)接觸過幾個類。然而,并不是所有的類都表現(xiàn)出面向?qū)ο蟮牡湫吞卣?。以Math類為例。你已經(jīng)看到,可以直接使用Math類的方法,如Math.random,而不必了解它具體是如何實現(xiàn)的,你只需要知道方法名和參數(shù)(如果有的話)。這正是封裝的關(guān)鍵所在,當(dāng)然所有類都是這樣。但Math類只封裝了功能,它不需要也不必隱藏數(shù)據(jù)。由于沒有數(shù)據(jù),因此也不必考慮創(chuàng)建對象和初始化它們的實例字段,因為根本沒有實例字段。
?
對象與對象變量:
要想使用對象,首先必須構(gòu)造對象,并指定其初始狀態(tài)。然后對對象應(yīng)用方法。
在Java程序設(shè)計語言中,要使用構(gòu)造器(constructor,或稱構(gòu)造函數(shù))構(gòu)造新實例。構(gòu)造器是一種特殊的方法,其作用是構(gòu)造并初始化對象。下面來看一個例子。標(biāo)準(zhǔn)Java庫中包含一個Date類。它的對象可以描述一個時間點,例如,“December 31, 1999, 23:59:59 GMT”。
【注釋:你可能會感到奇怪:為什么用類表示日期,而不是像其他語言中那樣用一個內(nèi)置(built-in)類型來表示?例如,Visual Basic中有一個內(nèi)置的date類型,程序員可以采用#12/31/1999#格式指定日期。看起來這似乎很方便,程序員只需要使用內(nèi)置的date類型,而不用考慮類。但實際上,Visual Basic這樣設(shè)計合適嗎?在有些地區(qū),日期表示為月/日/年,而另外一些地區(qū)則表示為日/月/年。語言設(shè)計者是否能夠預(yù)見這些問題呢?如果沒有處理好這類問題,語言就有可能陷入混亂,對此感到不滿的程序員也會喪失使用這種語言的熱情。如果使用類,這些設(shè)計任務(wù)就交給了類庫的設(shè)計者,如果類設(shè)計得不完善,那么其他程序員可以很容易地編寫自己的類,改進或替代(replace)這些系統(tǒng)類(作為印證:Java的日期類庫開始時有些混亂,現(xiàn)在已經(jīng)重新設(shè)計了兩次)。】
構(gòu)造器總是與類同名。因此,Date類的構(gòu)造器就名為Date。要想構(gòu)造一個Date對象,需要在構(gòu)造器前面去加上new操作符,如下所示:
new Date()
這個表達(dá)式會構(gòu)造一個新對象。這個對象初始化為當(dāng)前的日期和時間。
如果需要的話,可以將這個對象傳遞給一個方法:
System.out.println(new Date());
或者,可以對剛構(gòu)造的對象應(yīng)用一個方法。Date類中有一個toString方法。這個方法將生成日期的一個字符串描述??梢匀缦聦π聵?gòu)造的Date對象應(yīng)用toString方法:
String s = new Date().toString();
在這兩個例子中,構(gòu)造的對象僅使用了一次。通常,你可能希望保留所構(gòu)造的對象從而能繼續(xù)使用,為此,需要將對象存放在一個變量中:
Date rightNow = new Date();
對象與對象變量之間存在著一個重要的區(qū)別。例如,以下語句:
Date startTime; // startTime doesn't refer to any object;
定義了一個對象變量startTime,它也可以引用Date類型的對象。但是,一定要認(rèn)識到:變量startTime不是一個對象,而且實際上它甚至還沒有引用任何對象。此時不能在這個變量上使用任何Date方法。下面的語言:
s = startTime.toString(); // not yet
將產(chǎn)生編譯錯誤。
必須首先初始化startTime變量,這里有兩個選擇。當(dāng)然,可以初始化這個變量,讓它引用一個新構(gòu)造的對象:
startTime = new Date();
也可以設(shè)置這個變量,讓它引用一個已有的對象:
startTime = rightNow;
現(xiàn)在,這兩個變量都引用同一個對象。
要認(rèn)識到重要的一點:對象變量并不實際包含一個對象,它只是引用一個對象。
在Java中,任何對象變量的值都是一個引用,指向存儲在另外一個地方的某個對象。new操作符的返回值也是一個引用。下面的語句:
Date startTime = new Date();
有兩個部分。表達(dá)式new Date()構(gòu)造了一個Date類型的對象,它的值是新創(chuàng)建對象的一個引用。再將這個引用存儲在startTime變量中。
可以顯式地將對象變量設(shè)置為null,只是這個變量目前沒有引用任何對象。
startTime = null;
...
if (startTime != null)
????System.out.println(startTime);
【C++注釋:很多人錯誤地認(rèn)為Java中的對象變量就相當(dāng)于C++的引用。然而,C++中沒有null引用,而且引用不能賦值。應(yīng)當(dāng)把Java中的對象變量看作類似于C++的對象指針(但并不是指針,java是按值調(diào)用的)。例如,
Date rightNow; // Java
實際上等同于:
Date* rightNow; // C++
一旦建立了這種關(guān)聯(lián),一切就清楚了。當(dāng)然,只有使用了new調(diào)用后Date*指針才會初始化。就這一點而言,C++與Java的語法幾乎是一樣的。
Date* rightNow = new Date(); // C++
如果把一個變量復(fù)制到另一個變量,兩個變量就指向同一個日期,即它們是同一個對象的指針。Java中的null引用對應(yīng)于C++中的null指針。
所有的Java對象都存儲在堆中。當(dāng)一個對象包含另一個對象變量時,它只是包含另一個堆對象的指針。
在C++中,指針十分令人頭疼,因為它們很容易出現(xiàn)錯誤。稍不小心就會創(chuàng)建一個錯誤的指針,或者使內(nèi)存管理出問題。在Java語言中,這些問題都不復(fù)存在。如果使用一個沒有初始化的指針,那么運行時系統(tǒng)就會產(chǎn)生一個運行時錯誤,而不是生成隨機的結(jié)果。另外,你不必關(guān)系內(nèi)存管理問題,垃圾回收器會處理相關(guān)的事宜。
C++確實做了很大的努力,它通過支持復(fù)制構(gòu)造器和賦值運算符來實現(xiàn)對象的自動復(fù)制。例如,一個鏈表(linked list)的副本是一個新鏈表,其內(nèi)容于原始鏈表相同,但是有一組獨立的鏈接。這樣一來就可以適當(dāng)?shù)卦O(shè)計類,使它們與內(nèi)置類型有復(fù)制行為。在Java中,必須使用clone方法獲得一個對象的完整副本?!?/p>
?
Java類庫中的LocalDate類:
在前面的例子中,我們使用了Java標(biāo)準(zhǔn)類庫中的Date類。Date類的實例有一個狀態(tài),也就是一個特定的時間點。
盡管在使用Date類不必知道這一點,但時間是用距離一個固定時間點的毫秒數(shù)(可正可負(fù))表示的,這個時間點就是所謂的紀(jì)元(epoch),它是UTC時間1970年1月1日00:00:00。UTC就是Coordinated Universal Time(國際協(xié)調(diào)時間),與大家熟悉的GMT(即Greenwich Mean Time,格林尼治時間)一樣,是一種實用的科學(xué)標(biāo)準(zhǔn)時間。
但是,Date類對于處理人類記錄日期的日歷信息并不是很有用,如“December 31,1999”。這個特定的時間描述遵循Gregorian陽歷,這是世界上大多數(shù)國家使用的日歷。但是,同樣的這個時間點采用中國或希伯來的陰歷來描述會大不相同,倘若我們有來自火星的顧客,基于他們使用的火星歷來描述這個時間點就更不一樣了。
類庫設(shè)計者決定將保存時間與給時間點命名分開。所以,標(biāo)準(zhǔn)Java類庫分別包含了兩個類:一個是用來表示時間點的Date類;另一個是用大家熟悉的日歷表示法表示日期的LocalDate類。Java8引入了另外一些類來處理日期和時間的不同方面——(以后會講)。
將時間度量與日歷分開是一種很好的面向?qū)ο笤O(shè)計。通常,最好使用不同的類表示不同的概念。
不要使用構(gòu)造器來構(gòu)造LocalDate類的對象。實際上,應(yīng)當(dāng)使用靜態(tài)工廠方法(factory method),它會代表你調(diào)用構(gòu)造器。下面的表達(dá)式:
LocalDate.now()
會構(gòu)造一個新對象,表示構(gòu)造這個對象時的日期。
可以提供年、月和日來構(gòu)造對應(yīng)一個特定日期的對象:
LocalDate.of(1999, 12, 31)
一旦有了一個LocalDate對象,可以用方法getYear、getMonthValue和getDayOfMonth得到年、月和日:
int year = newYearsEve.getYear(); // 1999
int month = newYearEve.getMonthValue(); // 12
int day = newYearEve.getDayOfMonth(); // 31
看起來這似乎沒有多大的意義,因為這正是構(gòu)造對象時使用的那些值。不過,有時可能有一個計算得到的日期,然后你希望調(diào)用這些方法來了解它的更多信息。例如,plusDays方法會生成一個新的LocalDate,如果把應(yīng)用這個方法的對象稱為當(dāng)前對象,那么這個新日期對象則是距當(dāng)前對象指定天數(shù)的一個新日期:
LocalDate aThousandDayLater = newYearsEve.plusDays(1000);
int year = aThousandDayLater.getYear(); // 2002
int month = aThousandDayLater.getMonthValue(); // 09
int day = aThousandDayLater.getDayOfMonth(); // 26
LocalDate類封裝了一些實例字段來維護所設(shè)置的日期。如果不查看源代碼,就不可能知道類內(nèi)部的日期表示。當(dāng)然,封裝的意義就在于內(nèi)部表示并不重要,重要的是類對外提供的方法。
【注釋:實際上,Date類也有得到日、月、年的方法,分別是getDay、getMonth以及getYear,不過這些方法已經(jīng)廢棄。當(dāng)類庫設(shè)計者意識到某個方法最初就不該引入時就把它標(biāo)記為廢棄,不鼓勵使用。
類庫設(shè)計者意識到應(yīng)當(dāng)單獨提供類來處理日歷,不過在此之前這些方法已經(jīng)是Date類的一部分了。Java1.1中引入較早的一組日歷類時,Date方法被標(biāo)記為舍棄廢棄。雖然仍然可以在程序中使用這些方法,不過如果這樣做,編譯時會出現(xiàn)警告。最好不要使用廢棄的方法,因為將來的某個類庫版本很有可能會將它們完全刪除?!?/p>
【提示:JDK提供了jdeprscan工具來檢查你的代碼中是否使用了JavaAPI已經(jīng)廢棄的特性。有關(guān)說明參見
http://docs.oracle.com/en/java/javase/17/docs/specs/man/jdeprscan.html】
?
更改器方法與訪問器方法:
再來看上一節(jié)中的plusDays方法調(diào)用:
LocalDate aThousandDayLater = newYearsEve.plusDays(1000);
這個調(diào)用之后newYearsEve會有什么變化?它會改為1000天之后的日期嗎?事實上,并沒有。plusDays方法會生成一個新的LocalDate對象,然后把這個新對象賦給aThousandDaysLater變量。原來的對象不做任何改動。我們說plusDays方法沒有更改(mutate)調(diào)用這個方法的對象。(這類似于第3章見過的String類的toUpperCase方法。在一個字符串上調(diào)用toUpperCase時,這個字符串仍保持不變,并返回一個包含大寫字符的新字符串。)
Java庫的一個較早版本曾經(jīng)有另一個處理日歷的類,名為GregorianCalender??梢匀缦聻檫@個類表示的一個日期增加1000天:
GregorianCalendar someDay = new GregorianCalender(1999, 11, 31);
????// odd feature of that class: month numbers go from 0 to 11
someDay.add(Calendar.DAY_OF_MONTH, 1000);
與LocalDate.plusDays方法不同,GregorianCalendar.add方法是一個更改器方法(mutator method)。調(diào)用這個方法后,someDay對象的狀態(tài)會改變??梢匀缦虏榭葱聽顟B(tài):
year = someDay.get(Calendar.YEAR); // 2002
month = somDay.get(Calendar.MONTH) + 1; // 09
day = someDay.get(Calendar.DAY_OF_MONTH); // 26
正是因為這個原因,我們將變量命名為someDay而不是newYearsEve——調(diào)用這個更改器方法之后,它不再是新年前夜。
相反,只訪問對象而不修改對象的方法有時稱為訪問器方法(accessor method)。例如:LocalDate.getYear和GregorianCalendar.get就是訪問器方法。
【C++注釋:在C++中,帶有const后綴的方法是訪問器方法;沒有聲明為const的方法默認(rèn)為更改器方法。但是,在Java語言中,訪問器方法與更改器方法在語法上沒有明顯區(qū)別?!?/p>
下面用一個具體應(yīng)用LocalDate類的程序來結(jié)束這一節(jié)。這個程序?qū)@示當(dāng)前月的日歷。
當(dāng)前日期標(biāo)記有一個*號??梢钥吹剑@個程序需要知道如何計算某月份的天數(shù)以及一個給定日期是星期幾。
下面來看這個程序的關(guān)鍵步驟。首先構(gòu)造一個對象,并用當(dāng)前的日期初始化。
LocalDate date = LocalDate.now();
下面獲得當(dāng)前的月份和日期。
int month = date.getMonthValue();
int today = date.getDayOfMonth();
然后,將date設(shè)置為這個月的第一天,并得到這一天為星期幾。
date = date.minusDys(today - 1); // set to start of month
DayOfWeek weekday = date.getDayOfWeek();
int value = weekday.getValue(); // 1 = Monday, ... , 7 = Sunday
變量weekday設(shè)置為DayOfWeek類型的對象。我們調(diào)用這個對象的getValue方法來得到對應(yīng)星期幾的一個數(shù)值。這會得到一個整數(shù),這里遵循國際慣例,即周末是一周的結(jié)束,星期一就返回1,星期二返回2,依此類推。星期日則返回7。
注意,日歷的第一行是縮進的,使當(dāng)月第一天對應(yīng)正確的星期幾。下面的代碼會打印表頭和第一行的縮進:
System.out.println("Mon Tue Wed Thu Fri Sat Sun");
for (int i = 1; i < value; i++)
????System.out.print(" ?");
現(xiàn)在我們來打印日歷的主體。進入一個循環(huán),其中date遍歷一個月中的每一天。
每次迭代中,我們要打印日期值。如果date是當(dāng)前日期,這個日期則用一個*標(biāo)記。接下來,把date推進到下一天。如果到達(dá)新的一周的第一天,則換行打?。?/p>
while (date.getMonthValue() == month)
{
????System.out.printf("%3d", date.getDayOfMonth());
????if (date.getDayOfMonth() == today)
????????System.out.print("*");
????else
????????System.out.print("*");
????date = date.plusDays(1);
????if (date.getDayOfWeek().getValue() == 1) System.out.println();
}
什么時候結(jié)束呢?我們不知道這個月有幾天,是31天、30天、29天還是28天?實際上,只要date還在當(dāng)月就要繼續(xù)迭代。
可以看到,利用LocalDate類可以編寫一個日歷程序,它能處理星期幾以及各月天數(shù)不同等復(fù)雜問題。你并不需要知道LocalDate類如何計算月和星期幾,只需要使用這個類的接口,也就是諸如plusDays和getDayOfWeek等方法。
這個示例程序的重點是向你展示如何使用一個類的接口來完成相當(dāng)復(fù)雜的人物,而無須了解實現(xiàn)細(xì)節(jié)。
Java.time.LocalDate ?8
·static LocalDate now()
構(gòu)造一個表示當(dāng)前日期的對象。
·static LocalDate of(int year, int month, int day)
構(gòu)造一個表示給定日期的對象。
·int getYear()
·int getMonthValue()
·int getDayOfMonth()
得到當(dāng)前日期的年、月和日。
·DayOfWeek getDayOfWeek()
得到當(dāng)前日期是星期幾,作為DayOfWeek類的一個實例返回。在DayOfWeek實例上調(diào)用getValue來得到1~7之間的一個數(shù),表示這是星期幾,1表示星期一,7表示星期日。
·LocalDate plusDays(int n)
·LocalDate minusDays(int n)
生成當(dāng)前日期之后或之前n天的日期。
?
以下是示例代碼:
import java.time.*;
import java.util.*;
public class firstWar {
????public static void main(String[] args){
????????LocalDateTime today = LocalDateTime.now();
????????System.out.println(today);
????????System.out.print("請輸入你想要查詢的月份:");
????????Scanner in = new Scanner(System.in);
????????int userYear = in.nextInt();
????????int userMonth = in.nextInt();
????????int userDay = 1;
????????LocalDate user = LocalDate.of(userYear,userMonth,userDay);
????????DayOfWeek weekday = user.getDayOfWeek();
????????int value = weekday.getValue();
????????System.out.println("Mon Tue Wed Thu Fri Sat Sun");
????????for (int i = 1; i < value; i++)
????????????System.out.print(" ???");
????????while (user.getMonthValue() == userMonth)
????????{
????????????System.out.printf("%3d", user.getDayOfMonth());
????????????if (user.getDayOfMonth() == today.getDayOfMonth())
????????????????System.out.print("*");
????????????else
????????????????System.out.print(" ");
????????????user = user.plusDays(1);
????????????if (user.getDayOfWeek().getValue() == 1) System.out.println();
????????}
????????if (user.getDayOfWeek().getValue() != 1) System.out.println();
???????}
????}
自定義類:
現(xiàn)在來學(xué)習(xí)如何編寫更復(fù)雜的應(yīng)用所需要的那種主力類(workhorse class)。通常,這些類沒有main方法,而有自己的實例字段和實例方法。要想構(gòu)建一個完整的程序,會結(jié)合使用多個類,其中只有一個類有main方法。
在Java中,最簡單的類定義形式為:
class ClassName
{
????field1
????field2
????...
????constructor1
????constructor2
????...
????method1
????method2
????...
}
下面看一個非常簡單的Employee類,編寫工資管理系統(tǒng)時可能會用到:
class Employee
{
???// instance fields
???private String name;
???private double salary;
???private LocalDate hireDay;
???// constructor
???public Employee(String n, double s, int year, int month, int day)
???{
????????name = n;
????????salary = s;
????????hireDay = LocalDate.of(year, month, day);
???}
???// some method
???public String getName()
???{
????????return name;
???}
???
???public double getSalary()
???{
????????return salary;
???}
???
???public LocalDate getHireDay()
???{
????????return hireDay;
???}
???
???public void raiseSalary(double byPercent)
???{
????????double raise = salary * byPercent / 100;
????????salary += raise;
???}
???
???// more method
???...
}
這里將這個類的實現(xiàn)分成以下幾個部分,并分別在稍后的幾節(jié)中介紹。不過,先來看個程序:
import java.time.*;
public class firstWar {
????public static void main(String[] args){
????????// fill the staff array with three Employee objects
????????Employee[] staff = new Employee[3];
????????staff[0] = new Employee("Carl Caracker", 75000,1987,12,15);
????????staff[1] = new Employee("Harry Hacker", 50000, 1989, 10, 1);
????????staff[2] = new Employee("Tony Tester", 40000, 1990, 3, 15);
????????//raise everyone's salary by 5%
????????for (Employee e :staff)
????????????e.raiseSalary(5);
????????// print out information about all Employee objects
????????for (Employee e : staff)
????????????System.out.println("name = " + e.getName() + ",salary=" + e.getSalary()+" , hireDay=" + e.getHireDay());
???????}
????}
?class Employee {
?????// instance fields
?????private final String name;
?????private double salary;
?????private final LocalDate hireDay;
?????// constructor
?????public Employee(String n, double s, int year, int month, int day) {
?????????name = n;
?????????salary = s;
?????????hireDay = LocalDate.of(year, month, day);
?????}
?????// some method
?????public String getName() {
?????????return name;
?????}
?????public double getSalary() {
?????????return salary;
?????}
?????public LocalDate getHireDay() {
?????????return hireDay;
?????}
?????public void raiseSalary(double byPercent) {
?????????double raise = salary * byPercent / 100;
?????????salary += raise;
?????}
?????// more method
?????//...
?}
在這個程序中,我們構(gòu)建了一個Employee數(shù)組,并填入了3個Employee對象:
Employee[] staff = new Employee[3];
staff[0] = new Employee("Carl Caracker", 75000,1987,12,15);
staff[1] = new Employee("Harry Hacker", 50000, 1989, 10, 1);
staff[2] = new Employee("Tony Tester", 40000, 1990, 3, 15);
接下來,使用Employee類的raiseSalary方法將每個員工的薪水提高5%:
for (Employee e :staff)
????e.raiseSalary(5);
最后,調(diào)用getName方法、getSalary方法和getHireDay方法打印各個員工的信息:
for (Employee e : staff)
????System.out.println("name = " + e.getName()
????????????+ ",salary=" + e.getSalary()
????????????+" , hireDay=" + e.getHireDay());
注意,這個示例程序中包含兩個類:Employee類和帶有public訪問修飾符的firstWar類。firstWar類包含main方法,其中使用了前面的代碼。
源文件名是firstWar.java,這是因為文件名必須與public類的名字匹配。一個源文件中只能有一個公共類,但可以有任意數(shù)目的非公共類。
接下來,編譯這段源代碼的時候,編譯器將在目錄中創(chuàng)建兩個類文件:firstWar.class和Employee.class。
然后啟動這個程序,為字節(jié)碼解釋器提供程序中包含main方法的那個類的類名:
java firstWar
字節(jié)碼解釋器開始運行firstWar類的main方法中的代碼。這個代碼會先后構(gòu)造3個新Employee對象,并顯示它們的狀態(tài)。
?
使用多個源文件:
在上面的程序中,一個源文件包含了兩個類。許多程序員習(xí)慣將各個類放在一個單獨的源文件中。例如,將Employee類放在文件Employee.java中,而將firstWar類存放在文件firstWar.java中。
如果喜歡這樣組織文件,可以有兩種編譯源程序的方法。一種是使用通配符調(diào)用Java編譯器:
javac?Employee*.java
這樣一來,所有與通配符匹配的源文件都將被編譯成類文件。或者可以簡單地鍵入以下命令:
javac firstWar.java
你可能會感到驚訝,使用第二種方式時并沒有顯式地編譯Employee.java。不過,當(dāng)Java編譯器發(fā)現(xiàn)firstWar.java中使用了Employee類時,它會查找名為Employee.class的文件。如果沒有找到這個類文件,就會自動搜索Employee.java并編譯這個文件。另外,如果Employee.java的版本較已有的Employee.class文件版本更新,Java編譯器就會自動地重新編譯這個文件。
【注釋:如果熟悉UNIX的make工具(或者是Windows中的相應(yīng)工具,如make),那么就可以認(rèn)為Java編譯器內(nèi)置了make功能。
?
剖析Employee類:
下面將對Employee類進行剖析。
首先從這個類的方法開始。通過查看源代碼會發(fā)現(xiàn),這個類包含一個構(gòu)造器和4個方法:
public Employee(String n, double s, int year, int month, int day)
public String getName()
public double getSalary()
public LocalDate getHireDay()
public void raiseSalary(double byPercent)
這個類的所有方法都被標(biāo)記為public。關(guān)鍵字public意味著任何類的任何方法都可以調(diào)用這些方法(共有4種訪問級別,將在后面講)。
接下來,需要注意在Employee類的實例中有3個實例字段,用來存放將要操作的數(shù)據(jù):
private final String name;
private double salary;
private final LocalDate hireDay;
關(guān)鍵字private確保只有Employee類本身的方法能夠訪問這些實例字段,任何其他類的方法都不能讀寫這些字段。
【注釋:可以用public標(biāo)記實例字段,但這是一種很不好的做法。public實例字段允許程序的任何部分都能對其進行讀取和修改,這就完全破壞了封裝。任何類的任何方法都可以修改public字段,從我們的經(jīng)驗來看,有些代碼將利用這種做法存取權(quán)限,而這是我們最不希望看到的。因此,這里強烈建議將實例字段標(biāo)記為private?!?/p>
最后,請注意,有兩個實例字段本身就是對象:name字段是String類對象的引用,hireDay字段是LocalDate類對象的引用。類經(jīng)常包含類類型的實例字段。
?
從構(gòu)造器開始:
下面先看看Employee類的構(gòu)造器:
public Employee(String n, double s, int year, int month, int day) {
????name = n;
????salary = s;
????hireDay = LocalDate.of(year, month, day);
}
可以看到,構(gòu)造器與類同名。構(gòu)造Employee類的對象時,構(gòu)造器會運行,這會將實例字段初始化為所希望的初始狀態(tài)。
例如,使用下面這個代碼創(chuàng)建Employee類的一個實例時:
new Employee("Jame Bond", 100000, 1950, 1, 1)
將如下設(shè)置實例字段:
name = "James Bond";
salary = 100000;
hireDay = LocalDate.of(1960, 1, 1); // January 1, 1950
構(gòu)造器與其他方法有一個重要的不同。構(gòu)造器總是結(jié)合new操作符來調(diào)用。不能對一個已經(jīng)存在的對象調(diào)用構(gòu)造器來重新設(shè)置實例字段。例如,
james.Employee("Jame Bond", 250000, 1950, 1, 1) // ERROR
將產(chǎn)生編譯錯誤。
稍后會更詳細(xì)地介紹有關(guān)構(gòu)造器的內(nèi)容?,F(xiàn)在只需要記?。?/p>
·構(gòu)造器與類同名。
·每個類可以有一個以上的構(gòu)造器。
·構(gòu)造器可以有0個、1個或多個參數(shù)。
·構(gòu)造器沒有返回值。
·構(gòu)造器總是結(jié)合new操作符一起調(diào)用。
【C++注釋:Java中構(gòu)造器的工作方式與C++中相同。但是,要記住所有Java對象都是在堆中構(gòu)造的,構(gòu)造器總是結(jié)合new操作符一起使用。C++程序員最易犯的錯誤就是忘記new操作符:
Employee number007("Jame Bond", 100000, 1950, 1, 1); // C++, not Java
這條語句在C++中能夠正常運行,但在Java中卻不行。】
【警告:請注意,不要引入與實例字段同名的局部變量。例如,下面的構(gòu)造器將不會設(shè)置name或salary實例字段:
public Employee(String n, double s, . . .)
{
????String name = n; // ERROR
????double salary = s; // ERROR
????. . .
}
這個構(gòu)造器聲明了局部變量name和salary。這些變量只能在構(gòu)造器內(nèi)部訪問,它們會遮蔽(shadow)同名的實例字段。有些程序員偶爾會不假思索地寫出這類代碼,因為他們的手指會不自覺地增加數(shù)據(jù)類型。這種錯誤很難檢查出來,因此,必須注意在所有地方法中都不要使用與實例字段同名的變量?!?/p>
?
用var聲明局部變量:
在Java 10中,如果可以從變量的初始值推導(dǎo)出它們的類型,那么可以用var關(guān)鍵字聲明局部變量,而無須指定類型。例如,可以不這樣聲明:
Employee harry = new Employee("Harry Hacker", 50000, 1989,10,1);
只需要寫為:
var harry = new Employee("Harry Hacker", 50000, 1989, 10, 1);
這一點很好,因為這樣可以避免重復(fù)寫類型名Employee。
從現(xiàn)在開始,倘若無須了解Java API就能從等號右邊明顯看出類型,在這種情況下我們都將使用var表示法。不過我們不會對數(shù)值類型使用var,如int、long或double,這樣你就不用當(dāng)心0、0L和0.0之間的區(qū)別。對javaAPI有了更多經(jīng)驗后,你可能會希望更多地使用var關(guān)鍵字。
注意var關(guān)鍵字只能用于方法中的局部變量。參數(shù)和字段的類型必須聲明。
?
使用null引用:
在之前我們已經(jīng)了解到,對象變量包含一個對象的引用,或者包含一個特殊值null,后者表示沒有任何對象。
聽上去這是一種處理特殊情況的便捷機制,如未知的名字或雇用日期。不過使用null值時要非常小心。
如果對null值應(yīng)用一個方法,會產(chǎn)生一個NullPointerException異常:
LocalDate rightNow = null;
String s = rightNow.toString(); // NullPointerException
這是一個很嚴(yán)重的錯誤,類似于“索引越界”異常。如果你的程序沒有“捕獲”異常,那么程序就會終止。正常情況下,程序并不捕獲這些異常,而是依賴于程序員從一開始就不要帶來異常。
【提示:程序因NullPointerException異常終止時,棧軌跡會顯示問題出現(xiàn)在哪一行代碼中。從Java17開始,錯誤消息會包含有null值的變量或方法名。例如,在以下調(diào)用中:
String s = e.getHireDay().toString();
錯誤消息會告訴你e是否為null或者getHireDay是否返回null?!?/p>
定義一個類時,最好清楚地知道哪些字段可能為null。在我們的例子中,我們不希望name或hireDay字段為null。(不用擔(dān)心salary字段,這個字段是基本類型,所以不可能是null。)
hireDay字段肯定是非null的,因為它初始化為一個新的LocalDate對象。但是name可能為null,如果調(diào)用構(gòu)造器時為n提供的實參是null,name就會是null。
對此有兩種處理方法?!皩捜荨狈椒ㄊ前裯ull參數(shù)轉(zhuǎn)換為一個適當(dāng)?shù)姆莕ull值:
if (n == null) name = "unknown";else name = n;
Objects類對此提供了一個便利方法:
public Employee(String n, double s, int year, int month, int day) {
?????????name = Objects.requireNonNullElse(n, "unknown");
?????????. . .
?????}
“嚴(yán)格”方法則干脆拒絕null參數(shù):
public Employee(String n, double s, int year, int month, int day) {
?????????name = Objects.requireNonNull(n, "The name cannot be null");
?????????. . .
?????}
如果用null名字構(gòu)造一個Employee對象,就會產(chǎn)生NullPointerException異常。乍看上去這種補救方法好像不太有用,不過這種方法有兩個好處:
1.?異常報告會提供這個問題的描述。
2.?異常報告會準(zhǔn)確地指出問題所在的位置,否則NullPointerException異常會出現(xiàn)在其他地方,而很難追蹤到真正導(dǎo)致問題的構(gòu)造器參數(shù)。
【注釋:如果要接受一個對象引用作為構(gòu)造參數(shù),就要問問自己:是不是真的希望接受可有可無的值。如果不是,那么“嚴(yán)格”方法更合適?!?/p>
?
隱式參數(shù)和顯式參數(shù):
方法會操作對象并訪問它們的實例字段。例如,以下方法:
public void raiseSalary(double byPercent) {
????double raise = salary * byPercent / 100;
????salary += raise;
}
將調(diào)用這個方法的對象的salary實例字段設(shè)置為一個新值??紤]下面這個調(diào)用:
number007.raiseSalary(5);
其作用是將number007.salary字段的值增加5%。具體地說,這個調(diào)用將執(zhí)行以下指令:
double raise = salary * byPercent / 100;
salary += raise;
raiseSalary方法有兩個參數(shù)。第一個參數(shù)稱為隱式(implicit)參數(shù),是出現(xiàn)在方法名前的Employee類型的對象。第二個參數(shù)是位于方法名后面括號中的數(shù)值,這是一個顯式(explicit)參數(shù)。(有人把隱式參數(shù)稱為方法調(diào)用的目標(biāo)或接收者。)
可以看到,顯式參數(shù)顯式地列在方法聲明中,例如double byPercent。隱式參數(shù)則沒有出現(xiàn)在方法聲明中。
在每一個方法中,關(guān)鍵字this指示隱式參數(shù)。如果愿意,可以如下改寫raiseSalary方法:
public void raiseSalary(double byPercent) {
????double raise = this.salary * byPercent / 100;
????this.salary += raise;
}
有些程序員更偏愛這樣的風(fēng)格,因為這樣可以將實例字段與局部變量明顯地區(qū)分開來。
【C++注釋:在C++中,通常在類的外面定義方法:
void Employee::raiseSalary(double byPercent) // C++,not Java
{
????. . .
}
如果在類的內(nèi)部定義方法,那么這個方法將自動成為內(nèi)聯(lián)(inline)方法。
class Employee
{
????. . .
????int getName() { return name; } // inline in C++
}
在Java中,所有的方法都必須在類的內(nèi)部定義,但這并不表示它們是內(nèi)聯(lián)方法。是否將某個方法設(shè)置為內(nèi)聯(lián)方法是Java虛擬機的任務(wù)。即時編譯器會關(guān)注那些簡短、經(jīng)常調(diào)用而且沒有被覆蓋的方法調(diào)用,并進行優(yōu)化。
?
封裝的優(yōu)點:
最后再仔細(xì)看一下非常簡單的getName方法、getSalary方法和getHireDay方法:
public String getName() {
????return name;
}
public double getSalary() {
????return salary;
}
public LocalDate getHireDay() {
????return hireDay;
}
這些都是典型的訪問器方法。由于它們只返回實例字段的值,因此又稱為字段訪問器(field accessor)。
如果將name、salary和hireDay字段標(biāo)記為公共,而不是編寫單獨的訪問器方法,不是更容易一些嗎?
不過,name是一個只讀字段,一旦在構(gòu)造器中設(shè)置,就沒有辦法能夠修改這個字段。這樣我們可以確保name字段不會受到外界的破壞。
雖然salary不是只讀字段,一旦在構(gòu)造器中設(shè)置,就沒有辦法能夠修改這個字段。這樣我們可以確保name字段不會受到外界的破壞。
雖然salary不是只讀字段,但是它只能用raiseSalary方法修改。具體地,如果這個值出現(xiàn)了錯誤,那么只需要調(diào)試這個raiseSalary方法就可以了。如果salary字段是公共字段,破壞這個字段值的罪魁禍?zhǔn)子锌赡艹鰶]在任何地方(那就很難調(diào)試了)。
有些時候,可能想要獲得或設(shè)置實例字段的值,那么你需要提供下面三項內(nèi)容:
·一個私有的實例字段;
·一個公共的字段訪問器方法;
·一個公共的字段更改器方法。
這樣做要比提供一個簡單的公共實例字段復(fù)雜些,但有很多明顯的好處。
首先,可以改變內(nèi)部實現(xiàn),而不影響該類方法之外的任何其他代碼。例如,如果將存儲姓名的字段改為:
String firstName;
String lastName;
那么getName方法可以改為返回
firstName + " " + lastName
這個修改對于程序的其余部分是完全不可見的。
當(dāng)然,為了進行新舊數(shù)據(jù)表示之間的轉(zhuǎn)換,訪問器方法和更改器方法可能需要做許多工作。這將為我們帶來第二個好處:更改器方法可以完成錯誤檢查,而只對字段賦值的代碼不會費心這么做。例如,setSalary方法可以檢查工資是否小于0。
【警告:注意不要編寫返回可變對象引用的訪問器方法。在本書之前的一版中,我們的Empolyee類就違反了這個設(shè)計原則,其中的getHireDay方法返回了一個Date類的對象:
class Employee
{
????private Date hireDay;
????. . .
????public Date getHireDay()
????{
????????return hireDay; // BAD
????}
????. . .
}
LocalDate類沒有更改器方法,與之不同,Date類有一個更改器方法setTime,可以設(shè)置毫秒數(shù)。
Date對象是可變的,這一點就破壞了封裝性!考慮下面這段有問題的代碼:
Employee harry = ...;
Date d = harry.getHireDay();
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引用同一個對象。對d調(diào)用更改器方法會自動改變這個Employee對象的私有狀態(tài)!
如果需要返回一個可變對象的引用,首先應(yīng)該對它進行克?。╟lone)。對象克隆是指存放在另一個新位置的對象副本(有關(guān)對象克隆的詳細(xì)內(nèi)容以后會講)。下面是修改后的代碼:
class Employee
{
????. . .
????public Date getHireDay()
????{
????????return (Date) hireDay.clone(); // OK
????}
????. . .
}
這里有一個經(jīng)驗,如果需要返回一個可變字段的副本,就應(yīng)該使用clone。】
?
基于類的訪問權(quán)限:
從前面已經(jīng)知道,方法可以訪問調(diào)用這個方法的對象的私有數(shù)據(jù)。一個類的方法可以訪問這個類的所有對象的私有數(shù)據(jù),這令很多人感到奇怪。例如,下面來看用來比較兩個員工的equals方法:
class Employee
{
????. . .
????public boolean equals(Employee other)
????{
????????return name.equals(other.name);
????}
}
下面是一個典型的調(diào)用:
if (harry.equals(boss)). . .
這個方法訪問harry的私有字段,這并不讓人奇怪,不過,它還訪問了boss的私有字段。這是合法的,其原因是boss是Employee類型的對象,而Employee類的方法可以訪問任何Employee類型對象的私有字段。
【C++注釋:C++也有同樣的規(guī)則。方法可以訪問所屬類任何對象的私有特性(feature),而不僅限于隱式參數(shù)。
?
私有方法:
實現(xiàn)一個類時,我們會將所有實例字段都設(shè)置為私有字段,因為公共數(shù)據(jù)很危險。不過,方法又應(yīng)該如何設(shè)置呢?盡管大多數(shù)方法都是公共的,但在某些情況下,私有方法可能很有用。有時,你可能希望將一個計算代碼分解成若干個獨立的輔助方法。通常,這些輔助方法不應(yīng)該成為公共接口的一部分,這是因為它們往往與當(dāng)前實現(xiàn)關(guān)系非常緊密,或者需要一個特殊協(xié)議或調(diào)用次序。最好將這樣的方法實現(xiàn)為私有方法。
在Java中,要實現(xiàn)一個私有方法,只需將關(guān)鍵字public改為private即可。
如果將一個方法設(shè)置為私有,倘若你改變了方法的具體實現(xiàn),并沒有義務(wù)保證這個方法依然可用。如果數(shù)據(jù)的表示發(fā)生了變化,那么這個方法可能變得更難實現(xiàn),或者不再需要;這并不重要。重點在于,只要方法是私有的,類的設(shè)計者就可以確信它不會在別處使用,所以可以將其刪去。如果一個方法是公共的,就不能簡單地將其刪除,因為可能會有其他代碼依賴這個方法。
?
final實例字段:
可以將實例字段定義為final。這樣的字段必須在構(gòu)造對象時初始化。也就是說,必須確保在每一個構(gòu)造器執(zhí)行之后,這個字段的值已經(jīng)設(shè)置,并且不能再修改這個字段。例如,可以將Employee類中的name字段聲明為final,因為在對象構(gòu)造之后,這個值不會再改變,即沒有setName方法。
class Employee {
????private final String name;
. . .
}
final修飾符對于類型為基本類型或者不可變類的字段尤其有用。(如果類中的所有方法都不會改變其對象,這樣的類就是不可變類。例如,String類就是不可變的。)
對于可變類,使用final修飾符可能會造成混亂。例如,考慮以下字段:
private final StringBuilder evaluation;
它在Employee構(gòu)造器中初始化為
Evaluations = new StringBuilder();
final關(guān)鍵字只是表示存儲在evaluations變量中的對象引用不會再指示另一個不同的StringBuilder對象。不過這個對象可以更改:
public void giveGoldStar()
{
????evaluations.append(LocalDate.now() + ": Gold star!\n");
}
?
靜態(tài)字段:
如果將一個字段定義為static,那么這個字段并不出現(xiàn)在每個類的對象中。每個靜態(tài)字段只有一個副本??梢哉J(rèn)為靜態(tài)字段屬于類,而不屬于單個對象。例如,假設(shè)需要為每一個員工分配唯一的表示碼,這里為Empoyee類添加一個實例字段id和一個靜態(tài)字段nextId:
class Employee
{
????private static int nextId = 1;
????
????private int id;
????. . .
}
現(xiàn)在,每一個Employee對象都有自己的id字段,但這個類的所有實例將共享一個nextId字段。換句話話說,如果有1000個Employee類對象,則有1000個實例字段id,每個對象有一個實例字段id。但是,只有一個靜態(tài)字段nextId。即使沒有Employee對象,靜態(tài)字段nextId也存在。它屬于類,而不屬于任何單個對象。
【注釋:在一些面向?qū)ο蟪绦蛟O(shè)計語言中,靜態(tài)字段被稱為類字段。術(shù)語“靜態(tài)”只是沿用了C++的叫法,并無實際意義?!?/p>
在構(gòu)造器中,我們?yōu)樾翬mployee對象分配下一個可用的ID,然后將其增1:
id = nextId;
nextId++;
假設(shè)我們構(gòu)造了對象harry。Harry的id字段設(shè)置為靜態(tài)字段nextId的當(dāng)前值,并將靜態(tài)字段nextId的值加1:
harry.id = nextId;
Employee.nextId++;
?
靜態(tài)常量:
靜態(tài)變量使用得比較少,但靜態(tài)常量卻很常用。例如,Math類中定義了一個靜態(tài)常量:
public class Math
{
????. . .
????public static final double pi = 3.14159265358979323846;
????. . .
}
在你的程序中,可以用Math.PI來訪問這個常量。
如果省略關(guān)鍵字static,那么PI就變成了Math類的一個實例字段。也就是說,需要通過Math類的一個對象來訪問PI,并且每一個Math對象都有它自己的一個PI副本。
另一個你已經(jīng)多次使用的靜態(tài)常量是System.out。它在System類中聲明如下:
public class System
{
????. . .
????public static final PrintStream out = . . .;
????. . .
}
前面曾經(jīng)多次提到過,最好不要有公共字段,因為誰都可以修改公共字段。不過,公共常量(即final字段)卻沒問題。因為out被聲明為final,所以,不允許再將它重新賦值為另一個打印流:
System.out = new PrintStream(. . .); // ERROR--out is final
【注釋:如果查看System類,就會發(fā)現(xiàn)有一個setOut方法可以將System.out設(shè)置為不同的流。你可能會感到奇怪,為什么這個方法可以修改final變量的值。原因在于,setOut方法是一個原生方法,而不是在Java語言中實現(xiàn)的。原生方法可以繞過Java語言的訪問控制機制。這是一種特殊的解決方法,你自己編寫程序時不要模仿這種做法?!?/p>
?
靜態(tài)方法:
靜態(tài)方法是不操作對象的方法。例如,Math類的pow方法就是一個靜態(tài)方法。以下是表達(dá)式:
Math.pow(x, a)
會計算冪x^a。它并不使用Math對象來完成這個任務(wù)。換句話說,它沒有隱式函數(shù)。
可以認(rèn)為靜態(tài)方法是沒有this參數(shù)的方法(在一個非靜態(tài)方法中,this參數(shù)指示這個方法的隱式參數(shù)。)
Employee類的靜態(tài)方法不能訪問id實例字段,因為它不能操作對象。但是,靜態(tài)方法可以訪問靜態(tài)字段。下面是一個靜態(tài)方法的示例:
public static int advanceId()
{
????int r = nextId; // obtain next available id
????nextId++;
????return r;
}
要調(diào)用這個方法,需要提供類名:
int n = Employee.advanceId();
這個方法可以省略關(guān)鍵字static嗎?可以。但這樣的話就需要通過對Employee類型的對象引用來這個方法。
【注釋:可以使用對象調(diào)用靜態(tài)方法,這是合法的。例如,如果harry是一個Employee對象,那么可以調(diào)用harry.advaneId()而不是Employee.advance()。不過,這種寫法很容易造成混淆,其原因是advanceId方法計算的結(jié)果與harry毫無關(guān)系。建議使用類名而不是對象來調(diào)用靜態(tài)方法。】
下面兩種情況可以使用靜態(tài)方法:
·方法不需要訪問對象狀態(tài)(狀態(tài)指當(dāng)前對象的實例字段),因為它需要的所有參數(shù)都通過顯式參數(shù)提供(例如Math.pow)。
·方法只需要訪問類的靜態(tài)字段(例如Employee.advanceId)。
【C++注釋:Java中的靜態(tài)字段與靜態(tài)方法在功能上與C++相同。但是,語法稍有所不同。在C++中,要使用 :: 操作符訪問作用域之外的靜態(tài)字段或靜態(tài)方法,如Math::PI。
術(shù)語“靜態(tài)”有一段不尋常的歷史。起初,C引入關(guān)鍵字static是為了表示退出一個塊后依然存在的局部變量。在這種情況下,術(shù)語“靜態(tài)”是有意義的:變量一直保留,當(dāng)再次進入這個塊時它依然存在。隨后,static在C中有了第二種含義,表示不能從其他文件訪問的全局變量和函數(shù)。重用關(guān)鍵字static只是為了避免引入一個新的關(guān)鍵字。最后,C++第三次重用了這個關(guān)鍵字,與之前賦予的含義完全無關(guān),它指示屬于類而不屬于任何特定類對象的變量和函數(shù)。這與Java中這個關(guān)鍵字的含義相同?!?/p>
工廠方法:
靜態(tài)方法還有另一種常見的用途。類似LocalDate和NumberFormat的類使用靜態(tài)工廠方法(factory method)來構(gòu)造對象??梢匀缦碌玫讲煌瑯邮降母袷交瘜ο螅?/p>
NumberFormat currencyFormatter = NumberFormat.getCurrencyInstance();
NumberFormat percentFormatter = NumberFormat.getPercentInstance();
double x = 0.1;
System.out.println(currencyFormatter.format(x)); // prints ¥0.10
System.out.println(percentFormatter.format(x)); // prints 10%
為什么NumberFormat類不使用構(gòu)造器來創(chuàng)建對象呢?有兩個原因:
·無法為構(gòu)造器命名。構(gòu)造器的名字總是要與類名相同。但是,這里希望有兩個不同的名字,分別得到貨幣實例和百分比實例。
·使用構(gòu)造器時,無法改變所構(gòu)造對象的類型。而工廠方法實際上將返回DecimalFormat類的對象,這是繼承NumberFormat的一個子類(繼承以后再講)。[Decimal的意思貨幣型;雙精度;數(shù)據(jù)類型;十進制小數(shù);十進位制]
?
main方法:
需要指出,可以調(diào)用靜態(tài)方而不需要任何對象。例如,不需要構(gòu)造Math類的任何對象就可以調(diào)用Math.pow。
同理,main方法也是一個靜態(tài)方法。
public class Application
{
????public static void main(String[] args)
????{
????????// construct objects here
????????. . .
????}
}
main方法不對任何對象進行操作。事實上,啟動程序時還沒有任何對象。將執(zhí)行靜態(tài)main方法,并構(gòu)造程序所需要的對象。
{Java程序運行時,第一件事情就是試圖訪問main方法,因為main相等于程序的入口,如果沒有main方法,程序?qū)o法啟動,main方法更是占一個獨立的線程,找到main方法后,是不是就會執(zhí)行mian方法塊里的第一句話呢?答案是不一定。
因為靜態(tài)部分是依賴于類,而不是依賴于對象存在的,所以靜態(tài)部分的加載優(yōu)先于對象存在。
當(dāng)找到main方法后,因為main方法雖然是一個特殊的靜態(tài)方法,但是還是靜態(tài)方法,此時JVM會加載main方法所在的類,試圖找到類中其他靜態(tài)部分,即首先會找main方法所在的類。
?
執(zhí)行順序大致分類:
1.靜態(tài)屬性,靜態(tài)方法聲明,靜態(tài)塊。
2.動態(tài)屬性,普通方法聲明,構(gòu)造塊。
3.構(gòu)造方法。
1.1 靜態(tài):
當(dāng)加載一個類時,JVM會根據(jù)屬性的數(shù)據(jù)類型第一時間賦默認(rèn)值(一舉生成的)。然后再進行靜態(tài)屬性初始化,并為靜態(tài)屬性分配內(nèi)存空間,靜態(tài)方法的聲明,靜態(tài)塊的加載,沒有優(yōu)先級之分,按出現(xiàn)順序執(zhí)行,靜態(tài)部分僅僅加載一次。至此為止,必要的類都已經(jīng)加載完畢,對象就可以被創(chuàng)建了。
1.2 普通:
當(dāng)new一個對象時,此時會調(diào)用構(gòu)造方法,但是在調(diào)用構(gòu)造方法之前,(此刻1.1已經(jīng)完成,除非被打斷而暫停)執(zhí)行動態(tài)屬性定義并設(shè)置默認(rèn)值(一舉生成的)。然后動態(tài)屬性初始化,分配內(nèi)存,構(gòu)造塊,普通方法聲明(只是加載,它不需要初始化,只有調(diào)用它時才分配內(nèi)存,當(dāng)方法執(zhí)行完畢后內(nèi)存立即釋放),沒有優(yōu)先級之分,按出現(xiàn)順序執(zhí)行。最后進行構(gòu)造方法中賦值。當(dāng)再次創(chuàng)建一個對象,不再執(zhí)行靜態(tài)部分,僅僅重復(fù)執(zhí)行普通部分。
注意:如果存在繼承關(guān)系,創(chuàng)建對象時,依然會首先進行動態(tài)屬性進行定義并設(shè)默認(rèn)值,然后父類的構(gòu)造器才會被調(diào)用,其他一切都是先父類再子類(因為子類的static初始化可能會依賴于父類成員能否被正確初始化),如果父類還有父類,依次類推,不管你是否打算產(chǎn)生一個該父類的對象,這都是自然發(fā)生的。}
【提示:每一個類都可以有一個main方法。這是為類增加演示代碼的一個技巧。例如,可以在Employee類添加一個main方法:
class Employee {
{
???????public Employee(String n, double s, int year, int month, int day) {
????????name = n;
????????salary = s;
????????hireDay = LocalDate.of(year, month, day);
????}
。。。
????public static void main(String[] args){?// unit test
????????var e = new Employee("Romeo", 50000, 2003, 3, 31);
????????e.raiseSalary(10);
????????System.out.println(e.getName() + " "+e.getSalary());
????}
。。。
}
要看Employee類的演示,只需要執(zhí)行
java Employee
如果Employee類是一個更大的應(yīng)用的一部分,那么可以使用以下命令運行這個應(yīng)用:
java Application
此時,Employee類的main永遠(yuǎn)不會執(zhí)行?!?/p>
?
java.util.Objects???7
·static <T> void requireNonNull (T obj)
·static <T> void requireNonNull (T obj, String message)
·static <T> void requireNonNull (T obj, Supplier<String> messageSupplier) ?8
如果obj是null,這些方法會拋出一個NullPointerException異常而沒有任何消息,或者有給定的消息。(第六章會解釋如何利用供應(yīng)者以懶方式得到一個值。第8章會解釋<T>語法。)
·static <T> T requireNonNullElse (T obj, T defaultObj) ??9
·static <T> T requireNonNullElseGet (T obj, Supplier<T> defaultSupplier) ??9
如果obj不為null則返回obj,或者如果obj為null則返回默認(rèn)對象。
?
方法參數(shù):
首先來回顧在程序設(shè)計語言中關(guān)于如何將參數(shù)傳遞到方法(或函數(shù))的一些專業(yè)術(shù)語。按值調(diào)用(call by value)表示方法接收的是調(diào)用者提供的值。而按引用調(diào)用(call by reference)表示方法接收的是調(diào)用者提供的變量位置(location)。所以,方法可以修改按引用傳遞的值,而不能修改按值傳遞的變量的值。“按.....調(diào)用”(call by)是一個標(biāo)準(zhǔn)的計算機科學(xué)術(shù)語,用來描述各種程序語言(不只是Java)中方法參數(shù)的行為(事實上,以前還有一種按名調(diào)用(call by name),Algol程序設(shè)計語言是最古老的高級語言之一,它就采用了按名調(diào)用方式。不過,這種傳遞方式已經(jīng)成為歷史)。
Java程序設(shè)計語言總是按值調(diào)用。也就是說,方法會得到所有參數(shù)值的一個副本。具體來說,方法不能修改傳遞給它的任何參數(shù)變量的內(nèi)容。
例如,考慮下面的調(diào)用:
double percent = 10;
harry.raiseSalary(10);
不論這個方法具體如何實現(xiàn),我們知道,在這個方法調(diào)用之后,percent的值還是10。
下面再研究一下這種情況。假定一個方法試圖將一個參數(shù)值增加至3倍:(triple的意思adj.三部分的;三人的;三組的;三倍的;三重的;\\v.成為三倍;使增至三倍;\\n.三倍的數(shù)[量];三個一組;三壘安打;)
public static void tripleValue(double x) // doesn't work
{
????x = 3*x;
}
然后調(diào)用這個方法:
double percent = 10;
tripleValue(percent);
不過,這并不起作用。調(diào)用這個方法之后,percent的值還是10。下面來看發(fā)生了什么:
1.?x初始化為percent值的一個副本(也就是10)。
2.?x乘以3后等于30,但是percent仍然是10。
3.?這個方法結(jié)束之后,參數(shù)變量x不再使用。
不過,有兩種不同類型的方法參數(shù):
·基本數(shù)據(jù)類型(數(shù)字、布爾值)。
·對象引用。
我們已經(jīng)看到,一個方法不可能修改基本數(shù)據(jù)類型的參數(shù),而對象參數(shù)就不同了,可以很容易地實現(xiàn)一個方法將一個員工的工資增至3倍:
public static void tripleValue(Employee x) // work
{
????x.raiseSalary(200);
}
如下調(diào)用這個方法時,
harry = new Employee(. . .);
tripleValue(harry);
具體的執(zhí)行過程為:
1.?x初始化為harry值的一個副本,這里就是一個對象引用。
2.?raiseSalary方法應(yīng)用于這個對象引用。x和harry同時引用的那個Employee對象的工資提高了200%(也就是提高到三倍)
3.?方法結(jié)束后,參數(shù)變量x不再使用。當(dāng)然,對象變量harry繼續(xù)引用那個工資增至3倍的員工對象。
可以看到,實現(xiàn)方法改變對象參數(shù)的狀態(tài)是完全可以的,實際上也相當(dāng)常見。理由很簡單,方法得到的是對象引用的副本,原來的對象引用和這個副本都引用同一個對象。
很多程序設(shè)計語言(特別是C++和Pascal)提供了兩種參數(shù)傳遞方式:按值調(diào)用和按引用調(diào)用。有些程序員(甚至有些書的作者)生成Java對對象采用的是按引用調(diào)用,實際上,這是不對的。由于這種誤解很常見,所以很有必要給出一個反例來詳細(xì)地說明問題。
下面來編寫一個交換兩個Employee對象的方法:
public static void swap(Employee x, Employee y) // doesn't work
{
????Employee temp = x;
????x = y;
????y = temp;
}
如果Java對對象采用的是按引用調(diào)用,那么這個方法就應(yīng)該能夠?qū)崿F(xiàn)交換:
var a = new Employee("Alice", ...);
var b = new Employee("Bob", ...);
swap(a, b);
// does a now refer to Bob, b to Alic? Obviously, it doesn't.
但是,這個方法并沒有改變存儲在變量a和b中的對象引用。swap方法的參數(shù)x和y初始化為兩個對象的副本,這個方法交換的是這兩個副本。
// x refers to Alice, y to Bob
????Employee temp = x;
????x = y;
????y = temp;
// now x refers to Bob, y to Alice
最終,白費力氣。方法結(jié)束時,參數(shù)變量x和y被丟棄了。原來的變量a和b仍然引用這個方法調(diào)用之前所引用的對象。
這說明:Java程序設(shè)計語言對對象采用的不是按引用調(diào)用。實際上,對象引用(object reference)是按值傳遞的。
下面來總結(jié)在Java中對方法參數(shù)能做什么和不能做什么:
·方法不能修改基本數(shù)據(jù)類型的參數(shù)(即數(shù)值型或布爾型)。
·方法可以改變對象參數(shù)的狀態(tài)。(實例字段)
·方法不能讓一個對象參數(shù)引用一個新對象。
【C++注釋:C++中有按鍵調(diào)用和按引用調(diào)用。引用參數(shù)標(biāo)有&符號。例如,可以輕松地實現(xiàn)void tripleValue (double &x)方法或
void swap(Employee &x, Employee &y)方法來修改它們地引用參數(shù)?!?/p>
?
對象構(gòu)造:
前面已經(jīng)學(xué)習(xí)了如何編寫簡單的構(gòu)造器來定義對象的初始狀態(tài)。不過,由于對象構(gòu)造非常重要,所以Java提供了多種編寫構(gòu)造器的機制。下面幾節(jié)將詳細(xì)介紹這些機制。
重載:
有些類有多個構(gòu)造器。例如,可以如下構(gòu)造一個空的StringBuilder對象:
var message = new StringBuilder();
或者,可以指定一個初始字符串:
var todoList = new StringBuilder("To do:\n");
這種功能叫作重載(overloading)。如果多個方法(比如,StringBuilder構(gòu)造器方法)有相同的方法名但有不同的參數(shù),便出現(xiàn)了重載。編譯器必須挑選出具體調(diào)用哪個方法。它用各個方法首部中的參數(shù)類型于特定方法調(diào)用中所使用的值類型進行匹配,來選出正確的方法。如果編譯器無法匹配參數(shù),就會產(chǎn)生編譯時出錯,這可能因為不存在匹配,或者所有重載方法中沒有一個相對更好的方法(這個查找匹配的過程稱為重載解析(overloading resolution))。
【注釋:Java允許重載任何方法,而不只是構(gòu)造器方法。因此,要完整地描述一個方法,需要指定方法名以及參數(shù)類型,這叫做方法的簽名(signature)。例如,String類有4個名為indexOf的公共方法。它們的簽名是:
indexOf(int)
indexOf(int, int)
indexOf(String)
indexOf(string, int)
返回類型不是方法簽名的一部分。也就是說,不能有兩個名字相同、參數(shù)類型也相同卻有不同返回類型的方法?!?/p>
?
默認(rèn)字段初始化:
如果在構(gòu)造器中沒有顯示地為一個字段設(shè)置初始值,就會將它自動設(shè)置為默認(rèn)值:數(shù)值將設(shè)置為0,布爾值為false,對象引用為null。有些人認(rèn)為依賴默認(rèn)值的做法是一種不好的編程實踐。確實,如果不明確地對字段進行初始化,就會影響程序代碼的可讀性。
【注釋:這是字段與局部變量的一個重要區(qū)別。方法中的局部變量必須明確地初始化。但是在類中,如果沒有初始化類中的字段,就會自動初始化為默認(rèn)值(0、false或null)?!?span id="s0sssss00s" class="font-size-12">(不過在用idea時,如果沒有顯式的初始化或者沒有顯式的賦值,會直接產(chǎn)生編譯錯誤。故此說法是否準(zhǔn)確還有待考究。)
例如,考慮Employee類。假定沒有在構(gòu)造器中指定如何初始化某些字段,默認(rèn)情況下就會將salary字段初始化為0,將name和hireDay字段初始化為null。(name是String對象、hireDay是LocalDate類的一個子類對象?)
但是,這并不是一個好主意。如果有人調(diào)用getName方法或getHireDay方法,就會得到一個null引用,這可能不是他們想要的結(jié)果:
LocalDae h = harry.getHireDay();
int year = h.getYear(); // throws exception if h is null
?
無參數(shù)的構(gòu)造器:
很多類都包含無參數(shù)的構(gòu)造器,由無參數(shù)構(gòu)造器創(chuàng)建對象時,對象的狀態(tài)會設(shè)置為適當(dāng)?shù)哪J(rèn)值。例如,以下是Employee類的一個無參數(shù)構(gòu)造器:
public Employee()
{
????name = "";
????salary = 0;
????hireDay = LocalDate.now();
}
如果你寫的類沒有構(gòu)造器,就會為你提供一個無參數(shù)構(gòu)造器。這個構(gòu)造器將所有的實例字段設(shè)置為相應(yīng)的默認(rèn)值。所以,實例字段中的所有數(shù)值型數(shù)據(jù)會設(shè)置為0,所有布爾值設(shè)置為false,所有對象變量將設(shè)置為null。(在本機中并沒有提供,反而報告了錯誤。其是否有效有待考究。)
如果類中提供了至少一個構(gòu)造器,但是沒有提供無參數(shù)構(gòu)造器,那么構(gòu)造對象時就必須提供參數(shù),否則就是不合法的。例如,先前Employee提供了一個構(gòu)造器:
public Employee(String n, double s, int year, int month, int day)
對于這個類,構(gòu)造默認(rèn)的員工就是不合法的。也就是說,以下調(diào)用:
e = new Employee();
將產(chǎn)生錯誤。
【警告:請記住,僅當(dāng)類沒有任何其他構(gòu)造器的時候,你才會得到一個默認(rèn)無參數(shù)構(gòu)造器。編寫類的時候,如果寫了一個你自己的構(gòu)造器,要想讓這個類的使用者能夠通過以下調(diào)用創(chuàng)建一個實例:
new ClassName()
你就必須提供一個無參數(shù)的構(gòu)造器。當(dāng)然,如果接受所有字段設(shè)置為默認(rèn)值,則只需要提供以下代碼:(本機未驗證此說法,該內(nèi)容有待考究。)
public ClassName()
{
}
】
【C++注釋:C++對于構(gòu)造字段有一種特殊的初始化器列表語法,如下所示:
Employee::Employee(String n , double s, int y, int m, int d) // C++
: name(n),
??salary(s),
??hireDay(y, m, d)
{
}
C++使用這種特殊語法來避免不必要地調(diào)用無參數(shù)構(gòu)造器。在Java中,不需要這種語法,因為對象沒有子對象,只有其它對象的引用?!?/p>
?
顯式字段初始化:
通過重載類的構(gòu)造器方法,可以采用多種形式設(shè)置類的實例字段的初始狀態(tài)。不論調(diào)用哪個構(gòu)造器,每個實例字段都要設(shè)置為一個有意義的初始值,確保這一點總是一個好主意。
可以在類定義中直接為任何字段賦值。例如:
class Employee {
?????private String name = "unknown";
?????. . .
}
在執(zhí)行構(gòu)造器之前完成這個賦值。如果一個類的所有構(gòu)造器都需要把某個特定的實例字段設(shè)置為同一個值,那么這個語法尤其有用。
初始化不一定是常量值。在下面的例子中,就是利用方法調(diào)用初始化一個字段??紤]以下Employee類,其中每個員工有一個id字段??梢允褂靡韵路绞竭M行初始化:
class Employee {
?????private static int nextId = 1;
?????private int id = getNextId();
?????. . .
?????public static int getNextId(){
??????????int num = nextId;
??????????nextId++;
??????????return num;
??????}
?
?
?
參數(shù)名:
在編寫很小的構(gòu)造器時(這十分常見),在為參數(shù)命名時可能有些困惑。
我們通常喜歡用單個字母作為參數(shù)名:
public Employee(String n, double s) {
?????name = n;
?????salary = s;
}
但這樣做有一個缺點:只有閱讀代碼才能夠了解參數(shù)n和參數(shù)s的含義。
有些程序員在每個參數(shù)前面加上一個前綴“a”:
public Employee(String aName, double aSalary) {
?????name = aName;
?????salary = aSalary;
}
這樣更好一些。讀者一眼就能夠看懂參數(shù)的含義。
還有一種常用的技巧,它基于這樣的事實:參數(shù)變量會遮蔽同名的實例字段。例如,如果將參數(shù)命名為salary,那么salary將指示這個參數(shù),而不是實例字段。但是,還可以用this.salary訪問實例字段?;叵胍幌拢瑃his指示隱式參數(shù),也就是所構(gòu)造的對象。下面來看一個示例:
public Employee(String Name, double Salary) {
?????this.name = aName;
?????this.salary = aSalary;
}
【C++注釋:在C++中,經(jīng)常用下畫線或某個固定的字母(一般選用m或x)作為實例字段的前綴。例如,salary字段可能被命名為_salary、mSalary或xSalary。Java程序員通常不這樣做?!?/p>
?
調(diào)用另一個構(gòu)造器:
關(guān)鍵字this指示一個方法的隱式參數(shù)。不過,這個關(guān)鍵字還有另外一個含義。
如果構(gòu)造器的第一個語句形如this(....),這個構(gòu)造器將調(diào)用同一個類的另一個構(gòu)造器。下面是一個典型的例子:
public Employee(double salary){
????// calls Employee(String,?double)
????this("Employee #" + nextId, salary);
}
public Employee(String name, double salary){
????this.name = name;
????this.salary = salary;
????id = nextId;
????nextId++;
????hireDay = LocalDate.now();
}
當(dāng)調(diào)用new Employee (60000)時,Employee (double)構(gòu)造器將調(diào)用Employee (String, double)構(gòu)造器。
采用這種方式使用this關(guān)鍵字非常有用,這樣只需要寫一次公共構(gòu)造代碼。
【C++注釋:在Java中,this引用等價于C++中的this指針。但是,在C++中,一個構(gòu)造器不能調(diào)用另一個構(gòu)造器。如果在C++中想抽取出公共的初始化代碼,則必須編寫一個單獨的方法?!?/p>
?
初始化塊:
前面已經(jīng)介紹過兩種初始化實例字段的方法:
·在構(gòu)造器中設(shè)置值;
·在聲明中賦值。
實際上,Java還有第三種機制,成為初始化塊(initialization block)。在一個類的聲明中,可以包含任意的代碼塊。構(gòu)造這個類的對象時,這些塊就會執(zhí)行。例如,
class Employee
{
????private static int nextId;
????private int id;
????private String name;
????private double salary;
????// object initialization block
????{
????????id = nextId;
????????nextId++;
????}
}
在這個示例中,無論使用哪個構(gòu)造器構(gòu)造對象,id字段都會在對象初始化塊中初始化。首先運行初始化塊,然后才運行構(gòu)造器的主體部分。
這種機制不是必需的,也不常見。通常會直接將初始化代碼放在構(gòu)造器中。
【注釋:可以在初始化塊中設(shè)置字段,即使這些字段在類后面才定義,這是合法的。但是,為了避免循環(huán)定義,不允許讀取在后面初始化的字段。具體規(guī)則請參見Java語言規(guī)范的8.3.3節(jié)(http://docs.oracle.com/javase/specs)。這些規(guī)則太過復(fù)雜,讓編譯器的實現(xiàn)者都很頭疼,所以較早的Java版本中這些規(guī)則的實現(xiàn)存在一些小錯誤。因此,建議總是將初始化塊放在字段定義之后?!?/p>
由于初始化實例字段有多種途徑,所以列出構(gòu)造過程的路徑可能讓人很費解。下面是調(diào)用構(gòu)造器時的具體處理步驟:
1.?如果構(gòu)造器的第一行調(diào)用了另一個構(gòu)造器。則基于所提供的參數(shù)執(zhí)行第二個構(gòu)造器。
2.?否則,
a)?所有的實例字段初始化為默認(rèn)值(0、false或null)。
b)?按照在類聲明中出現(xiàn)的順序,執(zhí)行所有初始化方法和初始化塊。
3.?執(zhí)行構(gòu)造器主體代碼。
當(dāng)然,最好精心地組織初始化代碼,以便其他程序員輕松理解,而不要求他們都是語言專家。例如,如果讓類的構(gòu)造器依賴于實例字段聲明的順序,那就會顯得很奇怪并且容易引起錯誤。(不過好像這樣子弄不了。可能只是舉一個例子。)
可以通過提供一個初始值,或者使用一個靜態(tài)的初始化塊來初始化靜態(tài)字段,前面已經(jīng)介紹過第一種機制:
private static int nextId = 1;
如果類的靜態(tài)字段需要很復(fù)雜的初始化代碼,那么可以使用靜態(tài)的初始化塊。
將代碼放在一個塊中,并標(biāo)記關(guān)鍵字static。下面是一個示例。我們希望將員工ID的起始值賦為一個小于10000的隨機整數(shù)。
private static Random generator = new Random();
// static initialization block
static
{
????nextId = generator.nextInt(10000);
}
在類第一次加載的時候,會完成靜態(tài)字段的初始化。與實例字段一樣,除非將靜態(tài)字段顯式地設(shè)置成其他值,否則默認(rèn)的初始值為0、false或null。所有的靜態(tài)字段初始化方法以及靜態(tài)初始化塊都將依照類聲明中出現(xiàn)的順序執(zhí)行。
【注釋:讓人驚訝的是,在JDK 6之前,完全可以用Java編寫一個沒有main方法的“Hello, World”程序。
public class Hello
{
????static
????{
????????System.out.println("Hello, World");
????}
}
當(dāng)用java Hello調(diào)用這個類時,就會加載這個類,靜態(tài)初始化將會打印“Hello, World”。在此之后才會顯示一個消息指出main未定義。從Java 7之后,java程序會首先檢查是否有一個main方法?!?/p>
這個例子使用了Random類來生成隨機數(shù)。從JDK 17開始,java.util.random包提供了考慮多種因素的強算法的實現(xiàn)。閱讀java.util.random包的API文檔,其中對如何選擇算法給出了建議。然后通過提供算法名來得到一個實例,如下所示:
RandomGenerator generator = RandomGenerator.of("L64X128MixRandom");
調(diào)用generator.nextInt(n)或其他RandomgGenerator方法來生成隨機數(shù)。(RandomGenerator是一個接口,接口以后會講,Random類的對象可以使用所有RandomGenerator方法。)
?
java.util.Random ?1.0
·Random()
構(gòu)造一個新的隨機數(shù)生成器。
java.util.random.RandomGenerator ?17
·int nextInt(int n)
返回一個0~n-1之間的隨機數(shù)。
·static RandomGenerator of(String name)
由給定算法名生成一個隨機數(shù)生成器。算法“LX28MixRandom”對大多數(shù)應(yīng)用都適用。
?
對象析構(gòu)與fianlize方法:
有些面向?qū)ο蟮某绦蛟O(shè)計語言(特別是C++)有顯式的析構(gòu)器方法,其中設(shè)置一些清理代碼,當(dāng)對象不再使用可能需要執(zhí)行這些清理代碼。在析構(gòu)器中,最常見的操作是回收分配給對象的存儲空間。由于Java會自動完成垃圾回收,不需要人工回收內(nèi)存,所以Java不支持析構(gòu)器。
當(dāng)然,某些對象使用了內(nèi)存之外的其他資源,例如,文件或使用系統(tǒng)資源的另一個對象的句柄。在這種情況下,當(dāng)資源不再需要時,將其回收和再利用就十分重要。
如果一個資源一旦使用完就需要立即關(guān)閉,那么應(yīng)當(dāng)提供一個close方法來完成必要的清理工作??梢栽趯ο笫褂猛陼r調(diào)用這個close方法。(以后會講如何確保自動調(diào)用這個方法。)
如果可以等到虛擬機退出,那么可以用方法Runtime.addShutdownHook增加一個“關(guān)閉鉤”(shutdown hook)。在Java 9中,可以使用Cleaner類注冊一個動作,當(dāng)對象不再可達(dá)時(除了清潔器還能訪問,其他對象都無法訪問這個對象),就會完成這個動作。在實際中這些情況很少見??梢詤⒁夾PI文檔來了解這兩種方法的詳細(xì)內(nèi)容。
【警告:不要使用fianlize方法來完成清理。這個方法原本要在垃圾回收器清理對象之前調(diào)用。不過,你并不能知道這個方法到底什么時候調(diào)用,而且該方法已經(jīng)被廢棄?!?/strong>
?
記錄:
有時,數(shù)據(jù)就只是數(shù)據(jù),而面向?qū)ο蟪绦蛟O(shè)計提供的數(shù)據(jù)隱藏有些礙事。考慮一個類Point,這個類描述平面上的一個點,有x和y坐標(biāo)。
當(dāng)然,可以如下創(chuàng)建一個類:
class Point
{
????private final double x;
????private final double y;
????public Point(double x, double y)
????{
????????this.x = x;
????????this.y = y;
????}
????public getX()
????{
????????return x;
????}
????public getY()
????{
????????return y;
????}
????public String toString()
????{
????????return "Point[x = %d, y = %d]".formatted(x, y);
????}
????// More methods. . .
}
這里隱藏了x和y,然后通過獲取方法來獲得這些值,不過,這種做法對我們確實有好處嗎?
我們將來想改變Point的實現(xiàn)嗎?當(dāng)然,還有極坐標(biāo),不過對于圖形API,你可能不會使用極坐標(biāo)。在實際中,平面上的一個就用x和y坐標(biāo)來描述。
為了更簡潔地定義這些類,JDK14引用了一個大預(yù)覽特性:“記錄”。最終版本在JDK16中發(fā)布。
?
記錄概念:
記錄(record)是一種特殊形式的類,其狀態(tài)不可變,而且公共可讀。可以如下將Point定義為一個記錄:
record Point(double x, double y){}
其結(jié)果是有以下實例字段的類:
private final double x;
private final double y;
在Java語言規(guī)范中,一個記錄的實例字段成為組件(component)。
這個類有一個構(gòu)造器:
Point(double x, double y)
和以下訪問器方法:
public double x()
public double y()
注意,訪問器方法名為x和y,而不是getX和getY。(Java中實例字段可以與方法同名,這是合法的。)
var p = new Point(3, 4);
System.out.println(p.x() + " " + p.y();
【注釋:Java沒有遵循get約定,因為那有些麻煩。對于布爾字段,通常使用is而不是get。而且首字母大寫可能有問題。如果一個類既有x字段又有X字段,會發(fā)生什么?有些程序員不太滿意,因為他們原先的類不能輕松地變?yōu)橛涗?。不過實際上,那些遺留類中,很多都是可變的,所以并不適合轉(zhuǎn)換為記錄?!?/p>
除了字段訪問器方法,每個記錄有3個自動定義的方法:toString、equals和hashCode。下一章會更多地了解這些方法。
【警告:對于這些自動提供的方法,也可以定義你自己的版本,只要它們有相同的參數(shù)和返回類型。李儒,下面的定義就是合法的:
ecord Point(double x, double y)
{
????public double x() { return y;} // BAD
}
不過,這并不是一個好主意?!?/p>
可以為一個記錄增加你自己的方法:
record Point(double x, double y)
{
????public double distanceFromOrigin()
????{
????????return Math.hypot(x, y);
????}
}
與所有其它類一樣,記錄可以有靜態(tài)字段和方法:
record Point(double x, double y)
{
????public static Pont ORIGIN = new Point(0, 0);
????public static double distance(Point p, Point q)
????{
????????return Math.hypot(p.x - q.x, p.y - q.y);
????}
????. . .
}
不過不能為記錄增加實例字段:
record Point(double x, double y)
{
????private double r; // ERROR
????. . .
}
【警告:記錄的實例字段自動為final字段。不過,它們可能是可變對象的引用。
record PointInTime(double x, double y, Date when) {}
這樣記錄實例將是可變的:
var pt = new PointInTime(0, 0, new Date());
pt.when().setTime(0);
如果希望記錄實例是不可變的,那么字段就不能使用可變的類型。】
【提示:對于完全由一組變量表示的不可變數(shù)據(jù),要使用記錄而不是類。如果數(shù)據(jù)是可變的,或者數(shù)據(jù)表示可能隨時間改變,則使用類。記錄更易讀、更高效,而且在并發(fā)程序中更安全。】
構(gòu)造器:標(biāo)準(zhǔn)、自定義和簡潔:
自動定義地設(shè)置所有實例字段地構(gòu)造器稱為標(biāo)準(zhǔn)構(gòu)造器(canonical constrctor)。
還可以定義另外的自定義構(gòu)造器(custom constructor)。這種構(gòu)造器的第一個語句必須調(diào)用另一個構(gòu)造器,所以最終會調(diào)用標(biāo)準(zhǔn)構(gòu)造器。下面來看一個例子:
record Point(double x, double y)
{
????public Point() { this(0, 0); }
}
這個記錄有兩個構(gòu)造器:標(biāo)準(zhǔn)構(gòu)造器和一個生成原點的無參數(shù)構(gòu)造器。
如果標(biāo)準(zhǔn)構(gòu)造器需要完成額外的工作,那么可以提供你自己的實現(xiàn):
record Range(int from, int to)
{
????public Range(int from, int to)
????{
????????if (from <= to)
????????{
????????????this.from = from;
????????????this.to = to;
????????}
????????else
????????{
????????????this.from = to;
????????????this.to = from;
????????}
????}
}
不過,實現(xiàn)標(biāo)準(zhǔn)構(gòu)造器時,建議使用一種簡潔(compact)形式。不用指定參數(shù)列表:
record Range(int from, int to)
{
????public Range // Compact form
????{
????????if (from > to) // Swap the bounds
????????{
????????????int temp = from;
????????????from = to;
????????????to = temp;
???????}
????}
}
簡潔形式的主體是標(biāo)準(zhǔn)構(gòu)造器的“前奏”。它只是在為實例字段this.from和this.to賦值之前修改參數(shù)變量from和to。不能在簡潔構(gòu)造器的主體中讀取或修改實例字段。
?
包:
java允許使用包(package)將類組織在一個集合中。借助包可以方便地組織你的代碼并將你自己的代碼與其他人提供的代碼庫分開。下面我們將介紹如何使用和創(chuàng)建包。
包名:
使用包的主要原因是確保類名的唯一性。假如兩個程序員不約而同地提供了Employee類,只要他們將自己的類放置在不同的包中,就不會產(chǎn)生沖突。事實上,為了保證包名的絕對唯一性,可以使用一個intel net(因特網(wǎng))域名(這顯然是唯一的)以逆序的形式作為包名,然后對于不同的項目使用不同的子包。例如,考慮域名horstmann.com。如果逆序來寫,就得到了包名com.horstmann。然后可以追加一個項目名,如com.horstmann.corejava。如果再把Employee類放在這個包里,那么這個類的“完全限定”名就是com.horstmann.corejava.Employee。
【注釋:從編譯器的角度來看,嵌套的包之間沒有任何關(guān)系。例如,java.util包與java.util.jar包毫無關(guān)系。每一個包都是獨立的類集合。】
?
類的導(dǎo)入:
一個類可以使用所屬包(這個類所在的包)中的所有類,以及其他包中的公共類(public class)。
我們可以采用兩個方式訪問另一個包中的公共類。第一種方式是使用完全限定名(fully qualified name),也就是包名后面跟著類名。例如:
java.time.LocalDate today = java.time.LocalDate.now();
這顯然很繁瑣。更簡單且更常用的方式是使用import語句。import語句的關(guān)鍵是可以提供一種簡寫方式來引用包中各個類。一旦增加了import語句,在使用類時,就不必寫出類的全名了。
可以使用import語句導(dǎo)入一個特定的類或整個包。Import語句應(yīng)該位于源文件的頂部(但位于package語句的后面)。例如,可以使用下面這條語句導(dǎo)入java.time包中的所有類。
import java.time.*;
然后,就可以使用:
LocalDate today = LocalDate.now();
而不需要在前面加上包前綴。
還可以導(dǎo)入一個包中的特定類:
import java.time.LocalDate;
java.time.*的語法比較簡單,對代碼的規(guī)模也沒有任何負(fù)面影響。不過,如果能夠明確地指出所導(dǎo)入地類,那么代碼的讀者就能夠更加準(zhǔn)確地知道你使用了哪些類。
【提示:在Eclipse中,可以使用菜單選項Source—>Organize Imports。諸如import java.util.*;等包語句將會自動擴展為一組特定的導(dǎo)入語句,如:
import java.util.ArrayList;
import java.util.Date;
這是一個十分便捷的特性?!?/p>
但是,需要注意的是,只能使用星號(*)導(dǎo)入一個包,而不能使用import java.*或import java.*.*導(dǎo)入以java為前綴的所有包。
在大多數(shù)情況下,可以只導(dǎo)入你需要的包,并無須過多考慮。但在發(fā)生命名沖突的時候,就要注意包了。例如,java.util和java.sql包都有Date類。假設(shè)在程序種導(dǎo)入了這兩個包:
import java.sql.*;
import java.util.*;
在程序種使用Date類的時候,就會出現(xiàn)一個編譯錯誤:
Date today; // ERROR--java.util.Date or java.sql.Date?
此時,編譯器無法確定你想使用的是哪一個Date類。可以增加一個特定的import語句來解決這個問題:
import java.sql.*;
import java.util.*;
import java.util.Date;
如果這兩個Date類都需要使用,又該怎么辦呢?答案是,在每個類名的前面加上完整的包名:
var startTime = new java.util.Date();
var today = new java.sql.Date(. . .);
在包中定位類是編譯器(compiler)的工作。類文件中的字節(jié)碼總是完整的包名來引用其他類。
【C++注釋:C++程序員有時會將import與#include弄混。實際上,這兩者之間并沒有共同之處。在C++中,必須使用#include來包含外部特性的聲明,這是因為,除了正在編譯的文件以及顯式包含的頭文件,C++編譯器不會查看任何其他文件。Java編譯器則不同,只要你告訴它文件在哪里,它很樂于查看其他文件。
在Java中,通過顯式地給出完整地類名,如java.util.Date,可以完全避免使用import機制;而在C++中,則無法避免使用#include指令。
import語句唯一的好處是簡捷??梢允褂煤喍痰拿侄皇峭暾陌麃硪靡粋€類。例如,在import java.util.*(或import java.util.Date)語句之后,可以只用Date來引用java.util.Date類。
在C++中,與包機制類似的是命名空間(namespace)特性??梢哉J(rèn)為Java中的package和import語句類似于C++中的namespace和using指令?!?/p>
?
靜態(tài)導(dǎo)入:
有一種import語句允許導(dǎo)入靜態(tài)方法和靜態(tài)字段,而不只是類。
例如,如果在源文件最上面添加一條指令:
import static java.lang.System.*;
就可以使用System類的靜態(tài)方法和靜態(tài)字段,而不必加類名前綴:
out.println("Goodbye, World!"); // i.e., System.out
exit(0); // i.e., System.exit
另外,還可以導(dǎo)入特定的方法或字段:
import static java.lang.System.out;
實際上,是否有很多程序員想要用簡寫System.out或System.exit,這一點很讓人懷疑。這樣寫出的diamagnetic看起來不太清晰。不過,
sqrt(pow(x, 2) + pow(y, 2))
看起來則比
Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2))
簡潔得多。
?
包訪問:
前面已經(jīng)見過訪問修飾符pubic和private。標(biāo)記為public的部分可以由任意類使用;標(biāo)記為private的部分只能由定義它們的類使用。如果沒有指定public或private,這個部分(類、方法或變量)可以由同一個包中的所有方法訪問。(大概是默認(rèn)之類的)
下面再來一個例子,一個程序中,并沒有將Employee類定義為公共類,因此只有在通過一個包(在此指無名包)中的其他類(例如EmployeeTest)可以訪問這個類。對于類來說,這種默認(rèn)方式是合乎情理的。但是,對于變量來說就有些不適宜了,變量必須顯式地標(biāo)記為private,不然的話就默認(rèn)為包可訪問。顯然,這樣會破壞封裝性。問題是人們經(jīng)常忘記鍵入關(guān)鍵字private。以java.awt包中的Window類為例(java.awt包是JDK提供的源代碼的一部分):
public class Window extends Container{
????String warningString;
????...
}
請注意,這里的warningString變量不是private!這意味這java.awt包中的所有類的方法都可以訪問該變量,并將它設(shè)置為任意值(例如,”Trust me!”)。實際上,只有Window類的方法訪問這個變量,因此本應(yīng)該將它設(shè)置為私有變量才合適??赡苁浅绦騿T敲代碼時匆忙之中忘記private修飾符了?也可能沒有人關(guān)心這個問題?不僅如此,這個類還陸續(xù)增加了一些新的字段,而其中大約又一半也不是私有的。
這可能會成為一個問題。在默認(rèn)情況下,包不是封閉的實體。也就是說,任何人都可以向包中添加更多的類。當(dāng)然,有惡意或糟糕的程序員很可能利用包訪問添加一些能修改變量的代碼。例如,在Java程序設(shè)計語言的早期版本中,只需要將以下這條語句房子啊類文件的開頭,就可以很容易地在java.awt包中混入其他類:
Package java.awt;
然后,把得到的類文件放置在類路徑上某處的java/awt子目錄下,這樣就可以修改警告字符串。
從1.2版開始,JDK的實現(xiàn)者修改了類加載器,明確地禁止加載包名以“java.”開頭的用戶自定義的類!當(dāng)然,用戶自定義的類無法從這種保護中受益。另一種機制是讓JAR文件聲明包為密封的(sealed),以防止第三方修改,但這種機制已經(jīng)過時。現(xiàn)在應(yīng)當(dāng)使用模塊封裝包。(卷II第九章會講模塊)。
【學(xué)習(xí)參考書籍:《Java核心技術(shù)卷I》】