目錄
我會從接口、抽象類的一些實踐,以及語言變化方面去闡述一些擴展知識點。
Java 是非常典型的面嚮對象語言,曾經有一段時間,程序員整天把面向對象、設計模式掛在嘴邊。雖然如今大家對這方面已經不再那麼狂熱,但是不可否認,掌握面向對象設計原則和技巧,是保證高質量代碼的基礎之一。
面向對象提供的基本機制,對於提高開發、溝通等各方面效率至關重要。考察面向對象也是面試中的常見一環,下面我來聊聊面向對象設計基礎。
談談接口和抽象類有什麼區別?
典型回答
接口和抽象類是 Java 面向對象設計的兩個基礎機制。
接口是對行爲的抽象,它是抽象方法的集合,利用接口可以達到 API 定義和實現分離的目的。接口,不能實例化;不能包含任何非常量成員,任何 field 都是隱含着 public static final 的意義;同時,沒有非靜態方法實現,也就是說要麼是抽象方法,要麼是靜態方法。Java 標準類庫中,定義了非常多的接口,比如java.util.List。
抽象類是不能實例化的類,用 abstract 關鍵字修飾 class,其目的主要是代碼重用。除了不能實例化,形式上和一般的 Java 類並沒有太大區別,可以有一個或者多個抽象方法,也可以沒有抽象方法。抽象類大多用於抽取相關 Java 類的共用方法實現或者是共同成員變量,然後通過繼承的方式達到代碼複用的目的。Java 標準庫中,比如 collection 框架,很多通用部分就被抽取成爲抽象類,例如java.util.AbstractList。
Java 類實現 interface 使用 implements 關鍵詞,繼承 abstract class 則是使用 extends 關鍵詞,我們可以參考 Java 標準庫中的 ArrayList。
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
//...
}
考點分析
這是個非常高頻的 Java 面向對象基礎問題,看起來非常簡單的問題,如果面試官稍微深入一些,你會發現很多有意思的地方,可以從不同角度全面地考察你對基本機制的理解和掌握。比如:
- 對於 Java 的基本元素的語法是否理解準確。能否定義出語法基本正確的接口、抽象類或者相關繼承實現,涉及重載(Overload)、重寫(Override)更是有各種不同的題目。
- 在軟件設計開發中妥善地使用接口和抽象類。你至少知道典型應用場景,掌握基礎類庫重要接口的使用;掌握設計方法,能夠在 review 代碼的時候看出明顯的不利於未來維護的設計。
- 掌握 Java 語言特性演進。現在非常多的框架已經是基於 Java 8,並逐漸支持更新版本,掌握相關語法,理解設計目的是很有必要的。
知識擴展
我會從接口、抽象類的一些實踐,以及語言變化方面去闡述一些擴展知識點。
Java 相比於其他面嚮對象語言,如 C++,設計上有一些基本區別,比如Java 不支持多繼承。這種限制,在規範了代碼實現的同時,也產生了一些侷限性,影響着程序設計結構。Java 類可以實現多個接口,因爲接口是抽象方法的集合,所以這是聲明性的,但不能通過擴展多個抽象類來重用邏輯。
在一些情況下存在特定場景,需要抽象出與具體實現、實例化無關的通用邏輯,或者純調用關係的邏輯,但是使用傳統的抽象類會陷入到單繼承的窘境。以往常見的做法是,實現由靜態方法組成的工具類(Utils),比如 java.util.Collections。
設想,爲接口添加任何抽象方法,相應的所有實現了這個接口的類,也必須實現新增方法,否則會出現編譯錯誤。對於抽象類,如果我們添加非抽象方法,其子類只會享受到能力擴展,而不用擔心編譯出問題。
接口的職責也不僅僅限於抽象方法的集合,其實有各種不同的實踐。有一類沒有任何方法的接口,通常叫作 Marker Interface,顧名思義,它的目的就是爲了聲明某些東西,比如我們熟知的 Cloneable、Serializable 等。這種用法,也存在於
業界其他的 Java 產品代碼中。
從表面看,這似乎和 Annotation 異曲同工,也確實如此,它的好處是簡單直接。對於 Annotation,因爲可以指定參數和值,在表達能力上要更強大一些,所以更多人選擇使用 Annotation。
Java 8 增加了函數式編程的支持,所以又增加了一類定義,即所謂 functional interface,簡單說就是隻有一個抽象方法的接口,通常建議使用@FunctionalInterface Annotation 來標記。Lambda 表達式本身可以看作是一類functional interface,某種程度上這和麪向對象可以算是兩碼事。我們熟知的Runnable、Callable 之類,都是 functional interface 。
還有一點可能讓人感到意外,嚴格說,Java 8 以後,接口也是可以有方法實現的!
從 Java 8 開始,interface 增加了對 default method 的支持。Java 9 以後,甚至可以定義 private default method。Default method 提供了一種二進制兼容的擴展已有接口的辦法。比如,我們熟知的 java.util.Collection,它是collection 體系的 root interface,在 Java 8 中添加了一系列 default method,主要是增加 Lambda、Stream 相關的功能。我在專欄前面提到的類似Collections 之類的工具類,很多方法都適合作爲 default method 實現在基礎接口裏面。
你可以參考下面代碼片段:
public interface Collection<E> extends Iterable<E> {
/**
* Returns a sequential Stream with this collection as its source
* ...
**/
default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}
}
面向對象設計
談到面向對象,很多人就會想起設計模式,那些是非常經典的問題和設計方法的總結。我今天來夯實一下基礎,先來聊聊面向對象設計的基本方面。
我們一定要清楚面向對象的基本要素:封裝、繼承、多態。
- 封裝 的目的是隱藏事務內部的實現細節,以便提高安全性和簡化編程。封裝提供了合理的邊界,避免外部調用者接觸到內部的細節。我們在日常開發中,因爲無意間暴露了細節導致的難纏 bug 太多了,比如在多線程環境暴露內部狀態,導致的併發修改問題。從另外一個角度看,封裝這種隱藏,也提供了簡化的界面,避免太多無意義的細節浪費調用者的精力。
- 繼承 是代碼複用的基礎機制,類似於我們對於馬、白馬、黑馬的歸納總結。但要注意,繼承可以看作是非常緊耦合的一種關係,父類代碼修改,子類行爲也會變動。在實踐中,過度濫用繼承,可能會起到反效果。
- 多態,你可能立即會想到重寫(override)和重載(overload)、向上轉型。簡單說,重寫是父子類中相同名字和參數的方法,不同的實現;重載則是相同名字的方法,但是不同的參數,本質上這些方法簽名是不一樣的,爲了更好說明,請參考下面的樣例代碼:
public int doSomething() {
return 0;
}
// 輸入參數不同,意味着方法簽名不同,重載的體現
public int doSomething(List<String> strs) {
return 0;
}
// return 類型不一樣,編譯不能通過
public short doSomething() {
return 0;
}
這裏你可以思考一個小問題,方法名稱和參數一致,但是返回值不同,這種情況在Java 代碼中算是有效的重載嗎? 答案是不是的,編譯都會出錯的。
面向對象開發的5個基本原則
進行面向對象編程,掌握基本的設計原則是必須的,我今天介紹最通用的面向對象開發的5個基本原則,也就是所謂的 S.O.L.I.D 原則。
- 單一職責(Single Responsibility),類或者對象最好是隻有單一職責,在程序設計中如果發現某個類承擔着多種義務,可以考慮進行拆分。
- 開閉原則(Open-Close, Open for extension, close for modification),設計要對擴展開放,對修改關閉。換句話說,程序設計應保證平滑的擴展性,儘量避免因爲新增同類功能而修改已有實現,這樣可以少產出些迴歸(regression)問題。
- 里氏替換(Liskov Substitution),這是面向對象的基本要素之一,進行繼承關係抽象時,凡是可以用父類或者基類的地方,都可以用子類替換。
- 接口分離(Interface Segregation),我們在進行類和接口設計時,如果在一個接口裏定義了太多方法,其子類很可能面臨兩難,就是隻有部分方法對它是有意義的,這就破壞了程序的內聚性。對於這種情況,可以通過拆分成功能單一的多個接口,將行爲進行解耦。在未來維護中,如果某個接口設計有變,不會對使用其他接口的子類構成影響。
- 依賴反轉(Dependency Inversion),實體應該依賴於抽象而不是實現。也就是說高層次模塊,不應該依賴於低層次模塊,而是應該基於抽象。實踐這一原則是保證產品代碼之間適當耦合度的法寶。
OOP 原則實踐中的取捨
值得注意的是,現代語言的發展,很多時候並不是完全遵守前面的原則的,比如,Java 10 中引入了本地方法類型推斷和 var 類型。按照,里氏替換原則,我們通常這樣定義變量:
List<String> list = new ArrayList<>();
如果使用 var 類型,可以簡化爲
var list = new ArrayList<String>();
但是,list 實際會被推斷爲“ArrayList < String >”
ArrayList<String> list = new ArrayList<String>();
理論上,這種語法上的便利,其實是增強了程序對實現的依賴,但是微小的類型泄漏卻帶來了書寫的便利和代碼可讀性的提高,所以,實踐中我們還是要按照得失利弊進行選擇,而不是一味得遵循原則。
OOP 原則在面試題目中的分析
我在以往面試中發現,即使是有多年編程經驗的工程師,也還沒有真正掌握面向對象設計的基本的原則,如開關原則(Open-Close)。看看下面這段代碼,改編自朋友圈盛傳的某偉大公司產品代碼,你覺得可以利用面向對象設計原則如何改進?
public class VIPCenter {
void serviceVIP(T extend User user>) {
if (user instanceof SlumDogVIP) {
// 窮 X VIP,活動搶的那種
// do somthing
} else if(user instanceof RealVIP) {
// do somthing
}
// ...
}
這段代碼的一個問題是,業務邏輯集中在一起,當出現新的用戶類型時,比如,大數據發現了我們是肥羊,需要去收穫一下, 這就需要直接去修改服務方法代碼實現,這可能會意外影響不相關的某個用戶類型邏輯。
利用開關原則,我們可以嘗試改造爲下面的代碼:
public class VIPCenter {
private Map<User.TYPE, ServiceProvider> providers;
void serviceVIP(T extend User user) {
providers.get(user.getType()).service(user);
}
}
interface ServiceProvider{
void service(T extend User user) ;
}
class SlumDogVIPServiceProvider implements ServiceProvider{
void service(T extend User user){
// do somthing
}
}
class RealVIPServiceProvider implements ServiceProvider{
void service(T extend User user) {
// do something
}
}
上面的示例,將不同對象分類的服務方法進行抽象,把業務邏輯的緊耦合關係拆開,實現代碼的隔離保證了方便的擴展。
今天我對 Java 面向對象技術進行了梳理,對比了抽象類和接口,分析了 Java 語言在接口層面的演進和相應程序設計實現,最後回顧並實踐了面向對象設計的基本原則,希望對你有所幫助。