設計模式:面向對象的基礎知識

語言: CN / TW / HK

主流的編程範式有三種:面向過程、面向對象和函數式編程,我們現在使用的主流編程語言 C# 或 Java,都是面嚮對象語言,所以常常説的設計模式也是在面嚮對象語言這個前提之下。

面向對象的基礎知識和一些設計原則,我認為是學習設計模式的基礎,本文就聊下這些基礎知識。

在面試時,一問到面向對象,幾乎每個人都能脱口而出:封裝、繼承、多態。但大部分只能説出一個簡單的概念,而多態還有很多連概念都説不清楚。我們學習面向對象,不止需要了解概念,更需要知道每個特性存在的意義和目的。

對於面向對象的特性,面向對象的語言都會給出相應的支持,不同語言可能會有細微差別,下面的示例以 C# 語言為主。

封裝

我們先來思考下,平時寫代碼時有哪些是屬於封裝,是不是會有下面的一些場景:

1、將一些屬性字段放到一個類中;

2、將一些方法放到一個類中

3、將某些類組織到某個特定的命名空間下。

在 C# 9.0 版本中還提供了屬性的 init 特性,可以更方便地提供封裝性:

public class UserInfo
{
public string Name { get; init; }
}
UserInfo user = new UserInfo { Name = "oec2003" };
//當 user 初始化完了之後就不能再改變 Name 的值
user.Name = "oec2004";

除了屬性、方法和類也有對應的訪問修飾符,這些訪問修飾符的靈活運用就達到了封裝的目的,用來隱藏信息或進行數據的保護。

試想一下,如果我們對類中屬性或方法全部都使用 public ,調用方可以任意修改屬性和調用方法,這樣會使代碼變得不可控,屬性可能被很多地方以不同的方式進行修改,代碼難以維護。而且不熟悉業務的開發人員如果隨意改動了一些關鍵屬性,可能引發嚴重的問題。

從另一個方面來説,類的共有屬性和方法暴露的越多,對於調用者來説就會越複雜,越容易出現問題,合理地進行封裝,可以提高可讀性、可維護性,減少出錯。

這時,你是不是可以想想,平時寫代碼時,屬性、方法、類是不是都不假思索地寫上了 public 了呢?

繼承

目前面向對象的語言基本都支持繼承特性,只是語法上有些細微的差別,比如 C# 語言是使用冒號,Java 語言使用 extends 關鍵字。但都是表示 is-a 的關係。

在 C# 中一個類可以繼承多個接口,但只能繼承一個父類,我們通常説的 C# 只支持單繼承指的就是 C# 只能繼承一個父類,但在 C++ 、Python 等語言中類是可以繼承多個父類的。

我們經常會跟開發人員講,不要到處複製代碼,代碼要做到能夠複用,發現同一個邏輯在兩個不同的類中的時候,可以抽象出來一個父類,讓這兩個類都繼承這個父類。這個思路沒有問題,也確實能解決我們的實際問題,提升代碼的複用性。

但隨着功能的增加,我們需要對類的屬性和方法進行擴展,會發現需要新添加的屬性或方法放在父類或子類都不合適,只能繼續進行抽象,長此下去,繼承關係會變得非常複雜,變得難以維護。有條設計原則是這麼説的:組合優於繼承,其實就是為了解決這個問題。

組合和繼承的選擇是一種權衡,當涉及的類經常變化可能導致繼承層級向着複雜化演化時,需要考慮採用組合的方式,如果相關類比較穩定,繼承層級不深(一般不超過 3 層),就可以放心使用繼承。

在具體的模式中,組合模式、策略模式等就是使用組合的方式實現,模板模式使用的是繼承方式實現。

多態

多態的字面意思就是同樣的一個語法調用,能夠表達多個不同的意思。如果説繼承的最大好處是複用,那麼多態的好處就是方便擴展。

在 C# 語言中兩個比較典型的多態場景就是方法的重寫和方法的重載:

  • 重寫:存在繼承關係的類或接口,在子類中對父類的方法進行重新構建邏輯,但調用方法、參數、返回值保持一致,通常有下面幾種情況:

    • 普通的父類中有用 virtual 關鍵字標識的虛方法,在子類中使用 override 關鍵字進行重寫;

    • 子類對抽象類的抽象方法進行重寫;

    • 子類對接口中的方法進行實現。

  • 重載:類中的多個方法,方法名相同,但參數個數或類型不相同,稱之為重載方法。例如 C# 中的 File 類的 Open 方法就有三個重載,如下圖:

方法的重寫,在實際應用中非常常見,比如零代碼平台中的消息組件會有多種發送消息的方式,下面用一個示例代碼演示下:

public interface IMessage
{
void Send(string msg);
}

public class EmailMessage : IMessage
{
public void Send(string msg)
{
Console.WriteLine($"send email message {msg}");
}
}
public class WechatMessage : IMessage
{
public void Send(string msg)
{
Console.WriteLine($"send wechat message {msg}");
}
}
class Program
{
static void Main(string[] args)
{
List<IMessage> messageList = new List<IMessage>();
messageList.Add(new EmailMessage());
messageList.Add(new WechatMessage());

messageList.ForEach(s=>s.Send("test message"));
}
}

為什麼説能提高擴展性呢?如果這時消息組件需要擴展發送短信的消息種類,只需要編寫短信類型的消息類實現 IMessage 接口的 Send 方法即可。

還有一種場景,比如登陸的時候,有基於用户名密碼的認證、企業微信的認證、釘釘的認證、和對接第三方的認證,又應該怎麼設計呢?

我們雖然都在使用着面向對象的語言,但很多的時候思維還是面向過程的,具體體現在:

  • 實體類的屬性直接定義為 public ,set 和 get 都安排上,外部可以任意獲取和賦值,很多時候使用代碼生成工具直接生成實體類,默認的 set 和 get 都是 public ,也沒有依據具體的業務進行修改,嚴重破壞了封裝特性;

  • 數據和行為的分離,也就是所謂的貧血模式,但真正的對象是數據和行為在一起的,我們可能每天都在寫這樣的代碼,一種面向過程式的代碼;

  • 為了代碼複用,代碼中會存在大量的 Helper 類或者 Utils、Common 類,這些類通常是靜態類,裏面有各種各樣的靜態方法,在往裏面添加方法時需要思考下,真的必須放到這裏嗎?這種類隨着時間的推移很容易變成巨型類,變得難以維護;

  • 按照功能驅動,比如頁面上的一個按鈕操作,對應了一個 API 接口,不管你的代碼是如何設計和分層,都是一層層往下直到數據庫訪問。

所以不要以為使用了面向對象的語言就是在使用面向對象編程,重要的是抽象的思維,這種抽象需要我們去思考,去全盤考慮,相比較面向過程顯得更難,所以懶惰的程序員更容易寫出面向過程的代碼。

面向對象的基礎知識是學習設計模式的根基,掌握基礎知識,然後願意去思考,總結才能夠學習好設計模式,並將其應用到實際的工作中。下一篇將介紹面向對象中的常用設計原則,設計模式也都是基於這些設計原則演化而來。

希望本文對您有所幫助!