1. 程式人生 > 其它 >Scala的面向物件與函式程式設計

Scala的面向物件與函式程式設計

很難說FP和OO孰優孰劣,應該依場景合理選擇使用。倘若從這個角度出發,Scala就體現出好處了,畢竟它同時支援了OO和FP兩種設計正規化。

從設計角度看,我認為OO更強調物件的自治,即每個物件承擔自己應該履行的職責。倘若在編碼實現時能遵循“自治”原則,就不容易設計出貧血物件出來。FP則更強調函式的分治,即努力保證函式的純粹性和原子性,對一個大問題進行充分地分解,分別治理,然後再利用函式的組合性完成職責的履行,即所謂“通過增量組合建立抽象”。

需求

我最近正在編寫的一個需求場景,正好完美地展現了這兩種不同正規化的設計威力。我要實現的是一個條件表示式樹的驗證和解析,這棵樹的節點分為兩種型別:

  • Condition Group
  • Condition

Condition Group作為根節點,可以遞迴巢狀Condition Group和Condition,如下圖所示:

對條件表示式樹的驗證主要是避免出現非法節點,例如不支援的操作符,不符合要求的條件值,不合理的遞迴巢狀,空節點等。若驗證不通過則需要提供錯誤資訊,並返回給前端400的BadRequest。解析時,必須保證節點是合規的,解析後的結果為滿足SQL語法中where條件子句的字串。

驗證

針對表示式數的合規性驗證,我選擇了FP的實現方式。為何做出這樣的選擇?試剖析整個驗證行為,可以分解為如下的驗證邏輯:

  • 對錶達式樹的驗證
    • 對邏輯操作符的驗證
    • 對子條件Size的驗證
    • 對操作符的驗證
    • 對條件值的驗證
    • 對當前Condition節點的驗證
    • 對當前Condition Group節點的驗證

可以看到,分解出來的處於同一層次的驗證邏輯,彼此之間是完全正交的,獲得的結果互相不受影響。同時,這些“原子”的驗證邏輯又可以組合起來,形成更高粒度的正交的驗證,例如對Condition和Condition Group的驗證,彼此獨立,組合起來卻又可以形成對整個表示式樹的驗證。

考慮函式的side effect,應儘量做到無副作用,這更選擇選擇FP的方式,且Scala自身提供了Try[T]型別,可以避免在函式中丟擲具有副作用的異常。Try[T]是一個Monad,可以支援for comprehension對函式進行組合。

由於驗證邏輯彼此正交,對函式的實現就變得非常純粹而簡單,不用考慮太多外在的因素。只要設計好函式的介面,函式可以專心做自己的事情。

對Condition當前節點的驗證

對Condition的驗證相對簡單,只需要分別針對操作符和條件值進行驗證即可。如下是程式碼實現:

trait ConditionValidator {
  def validateCondition(condition: Condition): Try[Boolean] = {
    for {
      _ <- validateOperator(condition)
      result <- validateValues(condition)
    } yield result
  }

  def validateOperator(condition: Condition): Try[Boolean] = {
    List("between", "in", "<", ">", "=", "<=", ">=", "<>").contains(condition.operator.toLowerCase) match {
      case true => Success(true)
      case false => Failure(new Throwable(s"can't support operator ${condition.operator}"))
    }
  }

  def validateValues(condition: Condition): Try[Boolean] = {
    val error = new Throwable(s"invalid values for condition ${condition}")
    if (condition.values.isEmpty) return Failure(error)
    if (condition.operator.isBetween && condition.values.size != 2) return Failure(error)
    if (condition.operator.isCommon && condition.values.size != 1) return Failure(error)

    Success(true)
  }

  implicit class StringOperator(operator: String) {
    def isBetween: Boolean = operator.toLowerCase == "between"
    def isIn: Boolean = operator.toLowerCase == "in"
    def isCommon: Boolean = List("<", ">", "=", "<=", ">=", "<>").contains(operator.toLowerCase)
  }
}

對ConditionGroup當前節點的驗證

這裡對ConditionGroup的驗證僅僅針對當前節點,不用去考慮ConditionGroup的巢狀,那是對錶達式樹的驗證,屬於另一個層次。把這一職責的邊界明確界定,程式碼實現就變得非常的簡單:

trait ConditionGroupValidator {
  def validateConditionGroup(group: ConditionGroup): Try[Boolean] = {
    for {
      _ <- validateLogicOperator(group)
      result <- validateConditionSize(group)
    } yield result
  }

  def validateConditionSize(group: ConditionGroup): Try[Boolean] = {
    val error = new Throwable(s"invalid condition group for ${group}")
    group.logicOperator.toLowerCase match {
      case "not" => if (group.conditions.size == 1) Success(true) else Failure(error)
      case _ => if (group.conditions.size >= 2) Success(true) else Failure(error)
    }
  }

  def validateLogicOperator(group: ConditionGroup): Try[Boolean] = {
    List("and", "or", "not").contains(group.logicOperator.toLowerCase()) match {
      case true => Success(true)
      case false => Failure(new Throwable(s"invalid logic operator ${group.logicOperator} for ConditionGroup"))
    }
  }
}

對錶達式樹的驗證

對錶達式樹的驗證相對複雜,因為牽涉到遞迴,尤其是從效能考慮,需要使用尾遞迴(tail recursion)。關於尾遞迴的知識,在我之前的部落格《艾舍爾的畫手與尾遞迴》中已有詳細介紹,這裡不再贅述。閱讀下面的程式碼實現時,注意尾遞迴方法recurseValidate()的第二個引數,其實就是關鍵的accumulator。

trait CriteriaValidator extends ConditionValidator with ConditionGroupValidator {
  def validate(group: ConditionGroup): Try[Boolean] = {
    @tailrec
    def recurseValidate(expr: List[ConditionExpression], result: Try[Boolean]): Try[Boolean] = {
      val ex = new Throwable(s"invalid condition group ${group}")
      expr match {
        case Nil => Failure(ex)
        case head::Nil => result.flatMap(_ => validateExpression(head))
        case head::tail => recurseValidate(tail, validateExpression(head))
      }
    }
    validateConditionGroup(group).flatMap(_ => recurseValidate(group.conditions, Success(true)))
  }

  def validateExpression(expr: ConditionExpression): Try[Boolean] = expr match {
    case expr: ConditionGroup => validateConditionGroup(expr)
    case expr: Condition => validateCondition(expr)
  }
}

注意,在函式validate()中,實際上是驗證ConditionGroup當前節點的函式validateConditionGroup()與尾遞迴方法recurseValidate()的組合。至於validateExpression()函式的引入,不過是為了避免不必要的型別判斷和強制型別轉換罷了。

解析

我最初也曾嘗試依舊採用FP方式實現解析功能。思索良久,發現要實現起來困難重重。最主要的障礙在於:每個解析行為返回的結果都會影響到別的節點,進而影響整個表示式。例如,為了保證解析後where子句的語法合規,需要考慮為每個節點解析的結果新增小括號。當對整個表示式樹進行遞迴解析時,每次返回的結果無法直接作為accumulator的值。如果在當前遞迴層添加了小括號,由於該層次下的子節點還未得到解析,就會導致小括號範圍有誤;如果不新增小括號,就無法界定各個層次邏輯子句的優先順序,導致篩選結果不符合預期。換言之,其中的關鍵在於每個解析操作並非正交的,因此無法對函式進行“分治”的拆解。

倘若站在OO的角度去思考,則對條件表示式的解析,實際就是對各個節點的解析。由於解析行為需要的資料是各個節點物件已經具備的,遵循資訊專家模式,就應該讓節點物件自己來履行職責,這就是所謂的“物件的自治”。而從抽象層面進行分析,雖然各個節點擁有的資料不同,解析行為的實現也不盡相同,卻都是在完成對自身的解析。於是,我們通過ConditionExpression完成對不同節點型別的抽象。此時,Condition Group是表示式樹的枝節點,而Condition則是表示式樹的葉子節點。如下圖所示,不恰好是Composite模式的體現麼?

我們首先需要定義ConditionExpression抽象。這裡之所以定義為抽象類,而非trait,是為了支援Json解析的多型,與本文無關,這裡不再解釋。若希望瞭解,請閱讀我的另一篇文章《在Scala專案中使用Spring Cloud》:

abstract class ConditionExpression {
  def evaluate: String
}

作為枝節點的ConditionGroup,不僅要解析自身,還要負責解析巢狀的子節點。但是,父節點不用考慮解析子節點內部的實現,它僅僅是在合適的地方發起對子節點的呼叫就可以了。這才是真正的“自治”,也就是每個物件在理智上都保持對“權力的剋制”,僅負責履行屬於自己的職責,絕不越權。

case class ConditionGroup(logicOperator: String, conditions: List[ConditionExpression]) extends ConditionExpression {
  def evaluate: String = {
    logicOperator.toLowerCase match {
      case "not" => s"(NOT ${conditions.head.evaluate})"
      case _ => {
        val expr = conditions.map(_.evaluate).reduce((l, r) => s"${l} ${logicOperator.toUpperCase} ${r}")
        s"($expr)"
      }
    }
  }
}

case class Condition(fieldName: String, operator: String, values: List[String], dataType: String) extends ConditionExpression {
  def evaluate: String = {
    def handleValue(value: String, dataType: String): String = {
      dataType.toLowerCase match {
        case "text" => s"'${value}'"
        case "number" => value
        case _ => value
      }
    }

    val correctValues = values.map(v => handleValue(v, dataType))
    val expr = operator.toLowerCase() match {
      case "between" => s"BETWEEN ${correctValues.head} AND ${correctValues.last}"
      case "in" => {
        val range = correctValues.map(x => s"$x").mkString(",")
        s"IN (${range})"
      }
      case _ => s"${operator.toUpperCase} ${correctValues.head}"
    }
    s"(${fieldName} ${expr})"
  }
}

組合驗證與解析

若採用自頂向下的設計方法來看待整個功能,則表示式樹的驗證與解析屬於兩個不同的職責,遵循“單一職責原則”,我們應該將其分離。在進行驗證時,無需考慮解析的邏輯;在開始解析表示式樹時,也無需負擔驗證合法性的包袱。分則簡易,合則糾纏不清。只有進行了合理地“分治”後然後再組合,景色就大不相同了:

trait CriteriaParser extends CriteriaValidator {
  def parse(group: ConditionGroup): Try[String] = {
    validate(group).map(_ => group.evaluate)
  }
}

結論

就我個人而言,我認為OO與FP並不是勢如水火的天敵,也無需發出“既生瑜何生亮”的慨嘆,非得比出勝負。本文的例子當然僅僅是冰山一角地體現了OO與FP各自的優勢。善於面向物件思維的,不能抱殘守缺,閉關自守。函式式思維的大潮擋不住,也不必視其為洪水猛獸,反而應該主動去擁抱。精通函數語言程式設計的,也不必過於炫技,誇大函式式思維的重要性,就好似要“一統江湖”似的。

無論面向物件還是函式思維,用對了才是對的。誰也不是江湖永恆的霸主,青山依舊在,幾度夕陽紅!