代碼重構:類重構的 8 個小技巧

代碼重構:類重構的 8 個小技巧

在大多數 OOP 類型的編程語言和面向對象程序設計中,根據業務建模主要有以下幾個痛點 🤕:

  1. 對象不可能一開始就設計的合理,好用
  2. 起初就算設計精良,但隨着版本迭代,對象的職責也在發生變化
  3. 在迭代中,對象的職責往往會因為承擔過多職責,開始變的臃腫不堪(🙈聞到腐爛的味道了~)

那麼怎麼解決以上的問題?就要運用一些重構的技巧,來讓代碼結構保持整潔,從而讓後續的需求擴展更加穩定

1:合理的分配函數

說明:從 OOP 的角度來考慮,如果函數之間頻繁的調用,顯然適合放在一個對象當中
使用場景:在 A 對象內,看到它經常調用 B 類的函數,那麼你就有必要需要考慮把 B 類的函數搬過來了。

示例一

空說很難理解,我們先展示一段代碼,來展示說項重構的手法:

public class Account {
    // 計算透支費用
    double overdraftCharge() {
        if (_type.isPremium()) {
            double result = 10;
            if (_daysOverdrawn > 7) {
                result += (_daysOverdrawn - 7) * 0.85;
            }
            return result;
        } else {
            return _daysOverdrawn * 1.75;
        }
    }

    double bankCharge() {
        double result = 4.5;
        if (_daysOverdrawn > 0) {
            result += overdraftCharge();
        }
        return result;
    }

    // 編碼道德 758 條:私有變量應該放在類的底部
    private AccountType _type;
    private int _daysOverdrawn;
}

// 賬戶類型
class AccountType {
  //... do something 
}

在上面例子 🌰 中,我們看到 Account 顯然承擔一些不屬於它本身的職責,從 _type.isPremium() 的調用方式來看,overdraftCharge 不論從調用,還是命名來看,都更像是 AccountType 的職責,所以我們嘗試來搬遷它,最終代碼如下:

class AccountType {
    // 從 Account 搬過來了
    double overdraftCharge(Account account) {
        if (isPremium()) {
            double result = 10;
            if (account.getDaysOverdrawn() > 7) {
                result += (account.getDaysOverdrawn() - 7) * 0.85;
            }
            return result;
        } else {
            return account.getDaysOverdrawn() * 1.75;
        }
    }
    // more ...
}

public class Account {
    double bankCharge() {
        double result = 4.5;
        if (_daysOverdrawn > 0) {
            // 還可以根據不同 Account 類型進行擴展
            result += _type.overdraftCharge(this);
        }
        return result;
    }
}

函數 overdraftCharge 搬家後,我們有幾個可見的好處如下:

  1. 可以根據不同 Account 類型,計算不同結果,程序更靈活,調用方無需知道計算細節
  2. 避免類似 _type.isPremium() 的函數調用出現,看上去更合理

總結

通過 示例一 我們可以得出總結:

  1. 如果一個對象有太多行為和另一個對象耦合,那麼就要考慮幫它搬家
  2. 只要是合理的分配函數,就可以使系統結構,對象本身的行為更加合理

2:合理分配字段

說明:這裡的思路和 合理的分配函數 非常的相似,只是主體由 函數 替換為的 字段
使用場景:當 A 類的某一個字段頻繁的被 B 類使用,那麼就要考慮把它搬遷放到 B 類中

示例一

這裡比較簡單,能理解上面的函數分配,也就能理解這裡,我們看一段簡單的示例就好,還是以剛才的 Account 類為例子:

public class Account {
    // 結算日息
    double interestForAmountDays (double amount,int days){
        return _interestRate * amount * days / 365;
    }
    private AccountType _type;
    // 利率 %
    private int _interestRate;
}

// 賬戶類型
class AccountType {
    // do something....
}

從示例上看,_interestRate 字段顯然更適合放在 AccountType,我們做一次字段搬遷,搬完後代碼如下:

public class Account {
    double interestForAmountDays (double amount,int days){
        return _type.getInterestRate() * amount * days / 365;
    }
    private AccountType _type;
}

// 賬戶類型
class AccountType {
    // 利率 %
    private int _interestRate;

    public int getInterestRate() {
        return _interestRate;
    }
}

主要做有 2 個好處如下:

  1. 只需引入 AccountType 即可,無需再重複引入 _interestRate 字段
  2. AccountType 可以根據不同的類型,設置不同的 _interestRate 利率,代碼更靈活

總結

不管是搬遷函數,還是搬遷字段也好,它們都是在不斷重構類的職責和屬性,程序會跟隨需求不斷變化,沒有任何設計是可以保持一成不變的,所以這裡的重構方法,不需要等到特定的時間和特定的規劃再去進行,重構應該是融入在日常開發當中,隨時隨地都在進行的

3:拆解大類

說明:隨着需求越來越多,原來設計的對象承擔的職責也會不斷的增多(方法,屬性等……),如果不加以使用重構的手段來控制對象的邊界(職責,功能),那麼代碼最終就會變得過於複雜,難以閱讀和理解,最終演化成技術債,代碼腐爛,從而導致項目最終的失敗。
使用場景:當一個類變的過於龐大,並且承擔很多不屬於它的職責(通過類名來辨識)的時候,創建新類的分擔它的工作

示例一

這裡的 Person 承擔的過多的職責,我們把不屬於它職責範圍的函數抽離出來,從而保證對象上下文的清晰,拆解過程如下:
Person Class

實際代碼如下:

public class Person {
    
    private String name;
    private String sex;
    private String age;
    private String officeAreaCode;
    private String officeNumber;

    //... 省略 get/set 代碼...
}

Person 的類圖看起來是這樣的:
Person Class
顯然 Person 做了很多不屬於自己的事情(現實情況往往要慘的多),想要分解的 Person 的話,我們可以這樣做:

  1. 識別 Person 的職責,然後創建一個 TelePhoneNumber 對象進行分擔
  2. 將關聯字段和函數遷移到 TelePhoneNumber 類中
  3. 進行單元測試

當我們拆解後,新建的 TelePhoneNumber 類代碼如下:

public class TelePhoneNumber {

    private String officeAreaCode;
    private String officeNumber;

    //... 省略 get/set 代碼...
}

這時候 Person 對象的職責就簡單和清晰很多了,對象結構如下:
Person Class
TelePhoneNumber 對接結構圖如下:
Person Class

總結

拆解大類,是常見的重構技術手段,其最終目的都是保證每個對象的大小,職責都趨向合理。就像我們工作中如果有一個人太忙,那麼就找一個人幫他分擔就好了。

4:合併小類

說明:這裡是和 拆解大類 邏輯完全相反的的技巧
說用場景:如果一個類沒有做太多的事情,就要考慮把它和相似的類合併在一起,這樣做的目的是:

  • 儘可能保證和控制每個類的職責在一個合理的範圍之內
  • 類過大就使用 拆解大類 的手法
  • 類太小就使用 合併小類 的手法

示例一

我們還是用上面的 PersonTelePhoneNumber 類舉例,合併過程如下:

Person Class

上圖可以看到 Person 在本身屬性很少的情況下,又拆分了 TelePhoneNumber 類,這屬於典型的過度拆分了。就需要使用合手法,將散亂在各地臨散的類進行合併。代碼如下:

class Person {

    // Person 職責很少,沒必要拆解為 2 個類
    private String name;
    private String age;

    // ...
}

class TelePhoneNumber {

    private String phoneNumber;

    // ...
}

我們把 PersonTelePhoneNumber 進行合併,然後可以移除 TelePhoneNumberPerson 的最終代碼如下:

public class Person {

    // Person 看上去更加合理了
    private String name;
    private String age;
    private String phoneNumber;

    // ... do some 
}

總結

如果類很小,那麼就要考慮將它合併,從而讓臨近的類的職責更加合理

5:隱藏委託關係

說明:委託關係是指,必須通過 A 類才能調用另一個 B 類對象
使用場景:當只有個別函數需要通過關聯方式獲取的時候,使用隱藏委託模式,讓調用關係更加簡單

示例一

我們先看看委託模式的代碼,我們使用一個 PersonDepartment 類來舉例,代碼如下:

public class Person {

    Department department;

    // 獲取所屬部門
    public Department getDepartment() {
        return department;
    }
}

class Department {

    private String chargeCode;
    private Person manage;

    public Department(Person manage) {
        this.manage = manage;
    }

    // 需要通過 Department 才能找到部門 Manage
    public Person getManage() {
        return manage;
    }
}

以上代碼設計看上去沒有問題,但是當我想要獲取某一個 Person 對象的所屬經理的時候,我就需要先獲取 PersonDepartment 對象,然後在 Department 中才能調用 getManager() 函數,代碼看起來就會很彆扭,如下:

Person john = new Person();
// 委託模式:需要通過 Department 委託對象才能獲取 Person 想要的數據
Person manage = john.getDepartment().getManage();

這樣的類結構設計會存在以下幾個問題:

  1. 違背 OOP 的封裝原則,封裝的原則意味類儘可能的少對外的暴露信息
  2. 調用方需要去理解 PersonDepartment 的依賴關係,才能拿到 getManage() 信息
  3. 如果委託關係發生變化,那麼調用方也需要修改代碼

我們可以在 Person 中隱藏這層委託關係,從而讓 Person 可以直接獲取 getManage(),我們在 Person 加入以下代碼:

public class Person {

    Department department;

    public Person getManage() {
        return department.getManage();
    }
}

這裡看到 Person 有兩處修改:

  1. 隱藏 department.getManage() 委託關係
  2. 移除 getDepartment() 函數

最終獲取 Person 的 getManage() 顯示更加直接,代碼如下:

// 然後改用新函數獲取 Person 的 Manage 
Person manage = john.getManage();

總結

如果 只有少數函數 需要依賴委託關係獲取的時候,可以使用 隱藏委託關係 的重構手法來讓類關係和調用變的簡單。

6:移除中間人

說明:這是 隱藏委託關係 這個技巧的反例
使用場景:當有過多函數需要委託的時候,不建議繼續隱藏委託模式,直接讓調用方調用目標類,代碼反而更簡潔

示例一

我們上面的代碼通過在 Person 建立隱藏的委託模式,如下:

public Person getManage() {
    return department.getManage();
}

通過這種方式來簡化對象關係的調用,但是再想想,在後續的需求迭代中,Department 也在不斷增加特性,如果 Department 每個新增的特性,都需要通過 Person 來進行委託的話,那麼代碼看起來就像這樣:

class Department {

    private String chargeCode;
    private Person manage;
    // 部門不斷發展,新增不少角色
    private Person captain;
    private Person groupLeader;

    // 省略獲取部門角色的方法....
}

所以每當 Department 增加角色,Person 都要修改代碼來繼續隱藏委託模式,Person 代碼如下:

class Person {

    Department department;

    public Person getManage() {
        return department.getManage();
    }
    
    // 兼容 Department 新增的委託模式
    public Person getCaptain() {
        return department.getCaptain();
    }

    public Person getGroupLeader() {
        return department.getGroupLeader();
    }
}

所以 當有過多函數委託 的時候,倒不如移除 Person 這個簡單的中間人,讓對象直接調用 Department,區別如下:

// 通過委託模式獲取
Person manage = john.getManage();
Person captain = john.getCaptain();
Person groupLeader = john.getGroupLeader();

// 移除中間人獲取
Person manage = john.getDepartment().getManage();
Person captain = john.getDepartment().getCaptain();
Person groupLeader = john.getDepartment().getGroupLeader();

這樣做的好處就是 Person 的代碼量大大減少,移除中間人後的 Person 類:

public class Person {
    
    // 當委託工作變的非常重的時候,解除委託關係,可以讓 Person 獲得解放
    Department department;

    public Department getDepartment() {
        return department;
    }
}

總結

  • 當需要委託的特性越多,隱藏委託模式就顯得沒有必要,直接調用提供人代碼會更簡單
  • 如果只有簡單的委託特性,建議使用隱藏委託關係

7:擴展工具類

使用場景:當系統工具庫無法滿足你需求的時候,但是你又無法修改它(例如 Date 類),那麼你可以封裝和擴展它,來讓它具備你需要的新特性。

示例一

例如我們有一段處理時間的函數,Date 工具類似乎並沒有提供這樣的方法,我們自己實現了,代碼如下:

Date previousEnd = new Date();
Date newStart = new Date(previousEnd.getYear(), previousEnd.getMonth(), previousEnd.getDate() + 1);

以上 newStart 實現的方式有以下幾個問題:

  • 表達式難以閱讀
  • 無法復用

我們使用 擴展工具類 的方式,可以把程序重構為以下這樣:

Date previousEnd = new Date();
Date newStart = nextDay(previousEnd);

// 提煉一個函數,作為 Date 類的擴展函數方法
public static Date nextDay(Date arg) {
    return new Date(arg.getYear(), arg.getMonth(), arg.getDate() + 1);
}

總結

  • 通過擴展工具類,為工具類增強更多的功能,從而滿足業務的需求
  • 如果有可能(獲取修改工具類的權限),那麼可以考慮把擴展函數搬到工具類內部,讓更多人復用

8:增強工具類

使用場景:當你無法修改工具類(通常都無法修改),並且只有個別函數需要擴展的時候,那麼使用 擴展工具類 沒有任何問題,只要少量的代碼就可以滿足功能需求,但是這種擴展是一次性的,例如擴展的 nextDay() 函數,無法被其他類復用。所以我們需要用增強工具類來解決這個問題

示例一

我們還是使用上面的 nextDay() 擴展函數來舉例,假如這個函數會經常被用到,那麼我們就需要增強它,做法如下:

  1. 新建一個擴展類,然後繼承工具類(例如 Date
  2. 在擴展類內實現擴展函數,例如 nextDay()

代碼如下:

public class StrongDate extends Date {
    // 提煉一個函數,作為 Date 類的擴展函數方法
    public static Date nextDay(Date arg) {
        return new Date(arg.getYear(), arg.getMonth(), arg.getDate() + 1);
    }

    // ... 這裡還可以做更多擴展
}

調用方使用方式:

Date previousEnd = new Date();
Date newStart = StrongDate.nextDay(previousEnd);

總結

  • 工具類的擴展函數會經常被複用,建議使用 增強工具類 的方式重構顯然更加的合適
Tags: