在軟體開發領域,這個以其多樣化和強烈持有觀點而聞名的領域,很少有實踐能夠像 SOLID 原則那樣達成共識,作為成為更好的軟體工程師的保證途徑。
Robert C. Martin 在 2000 年代初期正式製定的 5 條黃金法則極大地影響了軟體開發產業,並為更好的程式碼品質和決策過程製定了新標準,至今仍具有相關性。
SOLID 原則是專門為支援 OOP(物件導向程式設計)範例而設計的。因此,本文是為希望提高開發技能並編寫更優雅、可維護和可擴展程式碼的 OOP 開發人員而設計的。
這裡使用的語言是 TypeScript,遵循常見的跨語言 OOP 概念。需要基本的 OOP 知識。
單一職責原則 (SRP) 是五個 SOLID 原則之一,它規定每個類別應該只有一個職責,以保持有意義的關注點分離。
此模式是一種稱為「上帝物件」的常見反模式的解決方案,「上帝物件」只是指承擔太多職責的類別或物件,使其難以理解、測試和維護。
遵循 SRP 規則有助於使程式碼元件可重複使用、鬆散耦合且易於理解。讓我們探討這項原則,展示 SRP 違規情況和解決方案。
enum Color {
BLUE = 'blue',
GREEN = 'green',
RED = 'red'
}
enum Size {
SMALL = 'small',
MEDIUM = 'medium',
LARGE = 'large'
}
class Product {
private _name: string;
private _color: Color;
private _size: Size;
constructor (name: string, color: Color, size: Size) {
this._name = name;
this._color = color;
this._size = size;
}
public get name(): string { return this._name; }
public get color(): Color { return this._color; }
public get size(): Size { return this._size; }
}
在下面的程式碼中, ProductManager
類別負責products 的建立和存儲,違反了單一職責原則。
class ProductManager {
private _products: Product[] = [];
createProduct (name: string, color: Color, size: Size): Product {
return new Product(name, color, size);
}
storeProduct (product: Product): void {
this._products.push(product);
}
getProducts (): Product[] {
return this._products;
}
}
const productManager: ProductManager = new ProductManager();
const product: Product = productManager.createProduct('Product 1', Color.BLUE, Size.LARGE);
productManager.storeProduct(product);
const allProducts: Product[] = productManager.getProducts();
將產品建立和儲存的處理分離到兩個不同的類別可以減少ProductManager
類別的職責數量。這種方法進一步模組化了程式碼並使其更易於維護。
class ProductManager {
createProduct (name: string, color: Color, size: Size): Product {
return new Product(name, color, size);
}
}
class ProductStorage {
private _products: Product[] = [];
storeProduct (product: Product): void {
this._products.push(product);
}
getProducts (): Product[] {
return this._products;
}
}
const productManager: ProductManager = new ProductManager();
const productStorage: ProductStorage = new ProductStorage();
const product: Product = productManager.createProduct("Product 1", Color.BLUE, Size.LARGE);
productStorage.storeProduct(product);
const allProducts: Product[] = productStorage.getProducts();
“軟體實體應該對擴展開放,但對修改關閉”
開閉原則 (OCP) 是「寫一次,寫得夠好以便可擴展,然後就忘記它」。
這項原則的重要性取決於這樣一個事實:模組可能會根據新的需求不時發生變化。如果在模組編寫、測試並上傳到生產環境後出現新需求,則修改此模組通常是不好的做法,尤其是當其他模組依賴它時。為了防止這種情況,我們可以使用開閉原則。
enum Color {
BLUE = 'blue',
GREEN = 'green',
RED = 'red'
}
enum Size {
SMALL = 'small',
MEDIUM = 'medium',
LARGE = 'large'
}
class Product {
private _name: string;
private _color: Color;
private _size: Size;
constructor (name: string, color: Color, size: Size) {
this._name = name;
this._color = color;
this._size = size;
}
public get name(): string { return this._name; }
public get color(): Color { return this._color; }
public get size(): Size { return this._size; }
}
class Inventory {
private _products: Product[] = [];
public add(product: Product): void {
this._products.push(product);
}
addArray(products: Product[]) {
for (const product of products) {
this.add(product);
}
}
public get products(): Product[] {
return this._products;
}
}
讓我們描述一個實作產品過濾類別的場景。讓我們加入按顏色過濾產品的功能。
class ProductsFilter {
byColor(inventory: Inventory, color: Color): Product[] {
return inventory.products.filter(p => p.color === color);
}
}
我們已經測試了此程式碼並將其部署到生產中。
幾天后,客戶請求一項新功能 - 也按大小過濾。然後我們修改該類別以支援新的要求。
現在違反了開閉原則!
class ProductsFilter {
byColor(inventory: Inventory, color: Color): Product[] {
return inventory.products.filter(p => p.color === color);
}
bySize(inventory: Inventory, size: Size): Product[] {
return inventory.products.filter(p => p.size === size);
}
}
在不違反 OCP 的情況下實現過濾機制的正確方法應該使用「規範」類別。
abstract class Specification {
public abstract isValid(product: Product): boolean;
}
class ColorSpecification extends Specification {
private _color: Color;
constructor (color) {
super();
this._color = color;
}
public isValid(product: Product): boolean {
return product.color === this._color;
}
}
class SizeSpecification extends Specification {
private _size: Size;
constructor (size) {
super();
this._size = size;
}
public isValid(product: Product): boolean {
return product.size === this._size;
}
}
// A robust mechanism to allow different combinations of specifications
class AndSpecification extends Specification {
private _specifications: Specification[];
// "...rest" operator, groups the arguments into an array
constructor ((...specifications): Specification[]) {
super();
this._specifications = specifications;
}
public isValid (product: Product): boolean {
return this._specifications.every(specification => specification.isValid(product));
}
}
class ProductsFilter {
public filter (inventory: Inventory, specification: Specification): Product[] {
return inventory.products.filter(product => specification.isValid(product));
}
}
const p1: Product = new Product('Apple', Color.GREEN, Size.LARGE);
const p2: Product = new Product('Pear', Color.GREEN, Size.LARGE);
const p3: Product = new Product('Grapes', Color.GREEN, Size.SMALL);
const p4: Product = new Product('Blueberries', Color.BLUE, Size.LARGE);
const p5: Product = new Product('Watermelon', Color.RED, Size.LARGE);
const inventory: Inventory = new Inventory();
inventory.addArray([p1, p2, p3, p4, p5]);
const greenColorSpec: ColorSpecification = new ColorSpecification(Color.GREEN);
const largeSizeSpec: SizeSpecification = new SizeSpecification(Size.LARGE);
const andSpec: AndSpecification = new AndSpecification(greenColorSpec, largeSizeSpec);
const productsFilter: ProductsFilter = new ProductsFilter();
const filteredProducts: Product[] = productsFilter.filter(inventory, andSpec); // All large green products
過濾機制現在是完全可擴展的。現有的類別不應該再被修改。
如果有新的過濾要求,我們只需建立一個新規範即可。或者如果需要更改規範組合,可以透過使用AndSpecification
類別輕鬆完成。
里氏替換原則(LSP)是軟體元件靈活性和穩健性的重要規則。它由 Barbara Liskov 提出,並成為 SOLID 原則的基本要素。
LSP 規定超類別的物件應該可以用子類別的物件替換,而不影響程式的正確性。換句話說,子類別應該擴展超類別的行為而不改變其原始功能。採用這種方法可以提高軟體元件的質量,確保可重複使用性並減少意外的副作用。
下面的範例說明了違反里氏替換原則 (LSP) 的場景。當Rectangle
物件被Square
物件取代時,透過檢查程序的行為可以觀察到這種違規的跡象。
class Rectangle {
protected _width: number;
protected _height: number;
constructor (width: number, height: number) {
this._width = width;
this._height = height;
}
get width (): number { return this._width; }
get height (): number { return this._height; }
set width (width: number) { this._width = width; }
set height (height: number) { this._height = height; }
getArea (): number {
return this._width * this._height;
}
}
// A square is also rectangle
class Square extends Rectangle {
get width (): number { return this._width; }
get height (): number { return this._height; }
set height (height: number) {
this._height = this._width = height; // Changing both width & height
}
set width (width: number) {
this._width = this._height = width; // Changing both width & height
}
}
function increaseRectangleWidth(rectangle: Rectangle, byAmount: number) {
rectangle.width += byAmount;
}
const rectangle: Rectangle = new Rectangle(5, 5);
const square: Square = new Square(5, 5);
console.log(rectangle.getArea()); // Expected: 25, Got: 25 (V)
console.log(square.getArea()); // Expected: 25, Got: 25 (V)
// LSP Violation Indication: Can't replace object 'rectangle' (superclass) with 'square' (subclass) since the results would be different.
increaseRectangleWidth(rectangle, 5);
increaseRectangleWidth(square, 5);
console.log(rectangle.getArea()); // Expected: 50, Got: 50 (V)
// LSP Violation, increaseRectangleWidth() changed both width and height of the square, unexpected behavior.
console.log(square.getArea()); //Expected: 50, Got: 100 (X)
重構的程式碼現在遵循 LSP,確保超類別Shape
的物件可以替換為子類別Rectangle
和Square
的物件,而不會影響計算面積的正確性,也不會引入任何改變程式行為的不必要的副作用。
abstract class Shape {
public abstract getArea(): number;
}
class Rectangle extends Shape {
private _width: number;
private _height: number;
constructor (width: number, height: number) {
super();
this._width = width;
this._height = height;
}
getArea (): number { return this._width * this._height; }
}
class Square extends Shape {
private _side: number;
constructor (side: number) {
super();
this._side = side;
}
getArea (): number { return this._side * this._side; }
}
function displayArea (shape: Shape): void {
console.log(shape.getArea());
}
const rectangle: Rectangle = new Rectangle(5, 10);
const square: Square = new Square(5);
// The rectangle's area is correctly calculated
displayArea(rectangle); // Expected: 50, Got: 50 (V)
// The square's area is correctly calculated
displayArea(square); // Expected: 25, Got: 25 (V)
介面隔離原則 (ISP) 強調建立特定於客戶端的介面而不是一刀切的重要性。
這種方法根據客戶的需求集中類,消除了類別必須實現它實際上不使用或不需要的方法的情況。
透過應用介面隔離原則,軟體系統可以以更靈活、易於理解和易於重構的方式建構。讓我們來看一個例子。
這裡違反了 ISP 規則,因為Robot
必須實現完全沒有必要的eat()
函數。
interface Worker {
work(): void;
eat(): void;
}
class Developer implements Worker {
public work(): void {
console.log('Coding..');
}
public eat(): void {
console.log('Eating..');
}
}
class Robot implements Worker {
public work(): void {
console.log('Building a car..');
}
// ISP Violation: Robot is forced to implement this function even when unnecessary
public eat(): void {
throw new Error('Cannot eat!');
}
}
下面的範例代表了我們之前遇到的問題的解決方案。現在,介面更加簡潔且更加特定於客戶端,允許客戶端類別僅實現與其相關的方法。
interface Workable {
work(): void;
}
interface Eatable {
eat(): void;
}
class Developer implements Workable, Eatable {
public work(): void {
console.log('Coding..');
}
public eat(): void {
console.log('Eating...');
}
}
class Robot implements Workable {
public work(): void {
console.log('Building a car..');
}
// No need to implement eat(), adhering ISP.
}
依賴倒置原則(DIP)是最終的SOLID原則,重點是透過使用抽象來減少低層模組(例如資料讀取/寫入)和高層模組(執行關鍵操作)之間的耦合。
DIP 對於設計能夠適應變化、模組化且易於更新的軟體至關重要。
DIP 的關鍵準則是:
高層模組不應該依賴低層模組。兩者都應該依賴抽象。這意味著應用程式的功能不應該依賴特定的實現,以便使系統更加靈活並且更容易更新或替換低階實現。
抽像不應該依賴細節。細節應該取決於抽象。這鼓勵設計專注於實際需要什麼操作,而不是如何實現這些操作。
讓我們來看一個展示依賴倒置原則 (DIP) 違規的範例。
MessageProcessor
(高階模組)緊密耦合並直接依賴FileLogger
(低階模組),違反了原則,因為它不依賴抽象層,而是依賴特定的類別實作。
額外獎勵:這也違反了開閉原則(OCP)。如果我們想要更改日誌記錄機制以寫入資料庫而不是文件,我們將被迫直接修改MessageProcessor
函數。
import fs from 'fs';
// Low Level Module
class FileLogger {
logMessage(message: string): void {
fs.writeFileSync('somefile.txt', message);
}
}
// High Level Module
class MessageProcessor {
// DIP Violation: This high-level module is is tightly coupled with the low-level module (FileLogger), making the system less flexible and harder to maintain or extend.
private logger = new FileLogger();
processMessage(message: string): void {
this.logger.logMessage(message);
}
}
以下重構的程式碼表示為了遵守依賴倒置原則 (DIP) 所需進行的變更。與前面的範例相反,高階類別MessageProcessor
持有特定低階類別FileLogger
的私有屬性,現在它持有Logger
類型的私有屬性(表示抽象層的介面)。
這種更好的方法減少了類別之間的依賴關係,從而使程式碼更具可擴展性和可維護性。
import fs from 'fs';
// Abstraction Layer
interface Logger {
logMessage(message: string): void;
}
// Low Level Module #1
class FileLogger implements Logger {
logMessage(message: string): void {
fs.writeFileSync('somefile.txt', message);
}
}
// Low Level Module #2
class ConsoleLogger implements Logger {
logMessage(message: string): void {
console.log(message);
}
}
// High Level Module
class MessageProcessor {
// Resolved: The high level module is now loosely coupled with the low level logger modules.
private _logger: Logger;
constructor (logger: Logger) {
this._logger = logger;
}
processMessage (message: string): void {
this._logger.logMessage(message);
}
}
const fileLogger = new FileLogger();
const consoleLogger = new ConsoleLogger();
// Now the logging mechanism can be easily replaced
const messageProcessor = new MessageProcessor(consoleLogger);
messageProcessor.processMessage('Hello');
透過遵循 SOLID 原則,開發人員在開發或維護任何規模的軟體系統時,可以避免常見的陷阱,例如緊密耦合、缺乏靈活性、程式碼可重複使用性差以及一般維護困難。掌握這些原則是成為更好的軟體工程師的又一步。
原文出處:https://dev.to/idanref/solid-the-5-golden-rules-to-level-up-your-coding-skills-2p82