8 กระบวนท่า ‘Optional’ ในภาษา Swift

I'Boss Potiwarakorn
6 min readMar 29, 2017

บังเอิญว่าผมอาจจะต้องเขียน mobile application เลยมาจับ Swift ซะหน่อย บังเอิญว่าทั่น Erik Meijer (ผู้สร้าง LINQ และ Rx) แกบอกว่า

ถ้าเฮียจะพูดขนาดนี้ ไหนขอลอง functional Swift ซะหน่อยซิ

ลองไปลองมา ก็ไปสะดุดกับ Optional (หนึ่งในวิชามารของ functional programming) เพราะว่าหน้าตามันประหลาดกว่าชาวบ้าน อย่าง Scala, Java 8 อยู่พอสมควร แล้วมีวิธีจะใช้งานมันแบบประหลาดๆ เยอะมากเลย ไม่แน่ใจว่าดีหรือไม่ดีเหมือนกัน เดี๋ยวเรามาดูกัน

แล้ว Optional มันคืออะไร?

สำหรับใครที่ไม่รู้ว่า Optional มันคืออะไร ถ้าให้แปล Optional แล้วล่ะก็ คงจะแปลเป็นไทยได้ว่า type นี้มันอาจจะมีหรือไม่มีตัวตนอยู่ก็ได้

เรื่องนี้เกี่ยวข้องกับ null reference (ใน Swift คือnil) ซึ่งหลายคนอาจจะเคยได้ยินแล้วว่า Tony Hoare คนที่คิดค้น null reference ขึ้นมาเพื่อใช้ในภาษา ALGOL W (และเป็นคนเดียวกับคนสร้าง CSP ซึ่งเป็น formal language เบื้องหลัง actor model และ go routine ด้วย) แกบอกว่า null reference เนี่ย

I call it my billion-dollar mistake

แกอดไม่ได้ที่จะใส่ null reference ลงไปเพราะมัน implement ง่าย แต่ผลที่ตามมาคือ error มากมาย, ช่องโหว่, crash ซึ่งคงจะทำให้สูญเสียกันเป็นพันล้านดอลล่า และผู้คนต้องปวดใจกันมาตลอด 40 ปี…

แล้วชาว functional แก้ปัญหานี้ยังไง?

อะไรปัญหาเยอะ ก็เอามันไป “ห่อ” ซะสิ

พอเราห่อมันแล้ว ชีวิตก็ไม่ต้องมาคอย if value == nil อยู่ตลอดเวลา ก็ใช้มันแบบที่มันห่อๆ ไว้แล้ว แล้วค่อยไปจัดการกับมันตอนปลายทางแล้วกัน

*จากนี้ไป แนะนำให้เปิด playground มาพิมพ์เล่นตามไปด้วยเพื่อเพิ่มอรรถรส!

หน้าตามันเป็นอย่างงี้ครับ

let optionalNumber: Int? = 1
let optionalString: String? = nil

ตอนแรกที่ผมเห็นผมก็งงๆ หน่อยว่ามันทำอะไรของมัน

สุดท้ายผมพบว่ามันเป็น syntactic sugar เฉยๆ ความจริงมันมีค่าเท่ากับแบบนี้

let optionalNumber: Optional<Int> = Optional(1)
let optionalString: Optional<String> = Optional.none

จะเห็นได้ว่าเจ้า ? เนี่ยมันไปแอบห่อเจ้าก้อนหลัง = ให้ด้วย Optional ซึ่งก็คิดซะว่า ไอที่ประกาศไว้นี่จะเป็น Int String หรือไม่มีอะไรอยู่ในกอไผ่ก็เป็นได้ ซึ่งทำให้มันเห็นได้ชัดมากๆ ว่าตรงไหนที่มันมีโอกาสจะไม่มีอะไรเลยได้บ้าง จะได้จัดการกับมันถูก

แถมถ้าลองแบบนี้ดู เราจะ assign nil ให้กับ type String

let optionalString: String = nil// => error: nil cannot initialize specified type 'String' 
let optionalString: String = nil

โดนด่าเลย :(

เราเลยมั่นใจได้ว่า ไม่ว่ายังไง ถ้ามันไม่ได้เขียน type แบบมี ? มา แม่งไม่ nil แน่ๆ

นอกจากเราจะแอบห่อตัวแปรได้แล้ว เรายังแอบห่อผลลัพธ์ของทั้ง function ได้อีกด้วย

func half(number: Int) -> Int? {
return number % 2 == 0 ? number / 2 : nil
}
half(number: 10) // => Optional(2)
half(number: 1) // => Optional.none

จะเห็นได้ว่า แม้ว่าเรา return ตัวเลข หรือ nil เปล่าๆ ไว้ แต่ return type บอกว่าเป็น Int? มันเลยห่อผลลัพธ์ด้วย Optional ให้เรียบร้อย

ห่อเอาไว้แล้วจะใช้งานมันได้ยังไง

โดยไอเดียแล้ว Optional มีไว้เพื่อให้ใช้งานแบบห่อๆ แล้วส่งต่อห่อไปเรื่อยๆ ตาม call stack แล้วค่อยไปแกะที่ปลายทาง ก่อนที่จะมาพูดเรื่องแกะห่อ เรามาพูดถึงเรื่องการแปลงร่างของในห่อโดยไม่ต้องแกะกันก่อนดีกว่า

อันนี้เป็นท่ามาตรฐานของบรรดาของที่ถูกห่อ บางคนคงเคยใช้กับพวก array มาแล้ว ซึ่งก็ไม่ต่างกันเท่าไหร่ นั่นก็คือ map และ flatMap นั่นเอง

1) map

สมมติว่าเรามี name: String? อยู่ เราสามารถทำแบบนี้ได้

name.map { s in s.characters.count }

จะเห็นได้ว่า function ที่เราใส่เข้าไปใน map รับ argument เป็น String ธรรมดา ถ้า name: String? = "Boss" map แล้วจะได้ Optional(4)ที่มี type เป็น Int?

สิ่งที่มันทำคือ มันแกะห่อ แล้วเอาของจากในห่อ ไปยัดใส่ function ให้ evaluate จากนั้นก็ยัดกลับเข้าไปในห่ออีกรอบ สุดท้ายแล้ว ผลลัพธ์ของ map จะกลายเป็นอะไรก็ตามที่เป็น return type ของ function ที่ pass ไป แล้วห่อด้วย Optional อีกทีนั่นเอง

แต่ถ้าเป็น name: String? = nil แล้ว name มีจะค่าเท่ากับ Optional.none พอเรา map แล้วจะได้ Optional.none เหมือนเดิม เพราะเมื่อเราจะทำอะไรกับความว่างเปล่า เราก็จะได้ความว่างเปล่ากลับมาเสมอ

2) flatMap

สมมติว่าเรามีเราอยากจะแบ่งครึ่ง 12 ซักสามรอบด้วยเจ้า half ที่เขียนไว้ข้างบน ทำยังไงดี

func half(number: Int) -> Int? {
return number % 2 == 0 ? number / 2 : nil
}

ลอง map ดูแล้วกัน

half(number: 12)
.map(half)
.map(half)
// error: cannot convert value of type '(Int) -> Int?' to expected argument type '(Int?) -> _'

ปรากฏว่า โค้ดข้างบนนี้ใช้ไม่ได้ เพราะผลลัพธ์ของ half(number: 12).map(half) จะกลายเป็น typeInt?? เนื่องจาก ผลลัพธ์จาก type ของ half คือ Int -> Int? พอเรา .map(half) ผลลัพธ์ที่เป็น Int? อยู่แล้วจะถูกห่อด้วย Optional อีกที กลายเป็น Int?? ก็เลยเอาไป .map(half) ต่อไม่ได้ เพราะพอแกะห่อ Int?? จะได้ Int? แต่ half ต้องการ Int เปล่าๆ เป็น argument

ในกรณีนี้ flatMap ช่วยคุณได้

half(number: 12)     // => Optional(6)
.flatMap(half) // => Optional(3)
.flatMap(half) // => Optional.none

สิ่งที่มันทำก็เหมือน map เลยครับ แค่ตอนที่หลังจาก evaluate function ที่ใส่เข้าไปแล้ว มันจะเอาผลลัพธ์ที่ได้มาเนี่ย ไปสลายห่อทิ้ง (flatten) ทำให้ type หลังจาก flatmap ผลลัพธ์จบด้วยห่อชั้นเดียวเสมอ

อันนี้แถม โดยปกติแล้ว Optional จะมี function filter มาให้ โดย pass function ที่ return Bool (predicate function) คือถ้าผ่านเงื่อนไขก็โอเค return ตัวเดิม ไม่ผ่านก็จะกลายเป็น Optional.none

แต่ใน Swift ดันไม่มี!

แต่เราก็ทำให้มันมีได้ไม่ยากเพราะมันมี extension ให้เราใช้ implement เพิ่มเติมจากของที่มีอยู่แล้วได้ (เหมือน Open class ของ Ruby เลย)

extension Optional {
func filter(_ predicate: (Wrapped) throws -> Bool) rethrows -> Wrapped? {
switch self {
case .some(let wrapped):
return try predicate(wrapped) ? wrapped : nil
case .none:
return nil
}
}
}

แล้วเราจะได้ของแบบนี้

let evenNum: Int? = 12
let oddNum: Int? = 3
let noNum: Int? = nil
func isEven(num: Int) -> Bool {
return num % 2 == 0
}
evenNum.filter(isEven) // => Optional(12)
oddNum.filter(isEven) // => Optional.none
noNum.filter(isEven) // => Optional.none

ทั้งนี้ทั้งนั้นmap flatMap และ filter ไม่ได้ไปแก้ค่าของตัวแปร แต่จะ return Optional ตัวใหม่ออกมาทุกครั้ง เพื่อรักษาความ immutable เอาไว้

3) Optional Chaining

สมมติว่าเราต้องการอ้างอิงถึง attribute หรือ function ของของที่ถูกห่อ เราทำอะไรได้บ้าง ตัวอย่างเช่น

class Person {
let name: String
init(name: String) {
self.name = name
}
}
class Dog {
let owner: Person?
init(owner: Person?) {
self.owner = owner
}
}
let ownedDog = Dog(owner: Person(name: "I'Boss"))
let strayDog = Dog(owner: nil)

เราสามารถไปถึงชื่อเจ้าของได้ด้วย map

ownedDog.owner.map { person in person.name } 
// => Optional("I'Boss")

แต่ถ้าใน map ไม่ได้จำเป็นต้องมีอะไรเพิ่มเติมนอกจากเรียก name แล้ว เราสามารถใช้ Optional Chaining แทนได้

ownedDog.owner?.name // => Optional("I'Boss")
strayDog.owner?.name // => Optional.none

ถึงเวลาต้องใช้แล้ว มาแกะห่อกันเถอะ!

เราไม่สามารถให้มันอยู่ในห่อตลอดไปได้ วันนึงเราก็ต้องแกะมันออกมาใช้อยู่ดี ในภาษา Swift มีท่าแกะห่อให้เลือกใช้เยอะมาก ลองไปดูกัน

4) การแกะห่อแบบไม่ปลอดภัย

ถ้าเรามี

let optionalNumber: Int? = 1
let optionalString: String? = nil

เราสามารถทำแบบนี้ได้ครับ

optionalNumber! // => 1

แต่ๆ ถ้าสมมติว่ามันเป็น nil ล่ะ

optionalString!// fatal error: unexpectedly found nil while unwrapping an Optional value

แน่นอนว่ามันเป็น runtime error ไม่ใช่ compile time มันเลยน่ากลัวมาก มันอาจจะทำให้เกิดพฤติกรรมที่เราไม่คาดคิดได้ ถ้าจัดการกับมันไม่ดี

จริงๆ แล้วเจ้า ! นี่มี function ที่ทำหน้าที่เหมือนกัน แล้วชื่อมันสื่อสารออกมาได้ดีมาก นั่นคือ .unsafelyUnwrapped ชื่อก็บอกแล้วว่าไม่ปลอดภัย ถ้าจำเป็นต้องใช้ก็ใช้อย่างระมัดระวัง เลือกใช้ตัวเลือกถัดๆ ไปดีกว่านะครับ

optionalString.unsafelyUnwrapped// fatal error: unexpectedly found nil while unwrapping an Optional value

5) การแกะห่อโดยปริยาย

เราสามารถเขียน ! ต่อท้ายจาก type ได้แบบนี้

let number: Int! = optionalNumber
number // => 1

มันก็ดูคล้ายๆ .unsafelyUnwrapped เนอะ แต่…

let string: String! = optionalString
string // => nil

มันไม่ error? อะไรฟระ?

จริงๆ ผมหลอกคุณ เพราะค่าที่แท้จริงของ number และ string มันเป็นแบบนี้

number // => ImplicitlyUnwrappedOptional(1)
string // => ImplicitlyUnwrappedOptional<String>(nil)

ครับ เจ้า type ที่ตามด้วย ! อย่าง Int เนี่ย มันคือ ImplicitlyUnwrappedOptional<Int> นั่นเอง

แล้วมันมีประโยชน์อะไร?

เรียกได้ว่า มันคือ optional type ที่จะแกะห่อ (เหมือนที่เราเรียก ! ต่อท้าย) แทนเราให้เองตอนที่พยายามใช้มัน เช่น

optionalNumber! + 1 // => 2
number + 1 // => 2
optionalString!.capitalized // fatal error: unexpectedly found nil while unwrapping an Optional valuestring.capitalized

//
fatal error: unexpectedly found nil while unwrapping an Optional value

ซึ่งเจ้า ImplicitlyUnwrappedOptional เนี่ย มี use cases ที่น่าสนใจหลายอย่างเวลาที่เรา develop application จริงๆ ลองดูเพิ่มเติมได้ที่นี่

6) nil-coalescing operator `??`

อันนี้ง่ายมาก ถ้าเป็น Java กับ Scala พวก Optional จะมี function orElse หรือ getOrElse อยู่ แปลว่าเราจะเอาค่ามันออกมาจากห่อนั่นแหละ แต่ถ้าไม่มี ก็ขอ default value ให้หน่อย

สำหรับ swift มี syntax น่ารักๆ แบบนี้

let optionalString: String? = nil
optionalString ?? "string is missing" // => "string is missing"

ซึ่งใน official documentation เขาบอกว่า มันคือตัวย่อของ

optionalString != nil ? optionalString! : "string is missing"

ซึ่งวิธีนี้ปลอดภัยในการใช้งาน เพราะยังไงก็ไม่ได้ nil ออกมาให้ fatal error แน่ๆ

7) Optional Binding

แบบแรก if let

“ถ้าไม่ nil ขอเอาค่าไปใช้หน่อย”

if let string = optionalString {
print(string.capitalized)
}

ถ้าค่าใน optionalString เป็น nil ก็จะไม่ทำอะไรเลย แต่ถ้า มีค่าอยู่ block นี้ก็จะทำงาน เจ้า string (แกะห่อแล้ว และเปลี่ยนตัวแรกเป็นอักษรตัวใหญ่) ก็จะถูก print ออกมาทาง console

เราสามารถเพิ่ม condition ได้ด้วย เช่น

if let string = optionalString, string.characters.count > 3 {
print(string.capitalized)
} else {
print("not long enough!")
}

ขา functional (เหมือนผม)อาจจะหงุดหงิด อาจจะหงุดหงิดนิดหน่อยเพราะ if ไม่ใช่ expression ทำให้ เราเอาเจ้าก้อนนี้ไป assign หรือ เอาค่าไปใช้ต่อเลยไม่ได้ (เป็นสาเหตุที่ทำให้ผมใช้ตัวอย่างที่มี side-effect อย่าง print)

แน่นอนว่าเราก็ใช้ guard แบบนี้ได้เหมือนกัน วิธีคิดแบบ guard คือเคลียร์อันที่ไม่อยากได้ออกไปก่อน แล้วค่อยทำอันที่สนใจ ซึ่งเอาไว้ใช้กับ loop หรือ function

func guardShortString(optionalString: String?) -> String {
guard let string = optionalString,
string.characters.count > 3 else
{
return "not long enough"
}
return string.capitalized
}
guardShortString(optionalString: "boss") // => "Boss"
guardShortString(optionalString: "me") // => "not long enough"

8) switch และ pattern matching

เราสามารถใช้ switch เพื่อแกะห่อ Optional ออกมาได้แบบนี้

switch optionalString {
case .some(let string):
"the wrapped string is \(string)"
case .none:
"there's nothing here"
}

บางคนก็อาจจะรู้สึกว่า อ๊ะ นี่มัน pattern matching นี่หว่า

บางคนอาจจะพอใจแล้วว่า เอ้อเราสามารถ switch แบบนี้ได้นะ แต่สำหรับขา functional อย่างผมจะหงุดหงิดนิดนึง

ถ้าใครเคยเขียน Scala มาจะเจอของแบบนี้

val s = optionalString match {
case Some(string) => s"the wrapped string is $string"
case None => "there's nothing here"
}

จะเห็นได้ว่าผมสามารถเอาไปใส่ตัวแปรได้เลย if ก็ทำแบบนี้ได้เหมือนกันแต่ Swift ทำไม่ได้ เพราะฉะนั้น โค้ด Swift ข้างบนจะไม่มีประโยชน์อะไรเลย เพราะในแต่ละ case ไม่ได้เอาไปใช้อะไรเลย

จะทำยังไงให้มีประโยชน์โดยไม่ต้องไปแก้ค่าตัวแปร หรือสร้าง side-effect อื่นๆ ? เพราะอยากจะเขียนให้มัน functional

ถ้าเราเขียนโค้ดโดยยึด Single Responsibility Principle อยู่แล้ว สุดท้ายเราคงหนีไม่ค่อยพ้น function เล็กๆ หน้าตาแบบนี้

func describe(optionalString: String?) -> String {
switch optionalString {
case .some(let string):
return "the wrapped string is \(string)"
case .none:
return "there's nothing here"
}
}

ซึ่งสำหรับผมก็พอใจแล้วนะ แต่ถ้าอยาก treat มันเป็น expression แบบที่ Scala ทำข้างบน ก็อาจจะใช้ closure เข้ามาช่วย

let s: String = {
switch optionalString {
case .some(let string):
return "the wrapped string is \(string)"
case .none:
return "there's nothing here"
}
}()

ซึ่งไอเดียคือเราสร้าง anonymous function ที่ในที่นี้มี type () -> String ขึ้นมา แล้วเรียกใช้มันทันที ทุกอย่างจะจบในนั้น แล้วคืนค่าที่เราต้องการออกมาได้ ซึ่งเราก็เอาไปใช้กับ if ข้างบนได้เช่นกัน

บทสรุป

จบไปแล้วสำหรับ 8 กระบวนท่า Optional สำหรับผมแล้ว มันมีท่าให้ใช้เยอะ เหมือนจะสะดวก แต่แอบรู้สึกว่ามันซับซ้อนเกินความจำเป็นไปนิดนึง เพราะต้องทำความเข้าใจ syntax ที่มันไม่ค่อย consistent เท่าไหร่ ซึ่งบางคนก็อาจจะชอบ บางคนอาจจะไม่ชอบ

ในแง่ความสะดวกในการเขียน Swift ใน functional style ผมว่าก็ใช้ได้เลย ถึงแม้จะมีข้อจำกัดบางอย่าง แต่โดยรวมแล้วก็น่าสนใจ

ส่วนใครสงสัยว่า Optional มันเกี่ยวกับ functional programming ยังไง Optional คือ Maybe monad ใน Haskell ถ้าใครสนใจเรื่องนี้ ลองไปดู Functors, Applicatives, And Monads In Pictures ได้ หรือเวอร์ชั่นแปลเป็น swift ก็มีครับ ลองไปดูกัน

ขอบคุณที่รับชม

functional programming FTW!

--

--

I'Boss Potiwarakorn

CTO @ █████; EX-ThoughtWorker; FP, Math, Stats, Blockchain & Human Enthusiast