2012年2月23日

[Refactoring] 去除讓人困惑的巢狀判斷

if 很好用,但遇到一層又一層,一層又一層巢狀 if 的時候常常搞得人昏頭轉向,
今天剛好看到一個很經典的案例,可以做重構案例的分享。

首先重構的第一個步驟,先瞭解原本程式在做什麼。(因為我們並不是要重寫,只是要讓程式以更優雅的姿態呈現),而這也是最困難的一個部分,因為一個需要重構的程式,通常都是因為不容易閱讀,才會需要重構。

所看一下程式的主要目的是什麼:
畫面上有 5 個欄位,分別是開始日期、開始日期的時間、結束日期、結束日期的時間、總時數; checkTimeFormat 被呼叫到的時機是,當相關的欄位 onchange 時就觸發。
function checkTimeFormat() {

    var startDate = new Date(ctrls.idtpCouseOpen_DATE_txtPreBox_txtDate.val());
    var endDate = new Date(ctrls.idtpCouseOpen_DATE_txtPostBox_txtDate.val());
    var startTime = ctrls.itxtCouseOpen_TIME_txtPreBox.val();
    var endTime = ctrls.itxtCouseOpen_TIME_txtPostBox.val();
    if ((!isNaN(startDate)) & (!isNaN(endDate))) {
        if ((!isNaN(startTime)) & (!isNaN(endTime))) {

            if (((parseInt(startTime) < 2359) & (parseInt(endTime)) < 2359)) {

                if ((parseInt(startTime)) < (parseInt(endTime))) {
                    caculateTTLHR(startDate, endDate, startTime, endTime);
                } else {
                    alert("開始時間大於結束時間");
                    ctrls.itxtCouseOpen_TIME_txtPreBox.val("");
                    ctrls.itxtCouseOpen_TIME_txtPostBox.val("");
                    ctrls.txtTRAIN_CLASS_TOTALHR_EditText.val("");
                }
            }
            else {
                if (parseInt(startTime) > 2359) {
                    alert("時間輸入錯誤,請重新輸入!");
                    ctrls.itxtCouseOpen_TIME_txtPreBox.val("");
                    ctrls.txtTRAIN_CLASS_TOTALHR_EditText.val("");
                }

                if (parseInt(endTime) > 2359) {
                    alert("時間輸入錯誤,請重新輸入!");
                    ctrls.itxtCouseOpen_TIME_txtPostBox.val("");
                    ctrls.txtTRAIN_CLASS_TOTALHR_EditText.val("");
                }

            }
        }
        else {
            if (isNaN(startTime)) {
                alert("時間輸入錯誤,請重新輸入!");
                ctrls.txtTRAIN_CLASS_TOTALHR_EditText.val("");
                ctrls.itxtCouseOpen_TIME_txtPreBox.val("");

            }
            if (isNaN(endTime)) {
                alert("時間輸入錯誤,請重新輸入!");
                ctrls.txtTRAIN_CLASS_TOTALHR_EditText.val("");
                ctrls.itxtCouseOpen_TIME_txtPostBox.val("");
            }

        }
    }
}
以下就程式判斷的邏輯做分析
1. 如果日期欄位轉成日期物件後不是非數值內容,才處理,
    否則什麼事都不做。

2. 如果時間欄位都有輸入非數值內容時,檢查日期、時間輸入是否合邏輯
    否則分別判斷哪個時間欄位非數值,並提示使用者時間欄位輸入錯誤。

3. 承 2 ,如果輸入的時間 < 2359 才判斷日期和時間的起迄關系(起不可大於迄)
    否則分別判斷哪個時間欄位大於 2359 並提示使用者時間欄位輸入錯誤。

4. 承 3 ,如果起時間小於迄時間才計算時數,
    否則提示使用者起迄時間輸入有誤。
    (因為需求的關系,只針對時間的起迄判斷,也就是說日期和時間並沒有關系)

>>> 考慮用 Replace Netsted Conditional with Guard Clauses

接下來就是思考如何重構,
從上面程式來看,最深有 4 層 if ,而最終正確結果只有一個(事情的真相只有一個,唯一看透了真相是一個外表看似小孩,智慧卻過於常人的名偵探柯南 XD),也就是最深那層的判斷,
其他的分支,都是錯誤的情況,且錯誤發生後續都不用再做額外的處理,
這是很典型可以用 Guard Clauses 處理的結構。

1. 我們把第一個 if 拿到最前面 ! 拿掉 & 改成 ||,成立便 return,馬上去掉一層,就語意層面來看也清楚多了(如果起時間不是數值 或 迄時間不是數值 就 離開不處理)。
if ((isNaN(startDate)) || (isNaN(endDate))) return
2. 把第二層的 if 中,else 的部分拿到最前面,於是我們又少了一層 if ,而且少了一個 if 判斷。

            if (isNaN(startTime)) {
                alert("時間輸入錯誤,請重新輸入!");
                ctrls.txtTRAIN_CLASS_TOTALHR_EditText.val("");
                ctrls.itxtCouseOpen_TIME_txtPreBox.val("");
                return;
            }
            if (isNaN(endTime)) {
                alert("時間輸入錯誤,請重新輸入!");
                ctrls.txtTRAIN_CLASS_TOTALHR_EditText.val("");
                ctrls.itxtCouseOpen_TIME_txtPostBox.val("");
                return;
            }
3. 把第三層的 if 中, else 的部分拿到最前面,結果同 2
                if (parseInt(startTime) > 2359) {
                    alert("時間輸入錯誤,請重新輸入!");
                    ctrls.itxtCouseOpen_TIME_txtPreBox.val("");
                    ctrls.txtTRAIN_CLASS_TOTALHR_EditText.val("");
                    return;
                }

                if (parseInt(endTime) > 2359) {
                    alert("時間輸入錯誤,請重新輸入!");
                    ctrls.itxtCouseOpen_TIME_txtPostBox.val("");
                    ctrls.txtTRAIN_CLASS_TOTALHR_EditText.val("");
                    return;
                }
於是我們就初步完成了 Replace Netsted Conditional with Guard Clauses
這樣程式是不是更容易閱讀了呢?(雖然還有重構的空間,不過就先這樣囉)
function checkTimeFormat() {

    var startDate = new Date(ctrls.idtpCouseOpen_DATE_txtPreBox_txtDate.val());
    var endDate = new Date(ctrls.idtpCouseOpen_DATE_txtPostBox_txtDate.val());
    var startTime = ctrls.itxtCouseOpen_TIME_txtPreBox.val();
    var endTime = ctrls.itxtCouseOpen_TIME_txtPostBox.val();



    if ((isNaN(startDate)) || (isNaN(endDate))) return;




    if (isNaN(startTime)) {
        alert("時間輸入錯誤,請重新輸入!");
        ctrls.txtTRAIN_CLASS_TOTALHR_EditText.val("");
        ctrls.itxtCouseOpen_TIME_txtPreBox.val("");
        return;
    }
    if (isNaN(endTime)) {
        alert("時間輸入錯誤,請重新輸入!");
        ctrls.txtTRAIN_CLASS_TOTALHR_EditText.val("");
        ctrls.itxtCouseOpen_TIME_txtPostBox.val("");
        return;
    }

    if (parseInt(startTime) > 2359) {
        alert("時間輸入錯誤,請重新輸入!");
        ctrls.itxtCouseOpen_TIME_txtPreBox.val("");
        ctrls.txtTRAIN_CLASS_TOTALHR_EditText.val("");
        return;
    }

    if (parseInt(endTime) > 2359) {
        alert("時間輸入錯誤,請重新輸入!");
        ctrls.itxtCouseOpen_TIME_txtPostBox.val("");
        ctrls.txtTRAIN_CLASS_TOTALHR_EditText.val("");
        return;
    }

    if ((parseInt(startTime)) < (parseInt(endTime))) {
        caculateTTLHR(startDate, endDate, startTime, endTime);
    } else {
        alert("開始時間大於結束時間");
        ctrls.itxtCouseOpen_TIME_txtPreBox.val("");
        ctrls.itxtCouseOpen_TIME_txtPostBox.val("");
        ctrls.txtTRAIN_CLASS_TOTALHR_EditText.val("");
    }
}

上面重構完之後,經測試發現有問題,
輸入起時間還沒輸迄時間的時候,會說開始時間大於結束時間,
原因在於如果時間欄位為 "" 空字串時, isNaN 會是 false 但是 parseInt 後會變成 NaN,
而在步驟 3 去掉這層 if 的時候,
本以為不是 parseInt > 2359 就是 parseInt < 2359,
結果出現了第三種可能,使得原來的程式不會去執行到開始時間小於結束時間的判斷。
而這個狀況雖然使得原來執行的結果是正確,但程式表達出來的語意卻不是這個樣子。
所以這邊要修正這個 Bug 就在判斷開始時間大於結束時間之前再加上

if (endTime=="" ||startTime=="") return;

最後,這支程式還有一個問題,就是 parseInt 方法 應該要傳入兩個參數 ~~ 才不會有 '08' 的問題 ~~


2012年2月9日

[.Net] Thread Local Storage

Thread Local Storage(TLS),顧名思意就是在 Thread 裡的儲存空間,
在 MultiThread 的世界裡面,Thread 和 Thread 之間本來就井水不河水,
要存取共用的資源時,就要透過鎖定同步等方式,才不會有競速的問題,
TLS 要解決的問題,並不是 Thread 之間資源共享的問題,
而是 Thread 本身資源共享的問題。

假設我們要設計一個 ADODB 物件,
讓程式都透過底層取得 ADODB 物件,再使用 ADODB 物件執行 SQL ,
而 Connection 的事都交給 ADODB 物件處理,
程式只需要
ADODB dbAdpter = ADODBFactory.getADODB();
dbAdpter.execSQL(...);
而 ADODB 物件的設計,會盡量使用同一條 Connection ,
void main()
{
    methodA();
    methodB();
}

void methodA() {
    ADODB dbAdpter = ADODBFactory.getADODB();
    dbAdpter.execSQL("...");  
}

void methodB() {
    ADODB dbAdpter = ADODBFactory.getADODB();
    dbAdpter.execSQL("...");  
}
如上面程式所示,
這邊會希望每次從 ADODBFactory.getADODB() 取出來的物件是同一個,
而 connection 就 keep 在這裡面,
對 ADODBFactory 而言,並不曉得呼叫他的是不是同一個 thread ,
他也不能隨便丟一個 ADODB 回去,如果每次都 new 一個 ADODB 的話,
就變成會開一堆 connection ,即使有用 ado.net 的 connection pooling ,
connection 用量還是很大,更慘的是,如果有 transctionscope 還會被升級成 DTC,
如果 ADODBFactory 能夠從 thread 中取得現有的 ADODB ,
沒有的時候就 new 一個給他,這樣就完美了,
所以我們需要在 thread 中佈置一塊空間,而且讓 thread 外面可以看得到,拿得到,
這時候 TLS 就很好用了,直接就在 thread 上放一個俱名的 Data Slot ,
ADODBFactory.getADODB 就先看目前 thread 裡有沒有存在的 ADODB 物件,
如果有就直接拿,沒有的話就 new 一個給他。
public static ADODB getADODB()
{
    if (Thread.GetData(Thread.GetNamedDataSlot("YourDataSlotID"))==null) {
        Thread.SetData(Thread.GetNamedDataSlot("YourDataSlotID"), newADODB());
    }
    return (ADODB)Thread.GetData(Thread.GetNamedDataSlot("YourDataSlotID"));
}
於是我們就很輕鬆的用 TLS 完成這個任務了。

參考資料:
Thread Local Storage: Thread-Relative Static Fields and Data Slots