Saturday, September 21, 2013

ทำยังไงให้ไม่ (ค่อย) เจอ exception

จากที่โพสไปในคราวที่แล้ว ว่าคนเขียน scala มักจะไม่ค่อยโดน NullPointerException, NoSuchElementException หรืออะไรทำนองนี้เล่นงาน วิธีที่ทำให้ไม่โดนเล่นงาน ก็คือไม่ต้องไปใช้มัน เพราะ scala (และหลาย ๆ ภาษา) มีวิธีอื่นที่ทำให้โปรแกรมเราสื่อความหมายได้ดีและปลอดภัยกว่าการใช้ null หรือการโยน exception ออกมาดื้อ ๆ แต่ถ้าใครใช้ scala แต่ยังใช้ null หรือ throw exception อันนี้ก็ตัวใครตัวมันครับ (รุมด่ามัน !)

โครงสร้างที่ว่ามีหลายแบบ ตัวอย่างแรกแบบที่คนเพิ่งหัดใช้ scala ใช้แล้วได้ผลประโยชน์ทันทีก็คือ Option/Some/None วันนี้เรามาดูกันว่า Option/Some/None มันช่วยทำให้โค้ดเรามีคุณภาพดีขึ้นกว่าการใช้ null/exception ยังไง

อ้อ ลืมบอกไปครับ Option นี่ไม่ใช่ keyword นะครับ เป็น class บ้าน ๆ ที่ออกลูกออกหลานได้ 2 ตัวคือ Some และ None ซึ่งแน่นอน ก็คือ class บ้าน ๆ เหมือนกัน

วิธีใช้

ปกติ สมมุติผมจะเขียน DAO ซักตัว ผมก็ร่าง interface มาก่อน เอาแค่ method เดียว สำหรับหา customer (คิดว่า trait = interface)

trait CustomerDao {
  def getCustomerById(id: Long): Customer
}

เสร็จละ ก็มา implement กัน

class JpaBackedCustomerDao extends CustomerDao {
  ...
  def getCusto ...
  ...
}

ยังไม่ทันเขียน method เสร็จ แว้บแรกที่ต้องเข้ามาในหัวแน่ ๆ คือ แล้วถ้ามันหาไม่เจอล่ะ ?? return null ก็ไม่ควร ปิ๊ง ใช้ Option สิ เพราะ Option มันสื่อว่า "อาจจะมีหรือไม่มีส่งกลับไปให้ก็ได้" คิดได้เราก็แก้ interface (trait) เราทันที

trait CustomerDao {
  def getCustomerById(id: Long): Option[Customer]
}

สบายใจแล้วก็ implement ต่อได้ แต่พอดี customer ที่ JPA (java) มัน return มาอาจจะเป็น null ก็ได้ เลยต้องป้องกันซักหน่อย (สีเทา ๆ คือไม่ต้องเขียนครับ ใน scala จะไม่ใช้ keyword 'return')

class JpaBackedCustomerDao extends CustomerDao {
  ...
  def getCustomerById(id: Long): Option[Customer] = {
    val entityManager = ...
    val customer = entityManager.find(...)
    if (customer != null)
      return Some(customer)
    else
      return None
  }
  ...
}

ส่วนคนนำไปใช้ ก็ไม่ต้องกลัวลืมเช็ค if null อีก เพราะ compiler จะบังคับให้เราจัดการกับ Option ว่าถ้าออก Some จะทำยังไง ออก None จะทำยังไง

def whateverTheNameIs {
  ...
  val customer = customerDao.getCustomerById(theId)
  ...
  ...
  // แบบนี้ compiler ไม่ยอม ไม่ปล่อยผ่าน
  println(customer.name)
  ...
  ...
  // compile ผ่าน แต่ไม่ควรทำ
  // เพราะเจอ exception ตอน runtime ถ้า customer เป็น none
  println(customer.get.name)
  ...
  ...
  // ต้องแบบนี้
  customer match {
    case Some(c) =>
      ... // ทำไรก็ทำไป c คือ customer ในกรณีที่หาเจอ
    case None =>
      ... // หาไม่เจอทำยังไง
  }
  ...
  ...
}

อาจจะดูว่า syntax อะไร ทำไมยุ่งยาก อันนี้ผมโชว์ให้ดูว่าเค้าตั้งใจให้เรา handle กรณีต่าง ๆ ให้ครบ ถ้าเขียนจริง ๆ มันจะมี method อื่น ๆ ให้เราใช้แบบกระชับ ๆ เช่น

val young = customer.exists(c => c.age < 25)

อันนี้จะเป็น true เมื่อ customer เป็น some และไส้ข้างในมี field age ที่น้อยกว่า 25 ถ้าเป็น none หรือ age >= 25 ก็จะได้ false โดย exists คือใช้ test สิ่งที่อยู่ใน "กล่อง" ใบนี้ ส่วนจะ test ยังไง เราก็ส่ง function c.age < 25 รึเปล่าเข้าไป

อีกตัวอย่าง

val fullName = customer.map(c => c.firstName + " " + c.lastName).getOrElse("no customer")

ก็จะได้ค่าชื่อและนามสกุลต่อกัน ในกรณีที่ customer เป็น some และได้คำว่า "no customer" ในกรณีที่ customer เป็น none โดย map คือการหยิบของในกล่องขึ้นมา ทำ ๆ ๆ ๆ เสร็จแล้วก็จับยัดลงกล่อง ถ้าเป็นกล่องเปล่า map แล้วก็จะได้กล่องเปล่าเหมือนเดิม ส่วน getOrElse คือการ "เปิดกล่อง" แบบปลอดภัย เพราะถ้ามีของในกล่องมันก็จะคืนค่านั้น แต่ถ้าไม่มีมันก็จะเอาค่าที่เราให้มันเป็น default คืนไปแทน ซึ่งในที่นี้ก็คือ "no customer"

หรือสุดท้าย สมมุติเราจะโอนเงิน เรามี

def transfer(dest: Account, src: Account, amount: BigDecimal): TransferResult = { ... }
val amount = ...
val srcAccount = findAccount(...)
val destAccount = findAccount(...)

ซึ่งเป็น account เราเป็น option ทั้งคู่ เราต้องการโอนเงินเฉพาะกรณีที่ทั้ง src และ dest มีค่า เราสามารถเอามามันมาเชื่อมกันได้โดยใช้ flatMap แบบนี้

val result = srcAccount.flatMap { src =>
  destAccount.filter(_ != src).map { dest =>
    transfer(dest, src, amount)
  }
} getOrElse TransferFailureBadAccount

เราก็จะได้ result (enum) ที่มีค่าตามผลการ transfer ว่าสำเร็จหรือเจ๊งเพราะอะไร ส่วนถ้าหา account ใด account หนึ่งไม่เจอ ก็จะได้ TransferFailureBadAccount เอาไปจัดการต่อได้

ถ้าชอบเขียนพรืด ๆ ต่อกัน ก็แบบนี้

srcAccount flatMap (src => destAccount filter (_ != src) map (dest => transfer(src, dest, amount)) getOrElse TransferFailureBadAccount

หรือจะเขียนเป็น for comprehension เลยก็ได้ แล้วแต่ใครคิดว่าแบบไหนสวยงาม (เดี๋ยว compiler ก็แปลงไปเรียก flatMap + map + filter อยู่ดี)

(for {
  src <- srcAccount
  dest <- destAccount if dest != src
} yield {
  transfer(dest,src,amount)
}) getOrElse TransferFailureBadAccount

สุดท้าย ไม่ใช่แค่โค้ดที่เราเขียนกันเองเท่านั้นที่ใช้ Option แต่โค้ดของ library มาตรฐานของ scala ก็ใช้ด้วย เรียกว่าเป็น style ของภาษาที่โปรแกรมเมอร์นิสัยดีพึงทำ ตัวอย่าง data structure ยอดนิยม เช่น List และ Map

val myNonEmptyList = List(1,2,3,4,5)
val myEmptyList = List[Int]()
myNonEmptyList.head        // ได้ 1
myNonEmptyList.headOption  // ได้ Some(1)
myEmptyList.headOption     // ได้ None
myEmptyList.head           // ได้ runtime exception

val scoreMap = Map("john" -> 55, "eric" -> 57, "jessy" -> 62)
scoreMap("john")           // ได้ 55
scoreMap.get("john")       // ได้ Some(55)
scoreMap.get("rob")        // ได้ None
scoreMap("rob")            // ได้ runtime exception

ก็เป็นงานของเราที่ต้องเลือกใช้ให้เหมาะ แต่ใครจะเลือกแบบที่มี exception ล่ะ จริงมั้ยครับ ?

No comments:

Post a Comment