影片筆記|如何優雅地避免程式碼巢狀

影片筆記|如何優雅地避免程式碼巢狀

·

5 min read

筆記來自於今天晚放學的Youtube影片,收穫非常大!但影片速度很快,希望自己把程式碼保存下來一份仔細閱讀與進行額外的筆記✏️。

前言

當我們程式碼超過三層的縮排,說明你的程式碼已經開始混亂:

// Typescript 申請適合的職位
function applyForSuitablePosition(positions: Array<JobPosition>): void {
    fot (lef position of positions){
        if(position.salary > 1000){
            if(position.name.includes("Software Engineer")){
                if(position.workingHours == "996"){
                    // 申請職位
                    posotion.apply();
                }
            }
        }

    }
}

表驅動法 Table-Driven Methods

如果可以透過邏輯與俱來選擇的情況,都可以用查表法或表驅動法來判斷。

查表法:

// TypeScript 使用邏輯控制語句跳轉
function choose(subtitle: string):void {
    switch(subtitle){
        case "表驅動法":
            jumpTo("00:16");
            break;
        case "提早返回":
            jumpTo("00:30");
            break;
        case "面向對象":
            jumpTo("01:21");
            break;
        case "高階函數":
            jumpTo("02:10");
            break;
        case "空值判斷":
            jumpTo("02:56");
            break;
    }
}

當選項變多時,表驅動法更易維護:

// TypeScript 使用邏輯控制語句跳轉
const subtitleMap = new Map([
    ["表驅動法", "00:16"],
    ["提早返回", "00:39"],
    ["面向對象", "01:21"],
    ["高階函數", "02:19"],
    ["空值判斷", "02:56"],
])

// TypeScript 使用表驅動法跳轉
function choose(subtitle: string): void{
    jumpTo(subtitleMap.get(subtitle)!);
}
💡
額外筆記jumpTo(subtitleMap.get(subtitle)!) 這裡是調用從Map 中獲取與傳入參數相同的時間戳,!在TypeScript 中被用作非空斷言,這代表調用 get 時一定會獲得一個有效的值,而非undefinednull

💡 延伸閱讀PHCHENder | switch 和 if 在各情況下的效能比較


提早返回 Early Returns

提早返回主要概念把傳入錯誤數據時需要的錯誤提早返回,而不是嵌套在函示主要邏輯中,這也帶到防禦式編程的概念,預防其他子程序產生出錯誤數據。

// TypeSctipt 透過表驅動法獲取每月天數
const monthDays = [
  [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31],
  [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31],
]

function getMonthDays(year: number, month: number):number {
    // 🌟 重要,將需要提早退出的條件寫在前面,簡化複雜的參數驗證!
    if(Number.isInteger(month) && month <= 12 && month >= 1)
        throw new Error("The parameter 'month' is invalid");

    if(Number.isInteger(year) && year >0)
        throw new Error("The parameter 'year' is invalid");


    let isLeapYear = (year % 4 === 0) && (year % 100 !== 0 || year % 400 ===0) ? 1: 0;
    return monthDays[isLeapYear][month - 1];
}

console.log(getMonthDay(2023, 13)); // Error: The parameter 'month' is invalid
console.log(getMonthDays(2024, 3.13)); // Error: The parameter 'month' is invalid
console.log(getMonthDays(NaN, 2)); // Error: The parameter 'year' is invalid

最後,不要忘記顛倒判斷邏輯(以下為顛倒前後比對):

    // 顛倒前 ⬇️
    if(Number.isInteger(month) && month <= 12 && month >= 1)
        throw new Error("The parameter 'month' is invalid");

    if(Number.isInteger(year) && year >0)
        throw new Error("The parameter 'year' is invalid");

    // 顛倒後 ⬇️    
    if(!Number.isInteger(month) || month > 12 || month <1 )
        throw new Error("The parameter 'month' is invalid");

    if(!Number.isInteger(year) || year < 0){
        throw new Error("The parameter 'year' is invalid");
    }
💡
額外筆記:這兩段代碼都是確認從原影片搬移過來的,第一個顛倒判斷邏輯前Number.isInteger(month)也被寫在拋出錯誤的判斷中。一開始以為是否為影片作者筆誤,但想了一下也許是重現新手常見的&&撰寫時出現的錯誤習慣。

使用類似於斷言的參數驗證工具,或是將判斷函示抽取出來也是不錯的選擇,這裡使用Zod 做示範:

import z from "zod"

function getMonthDaysCheck(year: number, month: number): void {
    const monthParser = z.coerce.number().int().gte(1).lte(12);
    monthParser.parse(month);
    const yearParser = z.coerce.number().int().gte(1);
    yearParser.parse(year);
}

function getMonthDays(year: number, month: number): number{
    getMonthDaysCheck(year, month);

        let isLeapYear = (year % 4 === 0) && (year % 100 !== 0 || year % 400 ===0) ? 1: 0;
    return monthDays[isLeapYear][month - 1];

    console.log(getMonthDay(2023, 13)); // ZodError: Number must be Less than or equal to 12
console.log(getMonthDays(2024, 3.13)); // ZodError: Expected integer, received float
console.log(getMonthDays(NaN, 2)); // ZodError: Expected number, received nan
}

面向對象 Object-Oriented

有對象的同學都知道,多態(Polymorphism)類似一個switch的分支,但是多態又可以把分支邏輯完美隱藏起來。

for (let order of orders){
    switch (order.promotionMethod){
        case "FullReduction":
            // 滿額減價
            processFullReduction(order);
            break
        case "FullGift":
            // 滿額贈品
            processFullGift(order);
            break;
        case "CouponBack":
            // 回饋券
            processCouponBack(order);
            break;
        default:
            throw new Error();
    }
}

// 多態搭配簡單工廠Simple Factory ⬇️
class PromotionFactory {
    static create(order: Order): Promotion {
        switch(order.promotionMethod) {
            case "FullRediction":
                return new FullReduction();
            case "FullGiftPromotion":
                return new FullGiftPromotion();
            case "FullCouponBack":
                return new CouponBackPromotion();
            default:
                throw new Error();
        }
    }
}

for(let order of orders) {
    let promotion = PromotionFactory.create(order);
    promotion.process();
}
💡
額外筆記:影片作者為中國人,可能因為用語的關係所以看影片當下不太理解作者的解說,這裡我的理解為使用switch 對應oder的promotionMethod並直接執行各自process function,這個方式雖然直觀。 但使用工廠(Factory Pattern)來對orderpromotionMethod 創建對應的促銷處理也可以直接調用該對象的本身的process方法,提高可維護性與擴展性。

面向對象舉例:微波爐

微波爐有以下狀態與操作:

  • 三種狀態:

    • 加熱中Heating

    • 開門中DoorOpen

    • 關門中DoorClosed

  • 四種操作:

    • 開門OpenDoor

    • 關門CloseDoor

    • 啟動Start

    • 停止Stop

狀態與操作會相互影響,例如開門操作只能在關門狀態下進行,剩下兩種狀態開門會報錯:

class MicrowaveOven {
    private state: State = State.DoorClosed;

    public OpenDoor(): void {
        if(this.state === State.Heating) {
            console.error("加熱時,不能開門");
        }else if(this.state === State.DoorOpen) {
            console.error("門已開");
        }else if(this.state === State.DoorClosed) {
            this.state = State.DoorOpen
        }
    }

    // ....其他省略
}

在操作中判斷狀態,會出現許多判斷語句,這時可以使用狀態模式,由多態來選擇相應的狀態:

// Microwave State
+ microwave MicrowaveOven

+ OpenDoor(): void
+ CloseDoor(): void
+ Start(): void
+ Stop(): void

abstract class MicrowaveState {
    // 因為要修改微波爐狀態
    // 狀態對象都需要引用微波爐
    readonly microwave: MicrowaveOven;
    constructor(microwave: MicrowaveOven){
        this.microwave = microwave;
    }
    abstract OpenDoor(): void;
    abstract CloseDoor(): void;
    abstract Start(): void;
    abstract Stop(): void;
}
// HeatingState
+ OpenDoor(): void
+ CloseDoor(): void
+ Start() :void
+ Stop() : void

// DoorOpenState
+ OpenDoor(): void
+ CloseDoor(): void
+ Start() :void
+ Stop() : void

// DoorClosedState
+ OpenDoor(): void
+ CloseDoor(): void
+ Start() :void
+ Stop() : void

// ex.HeatingState
class HeatingState extends MicrowaveState {
    OpenDoor() { console.error("加熱時,不能開門"); }
    CloseDoor() { console.error("加熱時,門已經關閉"); }
    Start() { console.error("正在加熱"); }
    Stop() {
        this.microwave.microwaveState = this.microwave.doorClosedState;
        console.log("微波爐已停止工作");
    }
}

回頭看MicowaveOven的條件語句就可以徹底消除:

class MicrowaveOven {
    // 創建所有狀態對象
    readonly heatingState = new HeatingState(this);
    readonly doorOpenState = new DoorOpenState(this);
    readonly doorClosedState = new DoorClosedState(this);

    // 微波爐的狀態 初始化為 關門狀態
    microwaveState: MicrowaveState = this.doorClosedState;

    OpenDoor(): void{
        this.microwaveState.OpenDoor();
    }

    CloseDoor(): void{
        this.microwaveState.CloseDoor();
    }

    Start(): void {
        this.microwaveState.Start();
    }

    Stop(): void {
        this.microwaveState.Stop();
    }
}
💡
💡額外筆記:因為我個人對於Class用法還不熟練,這裡微波爐實例單看片段程式碼有交由ChatGPT再做一次完整重現,另外整理一篇筆記

高階函數 Higher-order Function

各類程式語言幾乎都有提供對於Array, Object 的高階函式(Higher Order Function) ,例如篩選filter排序sort分組group投影map

以下positions 的值作為範例:

1. ["Software Engineer", 3008, "廣州"]
2. ["Software Engineer", 3004, "北京"]
3. ["Software Engineer", 3000, "上海"]
4. ["Web Developer", 4000, "上海"]
5. ["Software Engineer", 100000, "承德"]
6. ["Software Engineer", 3005, "上海"]

// ❌ 如果用if與for來解決的話
let result = Array<JobPosition>();
for (let position of positions){
    if(position.name.includes("Software")){
        result.push(position);
    }
}

// 🌟 如果用filter 來解決的話,兩層嵌套就消失了
let result = position.filter(o => o.name.includes("Software"));


// 🌟 許多語言也支持高階函式的鏈式調用
let result = positions.filter(o => o.name.includes("Software"))
    .filter(o => "北京,上海,廣州".includes(o.jobLocation))
    .sort((a, b) => b.salary - a.salary)
    .map(o => o.id)
    .slice(0, 3)
console.log(result); // [ 1, 6, 2 ]

空值判斷 Null Value Check

無論什麼語言,為了避免傳入空值,一定都看過這樣的程式碼,不經意又產生大量的if:

    function printCoty(contactPerson: User): void {
        if(!contactPerson.address
          || !contactperson.address.city) {
            console.log("缺少城市");
            return
        }
        console.log(contactperson.address.city);
    }

好在目前程式語言基本上都有優雅的空值解法:

function printCity(contaceperson: User): void{
    console.log(contactPerson.address?.city ?? "卻少城市");
}

🌱 總結

再次強調以上所有程式碼與解說都來自今天晚放學如何優雅地避免程式碼巢狀 | 程式碼嵌套 | 狀態模式 | 表驅動法 |,對於每天都在與if, else打架的工程師來說熟讀避免程式碼巢狀的pattern真的非常重要,如果有任何指教也歡迎留言給我,感謝!