在程式設計中思考,簡化你的判斷邏輯

雨帆發表於2017-03-06

之前看 Linus Toward 在去年的某次採訪中說到的好程式碼壞程式碼,當中提到了邏輯的精簡,能用更通用的邏輯減少 if else 的判斷在某種程度上可以使你的程式碼變得更好。最近一段時間重構了部分老程式碼,也 Review 了不少程式碼,對此觀點深有感觸。

很多時候,程式設計師接到的需求,產品巴不得你立刻就能搞定,有時候會給非常緊迫的時間點。這種情況下會帶來的最直接問題,就是“設計壞味”。有整體的架構設計的不合理,也有程式碼邏輯的問題。尤其是對於邊界條件的處理,因為需求的急,很多時候大家就會按照業務語言去寫。

比如,下面這個例子:

某一報警系統產生了報警郵件,現在需要按照型別顯示不同的內容。

型別 報警選擇物件 郵件中展示內容
Web事務 單條Web事務 Tier,Web事務,節點
Web事務 Tier Tier,節點
節點 某一個節點 節點
節點 Tier Tier,節點

如果按照業務的語言,我們可能寫出如下的虛擬碼:

if (Web事務 && 單條Web事務) {
    return Tier,Web事務,節點;
}
if (Web事務 && Tier) {
    return Tier,節點;
}
if (節點 && 某一個節點) {
    return 節點;
}
if (節點 && Tier) {
    return Tier,節點;
}

可能你看到這裡會立刻笑出來,哪有隻寫 if 不寫 else 的。那好,也許你會寫出這樣的程式碼。

if (Web事務) {
    if (單條Web事務) {
        return Tier,Web事務,節點;
    } else if (Tier) {
        return Tier,節點;
    }
} else if (節點) {
    if (某一個節點) {
        return 節點;
    } else if (Tier) {
        return Tier,節點;
    }
}

我相信,大部分人都能將判斷邏輯寫到這一層,但是,這就完了麼?也許你會說完了,邏輯也正確,看起來也很清晰。然而,這遠遠不夠。

其實我們可以發現,在每種情況下,都會返回節點資訊。只返回節點資訊的只有一種情況,其他的情況下,基本都返回Tier資訊。只有Web事務 && 單條Web事務的情況需要返回Web事務資訊。所以,最後我們可以精簡為兩個判斷。

result = "節點";
if (Web事務 && 單條Web事務) {
    result = "Web事務" + result;
}

if (Web事務 || !某一個節點) {
    result = "Tier" + result;
}

return result;

回到最初的命題,為何不要用業務的語言來編寫判斷邏輯呢?因為業務語言是給使用者和產品看的,他們在描述上本身就不夠精簡。其次,業務的描述,很多時候,是定義邊界,說明問題,而不是告訴你判斷邏輯。

所以,在寫程式碼的時候,更多的時候要細化邏輯。這樣,在維護修改時,才更為方便。下面我舉另一個更具體的例子。

這是我 Review Scala 程式碼的時候遇到的一個問題,首先我先用業務語言描述一下需求。需要判斷某個規則的開閉狀態,在一天的幾點到幾點間啟用,且還可以額外設定是週一到週日的哪幾天啟用。

於是我看到當時的同事,寫一個方法isSuppressTime,會給兩個引數,第一個引數為一個時間戳timestamp,第二個引數為一個 List[Map[String, String]]

Map[String, String] 裡面有4個值,分別是:

  1. startTime,從零點到某個具體開始時間的秒數,0~86399。
  2. endTime,從零點到某個具體結束時間的秒數,0~86399。
  3. isDaily,當時間戳在 startTime endTime 之間時,如果此項為 true,則返回 true。
  4. weekdays,週一到週日,1~7,以,間隔組成的字串,如1,4,5。表示週一、週四、週五且時間戳在 startTime endTime 之間時返回 true

最後他寫出瞭如下的程式碼(Scala):

def isSuppressTime(now: Long = System.currentTimeMillis(),
                   suppressTimes: java.util.List[java.util.Map[String, String]] = rule.getSuppressTime): Boolean = {
  if(suppressTimes == null)
    return false
  import scala.collection.JavaConversions._
  suppressTimes.foreach(params => {
    if (params != null && params.size > 0) {
      val startTime = params.get("startTime")
      val endTime = params.get("endTime")
      val isDaily = params.get("isDaily")
      val weekdays = params.get("weekday").split(",").toList

      val zero = zeroTimestamp()
      //今天零點零分零秒的毫秒數
      val start = zero + startTime.toLong * 1000
      val end = zero + endTime.toLong * 1000

      if (start <= now && end >= now) {
        if (isDaily.toBoolean)
          return true
        val cal = Calendar.getInstance()
        cal.setTimeInMillis(now)
        var day = cal.get(Calendar.DAY_OF_WEEK)
        if (day == 1)
          day = 7
        else
          day = day - 1
        val weekday = day.toString
        if (weekdays.contains(weekday))
          return true
      }
    }
  })
  return false
}

private def zeroTimestamp(): Long = {
  val cal = Calendar.getInstance()
  cal.set(Calendar.HOUR_OF_DAY, 0)
  cal.set(Calendar.SECOND, 0)
  cal.set(Calendar.MINUTE, 0)
  cal.set(Calendar.MILLISECOND, 1)
  cal.getTimeInMillis()
}

這個程式碼看著很長,判斷很多,而且還用了超級多陳舊的 API,使得它的效能也不好。而且這是一段 Scala 的程式碼,卻用了較多的returnvar,這兩個都是 Scala 不提倡的。最關鍵的,明明是函式式的程式碼,他卻寫出了過程式的感覺。給後面的維護人員(我)帶來了不少困擾。

下面,我們來一點點優化這段程式碼(需要一點點 Java 功底),首先對於方法zeroTimestamp(),我第一眼看到的時候,是驚訝的,原作者用了一個比較舊的Calendar。對它最深刻的印象就是當年寫SimpleDataFormat的時候,因為Calendar這貨導致執行緒不安全。而每次建立SimpleDataFormat的開銷比較大,最後不得不寫了個ThreadLocal<SimpleDataFormat>

而Java8以後,我們可以直接用java.time下面的類來改寫zeroTimestamp(),這裡只需要一行程式碼,如下:

private def zeroTimestamp(): Long = {
  Timestamp.valueOf(LocalDate.now().atStartOfDay()).getTime
}

回到isSuppressTime方法上來,我姑且不說他的設計多麼麻煩,因為這是一個已經被廣泛使用的方法,我能做到的就是在不改變簽名的情況寫來優化實現。

首先,開頭我們就看到

if(suppressTimes == null)
  return false

這個空的判斷和強制return,在 Java 裡面,null是一個很痛苦的事情,Scala 因為基於 JVM 也不例外,但是 Scala 和 Java 8 都分別有一個 Option 類(Java8是 Optional),來做空值處理。

所以,我們這裡第一時間可以幹掉這個判斷,寫成如下方式

Option[util.List[util.Map[String, String]]](suppressTimes).map(times => {
  // ba la ba la
}).getOrElse(false)

程式碼裡面的times為方法中非空的suppressTimes,註釋部分為核心的處理邏輯,這樣我們避免了一個if判斷,減少了一個return

而其實, Option([A]).map([B] => Boolean).getOrElse(false) 等價於Optionexists方法,最後完整的程式碼應該如下:

def isSuppressTime(now: Long = System.currentTimeMillis(),
        suppressTimes: java.util.List[java.util.Map[String, String]]): Boolean = {
  Option[util.List[util.Map[String, String]]](suppressTimes).exists(times => {
    times.foreach(params => {
      if (params != null && params.size > 0) {
        val startTime = params.get("startTime")
        val endTime = params.get("endTime")
        val isDaily = params.get("isDaily")
        val weekdays = params.get("weekday").split(",").toList

        val zero = zeroTimestamp()
        //今天零點零分零秒的毫秒數
        val start = zero + startTime.toLong * 1000
        val end = zero + endTime.toLong * 1000

        if (start <= now && end >= now) {
          if (isDaily.toBoolean)
            return true
          val cal = Calendar.getInstance()
          cal.setTimeInMillis(now)
          var day = cal.get(Calendar.DAY_OF_WEEK)
          if (day == 1)
            day = 7
          else
            day = day - 1
          val weekday = day.toString
          if (weekdays.contains(weekday))
            return true
        }
      }
    })
    false
  })
}

在上面的例子裡面,我們已經幹掉了兩個return和一個if,讓它有一點 Functional 的感覺了。

上面重構後程式碼中,times的型別是util.List[util.Map[String, String]]times.foreach(params => {})裡的params對應的是util.Map[String, String]型別。所以,我們看到判斷if (params != null && params.size > 0)時應該會發現,這就是一個list filter的過程嘛。.filter()之後不就是一個.map(),於是我們可以這麼改:

Option[util.List[util.Map[String, String]]](suppressTimes).exists(times => {
  times.filter(params => params != null && !params.isEmpty).map(params => {
    // ba la ba la
  }).contains(true)
})

當然.map(xxx => Boolean).contains(true)等價於.exists(),於是我們重構的方法如下:

def isSuppressTime(now: Long = System.currentTimeMillis(),
        suppressTimes: java.util.List[java.util.Map[String, String]]): Boolean = {
  Option[util.List[util.Map[String, String]]](suppressTimes).exists(times => {
    times.filter(params => params != null && !params.isEmpty).exists(params => {
      val startTime = params.get("startTime")
      val endTime = params.get("endTime")
      val isDaily = params.get("isDaily")
      val weekdays = params.get("weekday").split(",").toList

      val zero = zeroTimestamp()
      //今天零點零分零秒的毫秒數
      val start = zero + startTime.toLong * 1000
      val end = zero + endTime.toLong * 1000

      if (start <= now && end >= now) {
        if (isDaily.toBoolean)
          return true
        val cal = Calendar.getInstance()
        cal.setTimeInMillis(now)
        var day = cal.get(Calendar.DAY_OF_WEEK)
        if (day == 1)
          day = 7
        else
          day = day - 1
        val weekday = day.toString
        if (weekdays.contains(weekday))
          return true
      }
      false
    })
  })
}

這次重構,我們又去掉了一層 if 判斷,改為 filter 實現,減少了一次返回。到了這一步,我們會發現,下面需要實現的就是一個方法,對util.Map[String, String]去做處理,返回一個布林值,於是我們精簡方法的實現程式碼為兩部分:

def isSuppressTime(now: Long = System.currentTimeMillis(),
                   suppressTimes: java.util.List[java.util.Map[String, String]] = rule.getSuppressTime): Boolean = {
  Option[util.List[util.Map[String, String]]](suppressTimes)
    .exists(times => times.filter(params => params != null && !params.isEmpty).exists(isValidateTimes(_, now)))
}

這個為邊界過濾的方法。

private def isValidateTimes(params: java.util.Map[String, String], now: Long): Boolean = {
  val startTime = params.get("startTime")
  val endTime = params.get("endTime")
  val isDaily = params.get("isDaily")
  val weekdays = params.get("weekday").split(",").toList

  val zero = zeroTimestamp()
  //今天零點零分零秒的毫秒數
  val start = zero + startTime.toLong * 1000
  val end = zero + endTime.toLong * 1000

  if (start <= now && end >= now) {
    if (isDaily.toBoolean)
      return true
    val cal = Calendar.getInstance()
    cal.setTimeInMillis(now)
    var day = cal.get(Calendar.DAY_OF_WEEK)
    if (day == 1)
      day = 7
    else
      day = day - 1
    val weekday = day.toString
    if (weekdays.contains(weekday))
      return true
  }
  false
}

這個為我們要重構的核心處理邏輯。我們優化整理它的判斷條件,最後可以實現如下的完整程式碼:

def isSuppressTime(now: Long = System.currentTimeMillis(),
                   suppressTimes: java.util.List[java.util.Map[String, String]] = rule.getSuppressTime): Boolean = {
  Option[util.List[util.Map[String, String]]](suppressTimes)
    .exists(times => times.filter(params => params != null && !params.isEmpty).exists(isValidateTimes(_, now)))
}

private def isValidateTimes(params: java.util.Map[String, String], now: Long): Boolean = {
  val zero = zeroTimestamp()
  val start = zero + params.get("startTime").toLong * 1000
  val end = zero + params.get("endTime").toLong * 1000
  val isDaily = params.get("isDaily").toBoolean
  val weekdays = params.get("weekday").split(",").toList
  val weekday = LocalDate.now().getDayOfWeek.getValue.toString

  (start <= now && end >= now) && (isDaily || weekdays.contains(weekday))
}

這樣,我們就實現了一個if都沒有,一個return都沒有的純函式式寫法。

總的來說,程式碼誰都能寫出來,但是把需求從文字或者是流程描述換成編碼實現時就有了對程式設計師抽象邏輯能力的需求。如何組織抽象,就像是如何寫作文,或者是 Kata(空手道里面的招數、套路),不要按照業務描述寫 if else,而要儘可能簡化找到一致性的簡單邏輯描述。

如果能將所有的特殊情況變為通用情況,簡化邏輯判斷,那麼程式碼在後面的迭代中也會比較易於維護。

相關文章