จากที่โพสไปในคราวที่แล้ว ว่าคนเขียน 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 ล่ะ จริงมั้ยครับ ?