物件導向:繼承的特性
繼承是一種 提高程式碼的可用性,並提高系統的可擴充性的有效手段,而組合也達到相同效果(之後會拿「繼承」與「組合」作比較),然而繼承是完全沒有區點的嘛?是否有副作用?… 就是接下來要探討的重點
繼承的弱點
● 繼承雖然可以達成高效的覆用,而我們換到物件導向的想法,物件導向同時也注重於封裝,封裝細節,不讓其類它關注其細節
而繼承則弱點正在於封裝,繼承它打破了封裝的規則,讓子類參與到開發的細節,同時自身也會影響到子類的實現!
● 這種子類、父類的關係就是「緊耦合」關係,之所以是「緊」的原因在於它們的關係建立在程式語言的語法之上,並且連接在編譯期間!
● 繼承的弱點 1:子類的實現會影響父類
abstract class Shop {
abstract fun isShopOpen(): Boolean
fun buy() {
// 子類的實現會有關細到父類的行為
if (!isShopOpen()) {
throw Exception("Shop not open")
}
// do something
}
}
class BookShop: Shop() {
override fun isShopOpen(): Boolean {
return false
}
}
● 繼承的弱點 2:父類的改變會影響子類;最明顯的就是父類別的方法改變、拓展,子類必須被迫實現
繼承的缺點
● 繼承是一「強制性」擴充:強制其子類必須繼承父類所有的方法、屬性(這也包括了私有),最終可能導致子類必須實現它不需要的方法
abstract class Firmware {
abstract fun getHardwareVersion(): String
abstract fun getFirmwareVersion(): String
abstract fun update(): Boolean
}
class Motor: Firmware() {
override fun getHardwareVersion(): String {
return "1.1.0"
}
override fun getFirmwareVersion(): String {
return "2.0.13"
}
override fun update(): Boolean {
println("Update Motor")
return true
}
}
class LED: Firmware() {
override fun getHardwareVersion(): String {
return "1.0.0"
}
override fun getFirmwareVersion(): String {
return "1.1.1"
}
// 非必要的方法!
override fun update(): Boolean {
throw UnsupportedOperationException()
}
}
● 如果維護者不清晰了解父類,那在拓展時會增加多型的不穩定:
由於子類繼承後,可以覆寫(Override
)父類的方法,這是多型的特徵,但假 如子類覆寫時曲解了方法的意義,可能導致不安全性的發生
abstract class Shop {
// 原本的意義是只准反為 Boolean 判斷
abstract fun isShopOpen(): Boolean
fun buy() {
if (!isShopOpen()) {
throw Exception("Shop not open")
}
// do something
}
}
class BookShop: Shop() {
// 而子類卻曲解其意義,改為拋出!
override fun isShopOpen(): Boolean {
throw Exception("Book shop not open.")
}
}
繼承的使用原則
最基礎的就是開發文檔要寫清楚,並且說明實做該方法會牽連影響到哪些方法,最終可能導致哪些結果;當然除了文檔之外,我們也可以透過一些規則來實做繼承
層級限制
● 繼承的層級最好做適當的管控,否則容易造成類別拓展的負擔,並也降低程式的理解性、可讀性… 建議:繼承層次應該保持在不超過 3 層 的架構(不考慮到 Object
類)
// kotlin
// 第一層
abstract class Food {
}
// 第二層
abstract class Fruit: Food {
}
// 第三層(最多)
class Apple: Fruit {
}
界面 & 繼承 的宣告
● 如果有界面(interface
)的實做 & 繼承(abstract class
):我們應該 盡可能的使用界面類作為宣告,而非使用抽象類;
這是因為抽象類所代表的責任、含義會比介面更大(因為抽象類是介於介面、實體類的中間,它往往也承擔了部分的細節做法);概念程式如下…
● 介面 & 繼承的相關程式
// kotlin 範例
interface IEatAction {
fun eat();
}
abstract class Fruit: Food, IEatAction {
override fun eat() {
println("Eat fruit");
}
}
class Apple: Fruit {
}
● 我們這裡建議使用界面作為宣告,來降低對於實作細節的依賴!
fun main() {
// 應該使用「界面」作為宣告 (使用這個更好!)
val apple: IEatAction = Apple();
// 使用 抽象 作為宣告,會與實體的細節產生更多切合面的接觸點
val apple2: Fruit = Apple();
}
有規劃的設計父類
● 盡量在父類完成共有的方法,為子類提供一系列預設的實做(這也提高了程式碼的重用性);而實際上通常並非這麽順利,通常有需要子類實現的方法,如下︰
● 父類完成方法:某些方法適用於所有的子類(同常是邏輯),那就可以在父類完成該方法
abstract class LoadFile {
abstract fun load(): File
fun getContent(): String {
val file = load()
val reader = file.reader()
return reader.use {
it.readText().run {
this.ifEmpty {
"The file is empty"
}
}
}
}
}
● 子類完成方法:某些方法的實做取決於各個子類別的特定屬性、實做細節
class LoadDiskFile: LoadFile() {
override fun load(): File {
return File("file://....")
}
}
class LoadWebFile: LoadFile() {
override fun load(): File {
return File(URI("www.google.tw"))
}
}
● 盡量的管控,不要讓子類去複寫父類已經完成的方法(也就是建議父類已經完成的方法使用
final
宣告)
● 基於 盡量在父類完成共有的方法 的原則,我們在設計父類別時 可以使用一些技巧來將方法限制在父類別中
A. 使用 private
修飾共同的邏輯方法,不讓子類別去訪問
abstract class MyClass {
private fun commonLogic() {
// do nothing
}
}
B. 使用 final
來描述共同的方法,不讓子類別去覆寫(Override)
abstract class MyClass {
final fun sayHello() {
// do nothing
}
}
C. 父類別不應該在建構函數(Consturctor
)呼叫子類別實現的方法,否則可能造成各種狀況的崩潰 (Crash
)
下面範例可能會造成的問題是:JVM 在建構時會先建構基類(也就是父類別),這會導致子類別尚未建立完成就被呼叫,即可能造成 Crush
abstract class BaseMessageStore {
constructor() {
getMessage()
}
abstract fun getMessage(): String
}
class MessageStore: BaseMessageStore() {
override fun getMessage(): String {
return "HelloWorld"
}
}
繼承的考量
由上面我們可以知道繼承是一種有代價(而且不低)的行為,那我們甚麽時候該用繼承呢?
濫用繼承
● 先來看一個繼承的濫用案例
abstract class Clothe {
abstract fun color(): String
}
class GreenClothe: Clothe() {
override fun color(): String {
return "Green"
}
}
class RedClothe: Clothe() {
override fun color(): String {
return "Red"
}
}
class WhiteClothe: Clothe() {
override fun color(): String {
return "White"
}
}
這個設計 沒有詳加考慮到業務邏輯關係,僅因為讓子類別來定義簡易屬性,而使用繼承;
它除了類別名稱不同之外,屬性、行為接相同,這等同於 濫用繼承,這猶如殺雞用牛刀,完全沒有必要
● 繼承必須要父類、子類參與邏輯關係,其中也許可有不同的屬性、私有行為
繼承與組合的比較
在軟體的開發階段,會經過幾個時期
● 早期階段:早期是 創建 階段,從基礎簡單的呈現出符合業務邏輯的,在經歷整體類別到區域類別的分解(抽出重複部份),從 子類到到父類別的抽象過程
● 後期階段:後期階段基本上是進行 維護,維護已經創建的抽象父類別,如果這時要擴充,就需要進行區域類別的繼承、組合
早期:組合的分解 & 繼承的抽象
● 組合關係的分解過程,對應繼承關係的抽象過程:
接著我們用同一個案例但不同手段(繼承、組合)來完成目的;當我們收到業務之後,需要從 0 建構出一個類,這個過程是 實體到抽象
假設我們有個新需求,要創建兩種類型的 Licence 解析,分別是 JWT、RSA 的 License 驗證
● 繼承關係的抽象過程(繼承的抽象):會經過兩個過程實做、抽象化
A. 從實做分析:寫出各個實做的細節
class RSALicense {
fun parserLicenseContent(content: String): Boolean {
if (content.isNotEmpty()) {
return false
}
return content.startsWith("RSA")
}
}
class JWTLicense {
fun parserLicenseContent(content: String): Boolean {
if (content.isNotEmpty()) {
return false
}
return content.contains("JWT")
}
}
B. 抽象化:分析實做相關性並抽象化
abstract class LicenseCheck {
protected abstract fun parserLicenseContent(content: String): Boolean
fun check(content: String): Boolean {
if (content.isNotEmpty()) {
return false
}
return parserLicenseFile(content)
}
}
● 組合關係的分解過程(組合的分解):將相同之處進行分解,拆分到另一類,並且重新組合;
● 抽出相同方法,透過界面抽象化:其中相同之處就在於 parserLicenseFile
方法
這裡透過界面(
interface
)抽象化,讓組合類具有拓展性
interface IParser {
fun parserLicenseContent(content: String): Boolean
}
class RSAParserLicenseContent constructor(val content: String): IParser {
override fun parserLicenseContent(content: String): Boolean {
return content.startsWith("RSA")
}
}
class JWTParserLicenseContent constructor(val content: String): IParser {
override fun parserLicenseContent(content: String): Boolean {
return content.contains("JWT")
}
}
● 使用組合方式將相關處理類組合並使用
// 組合 IParser 抽象方法
class LicenseCheckComponent constructor(val parser: IParser) {
fun check(content: String): Boolean {
if (content.isNotEmpty()) {
return false
}
return parser.parserLicenseContent(content)
}
}
後期:組合的組合 & 繼承的擴充
● 組合關係的「組合過程」,對應繼承關係的「擴充過程」:
接著我們用同一個案例但不同手段(繼承、組合)來完成相同的目的;以覆用、拓展為目的,並以維護為目的
// 已達成的類如下
interface ICheck {
fun check(content: String): Boolean
}
abstract class LicenseCheck: ICheck {
protected abstract fun parserLicenseContent(content: String): Boolean
override fun check(content: String): Boolean {
if (content.isNotEmpty()) {
return false
}
return parserLicenseContent(content)
}
}
// 使用 open 描述,讓其可被繼承
open class RSALicense: LicenseCheck() {
override fun parserLicenseContent(content: String): Boolean {
return content.startsWith("RSA")
}
}
// 使用 open 描述,讓其可被繼承
open class JWTLicense: LicenseCheck() {
override fun parserLicenseContent(content: String): Boolean {
return content.contains("JWT")
}
}
假設我們在一個已經成熟的專案中,有一個拓展的新需求;而已達成的類如下描述,要透過以下類進行拓展…
● 繼承關係的擴充過程:
● 透過繼承,拓展出新的功能:覆用父類已完成的方法,並加上自身的業務邏輯
class RSALicence128: RSALicense() {
override fun parserLicenseContent(content: String): Boolean {
return super.parserLicenseContent(content)
// 加上自身的業務邏輯
&& content.length == 128
}
}
class JWTLicence256: JWTLicense() {
override fun parserLicenseContent(content: String): Boolean {
return super.parserLicenseContent(content)
// 加上自身的業務邏輯
&& content.length == 256
}
}
● 這種繼承關係,會引入與父類別的強關聯關係,也就是子類必須相當了解父類別實現的方法
● 組合關係的組合過程:
● 透過組合,拓展出新的功能:將需求透過「新建立的類」個別建立,並 完成部份業務需求
class Licence128: LicenseCheck() {
override fun parserLicenseContent(content: String): Boolean {
return content.length == 128
}
}
class Licence256: JWTLicense() {
override fun parserLicenseContent(content: String): Boolean {
return content.length == 256
}
}
組合新建立的類(須拓展的新方法)、已有的類(舊有的功能),來完整達成需求
class RSALicence128_2 constructor(val l128: Licence128, val rsa: RSALicense): ICheck {
override fun check(content: String): Boolean {
return l128.check(content) && rsa.check(content)
}
}
class JWTLicence256_2 constructor(val l285: Licence256, val jwt: JWTLicense): ICheck {
override fun check(content: String): Boolean {
return l285.check(content) && jwt.check(content)
}
}
● 以拓展、組合的方式去建立新的功能,可以保證舊有的類不受到影響,並且也符合類的設計原則 「開閉原則 Open Close Principle」
繼承 vs. 組合的結論
● 接著,我們說說繼承的「特徵」,謂何說繼承的代價是比較大的?
● 系統的複查度:多層級繼承會使的系統變得複雜,不易維護
組合相對比起來複雜度低,容易做插拔替換
● 靜態繼承關係:運行時會被迫接受父類的所有特徵(包括私有方法、成員),並且 無法改變父類(必須一直在父類環境下運行)
組合則不會有這種困擾,允許替換功能的環境
● 在 UML 中的關聯關係、聚合關係可以統一稱為組合,使用組合可以完成跟繼承相同的事情;其中兩個的差異比較如下表
\ | 組合關係 | 繼承關係 |
---|---|---|
封裝 | (v) 保有每個類的封裝性,獨立性高 | (x) 破壞封裝性,子類父類都會相互影響 |
擴充性 | (v) 針對不同的細節實做不同的類,擴充性高 | (x) 有擴充性,不過 相對的代價也高、複雜度變高 |
動態性 | (v) 支援動態組合,可以透過 setter 替換不同的實做 | (x) 子類無法改變父類的實做,與父類是強耦合關係 |
可變性 | (v) 基於依賴倒置關係,可以將封裝的方法抽象為界面,實做方只需要依賴所需的界面組合就可以 | (x) 子類強制繼承父類的所有方法、屬性 |
界面的關聯性 | (x) 隔離了界面的,不需要手動獲取界面的方法 | (v) 自動擁有父類的界面 |
建立物件的代價 | (x) 必須手動傳入組合類 | (v) 子類別建立時,同時就建立好父類 |
Java 內部類的不同
Java 的內部類有分為多種,如果使用得當,可以優雅的規劃出類的責任、關聯範疇;我們可以做以下分類來區別一下不同的內部類
A. 實名內部類
分類 | 關鍵 |
---|---|
實體內部類 | 與外部有引用關係,必須先創建外部類才能創建內部類 |
靜態內部類 | 關鍵字 static ,與外部類無明顯關係 |
區域(方法)內部類 | 這種方法內部類較少使用;它不能用 public 、protected 、private 描述 |
B. 變數(匿名)內部類
分類 | 關鍵 |
---|---|
實體變數 | 在創建類後添加 {} 即是一個實體變數;自動有外部引用 |
靜態變數 | 靜態特性的匿名類,沒有外部引用 |
局部(方法)變數 | 在方法內創建匿名類 |
● 內部類通常都會在以下需求中被使用
● 封裝內部(區域)所需資料
● 分開類別後,方便直接存取、呼叫外部類成員(包括 private 成員)
實名內部類
A. 實體內部類
由於與外部類有引用關係,所以內部類創建完後,可以使用外部類的成員
與外部有引用關係,必須先創建外部類才能創建內部類
// java 範例
class OuterClass {
private int outerValue = -1;
class InnerClass {
InnerClass() {
// 直接使用外部類成員
outerValue = 1000;
}
}
public static void main(String[] args) {
OuterClass oc = new OuterClass();
OuterClass.InnerClass ic = oc.new InnerClass();
}
}
B. 靜態內部類
與外部「無」直接依賴慣係(不會自動持有外部類的實體引用參考),可以直接創建物件的實例
// java 範例
class OuterClass {
static class StaticInnerClass {
StaticInnerClass() {
// 非法行為!無法通過編譯
// outerValue = -100;
}
}
public static void main(String[] args) {
OuterClass.StaticInnerClass osi = new StaticInnerClass();
}
}
C. 區域(方法)內部類
僅限於方法內可創建(可見範圍只在方法內)
class OuterClass {
private int outerValue = -1;
int myMethod() {
int testVar = 100;
final int testVar2 = 300;
class DataClass {
final int outsideVar = outerValue;
final int localVar = testVar;
final int localFinalVar = testVar2;
int total() {
return outsideVar + localVar + localFinalVar;
}
}
DataClass dc = new DataClass();
return dc.total();
}
}
匿名內部類
最常見的通常是匿名介面類
A. 實體變數:自動有外部引用
class Message {
String message;
}
class MyClazz {
int value = 100;
Message message = new Message() {
// 匿名類的建構函數
{
message = "Hello anonymous class";
System.out.println("message: " + message + ", value: " + value);
}
};
public static void main(String[] args) {
new MyClazz();
}
}
● 匿名類沒有建構函數,但我們可以在匿名類別中
{ }
符號中撰寫程式,這段程式可作為建構函數呼喚(由 JVM 自動呼叫該區塊)
B. 靜態變數:沒有外部引用
class MyClazz {
int value = 100;
static Message staticMessage = new Message() {
{
message = "Hello anonymous class";
// Error! 無法存取外部成員變數
// System.out.println("message: " + message + ", value: " + value);
// 必須自己創建外部物件
MyClazz mc = new MyClazz();
System.out.println("message: " + message + ", value: " + mc.value);
}
};
}
C. 局部(方法)變數:
void showMessage() {
Message message = new Message() {
{
message = "Hello anonymous class";
}
};
System.out.println("message: " + message.message + ", value: " + value);
}
內部類:Java 編譯後的文件名
● 經過 javac
編譯過後,JVM 對內類別名的規則如下
內部類 | 規則 | 說明 |
---|---|---|
成員內部類 | 外部名$內部名 | 由於有實體名稱,所以使用外部實體名、內部實體名 |
public class ClzName {
static class StaticClz { }
class InnerClz { }
}
● Enum 也算實名內部類!編譯後的規則也如上
內部類 | 規則 | 說明 |
---|---|---|
區域內部類(函數內的類) | 外部名$數字內部類名 | - |
public class ClzName {
void demoFunction() {
class MethodClz { }
class HelloClz { }
}
}
內部類 | 規則 | 說明 |
---|---|---|
匿名內部類 | 外部類$數字 | 由於是匿名類,所以後面沒有類別名稱很正常 |
class MyClz {
}
public class ClzName {
MyClz myClz = new MyClz() { };
void demoFunction() {
MyClz funcMyClz = new MyClz() { };
}
}
更多的 Java 語言相關文章
Java 語言深入
● 在這個系列中,我們全方位地探討了 Java 語言的各個核心主題,旨在幫助你徹底掌握這門強大的編程語言。無論你是想深入理解 Java 的基礎類型與變數作用域,還是探索異常處理與運算子的細節,這些文章都將為您提供寶貴的知識
此外,我們還涵蓋了物件創建、函數式編程、註解應用以及泛型的深入分析,幫助您提升在實際開發中的技能和效率
點擊以下連結,開始你的學習之旅~
● 深入探索 Java 基礎類型、編碼、浮點數、參考類型和變數作用域 | 探討細節
● 深入了解 Java 應用與編譯:從原始檔到命令產出 JavaDoc 文件 | JDK 結構
● 深入理解 Java 運算子與修飾符 | 重要概念、細節 | equals 比較
● 深入探索 Java 物件創建與引用細節:Clone 和finalize 特性,以及強、軟、弱、虛引用
● 認識 Java 函數式編程:從 Lambda 表達式到方法引用 | 3 種方法引用
Java IO 相關文章
● 探索 Java IO 的奧秘,了解檔案操作、流處理、NIO等精彩內容!
深入 Java 多執行緒
● 這一系列文章將帶你深入了解 Java 多執行緒技術的各個方面,從基礎知識到進階應用,涵蓋了多執行緒編程的核心概念與實踐
深入 Java 物件導向
● 探索 Java 物件導向的奧妙,掌握介面、抽象類、繼承等重要概念!幫助你針對物件導向設計有更深入的了解!