ป้องกัน Pyramid of Doom และทำการ Early exist out of the function ใน Swift โดยใช้ Guard

Khemmachart Chutapetch
Nextzy
Published in
5 min readDec 29, 2016

ถ้าพูดถึงการ์ด ผมจะนึกถึงการ์ดหน้าผับมากที่สุด สงสัยเพราะไปบ่อย ฮ่าๆ แต่หน้าที่ของการ์ดผับก็คล้ายๆ กับในภาษา Swift คือทำหน้าที่ “ป้องกัน” แล้วมันมีประโยชน์อย่างไร ตามมาดูกันเลยดีกว่า

ผมขอเกริ่นเรื่อง Unwrapped ตัวแปรประเภท Optional ซักเล็กน้อย หากผู้อ่านเข้าใจเกี่ยวกับ Optional type มาบ้างแล้ว แล้วสามารถข้ามไปอ่านหัวข้อ ปัญหาจากการใช้ If-statement, Pyramid of Doom, และ Guard ได้เลยครับ

ตัวแปรประเภท Optional

ในภาษา Swift นั้น หากเรามั่นใจว่าตัวแปรของเรามีสิทธิจะเป็น nil เราสามารถประกาศตัวแปรนั้นเป็นประเภท Optional ได้โดยใส่เครื่องหมาย Question mark (?) ไว้ที่ข้างหลังประเภทของตัวแปรได้เช่น

var firstname: String? = nil

โดยถ้าหากเราไม่ประกาศเป็นตัวแปรประเภท Optional แล้วเราจะไม่สามารถ Assign ตัวแปรนั้นเป็น nil ได้ เช่น

var lastname:  String  = nil 
// Nil cannot initialize specified type 'String'

หากลองสังเกตุจาก Error message จะรู้ว่าตัวแปร String? กับ String นั้นเป็นคนละประเภทกัน โดยที่ Optional นั้นคือ enumeration ที่มี 2 เคสคือ มีค่า กับ ไม่มีค่า และรับประเภทของตัวแปรที่เราประกาศ (เช่น String) เป็น Generic type เช่น

enum Optional<Wrapped> {
case none
case some(Wrapped)
}

โดยที่เราสามารถประกาศตัวแปรประเภท Optional แบบเต็มๆ ได้โดยใช้ Generic type แต่มันก็จะดูยาวไปนิดนึง เช่น

var aStr: Optional<String> = “Khemmachart”

ทำอย่างไรหากต้องการใช้ตัวแปรประเภท Optional

หากเราต้องการเรียกใช้ value จากตัวแปรประเภท Optional หรือตัวแปรที่มีสิทธิเป็น nil ได้นั้น เราจะต้อง unwrapped ตัวแปรนั้นก่อนเพื่อให้ได้ Type จริงๆ ของตัวแปรนั้นๆ — เช่นหากทำการ Unwrapped ตัวแปร Optional<String> เราก็จะได้ String

โดยการ unwrapped เนี่ยก็มีหลายวิธี ยกตัวอย่างเช่น การใช้ If statements and Forced Unwrapping ดังตัวอย่างข้างล่าง — ซึ่งมันก็คือใช้ if condition ในการเช็ค nil และการใช้เครื่องหมายตกใจหรือ exclamation mark (!) เพื่อ Forced Unwrapping และดึง value จากตัวแปรออกมาใช้งานนั่นเอง

Forced Unwrapping

var name: String? = "Khemmachart"
if name != nil {
print("Hello, \(name!)")
}

ปัญหาที่ตามมาคือ ทำไมเราต้อง Force Unwrapping ตัวแปรนั้นๆ ด้วยในเมื่อเราเช็ค nil ใน if statement เรียบร้อยแล้ว? ซึ่งผู้พัฒนาภาษา Swift ก็ได้ทำวิธี Optional Binding มาให้เราได้ใช้กัน เช่นตัวอย่างข้างล่าง

Optional Binding

var name: String? = "Khemmachart"
if let bindingName = name {
print("Hello, \(bindingName)")
}

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

ซึ่งผู้อ่านสามารถศึกษาเรื่อง Optional type อย่างละเอียดได้ที่บทความข้างล่างนี้

ปัญหาจากการใช้ If-statement

ปัญหาที่เกิดจากการใช้ If-statement นั้นสามารถพบได้ในทุก Programming language ซึ่งก็คือการใช้ If-statement ซ้อนทับกันไปเรื่อยๆ หรือเรียกว่า Nested-if เราเรียกปัญหานี้ว่า Pyramid of Doom ผมขอพูดถึงปัญหาในการใช้งาน Optional binding และ If-statement ก่อนซักเล็กน้อย จากนั้นจะยกตัวอย่างวิธีแก้ปัญหา Pyramid of Doom ด้วย Guard นะครับ

Pyramid of Doom

จริงอยู่ที่ Optional binding นั้นสามารถแก้ปัญหาเรื่อง Force unwrapping ได้ แต่การใช้ Optional binding นั้นก็คือการใช้ If-statement และถ้าเรามี Optional Object หลายตัวจะเกิดอะไรขึ้น? เช่น เรามีข้อมูลของผู้ใช้ดังนี้

class User {
var profile: Profile?
var display: UIImage?
}
class Profile {
var name: String?
var address: String?
}

เวลาที่จะ Binding ข้อมูลก้จะต้องใช้ If statement หลายชั้น (Nested if) และทำให้เกิดเป็น Pyramid of Doom ได้ เช่น

if let user = self.user {
if let profile = user.profile {
if let name = profile.name {
// Do something with name
}
if let address = profile.address {
// Do something with address
}
}
if let display = user.display {
// Do something with display
}
}

ตามตัวอย่างนี้แค่ Pyramid 3 ชั้นนะครับ ซึ่งจริงๆ อาจจะมีมากกว่านี้ก็เป็นไปได้ ไม่อยากจะคิดเลยแฮะ ฮ่าๆ — หรือ ใครอาจจะป้องกันการเกิด Pyramid of Doom ด้วยการเขียนแบบนี้

if self.user == nil {
return
}
if self.user.profile == nil {
return
}
if let display = user!.display {
// Do something with display
}
if let name = user!.profile!.name {
// Do something with name
}
if let address = user!.profile!.address {
// Do something with address
}

ก็จะสามารถพังทลายจำนวนชั้นของ Pyramid ลงได้ แต่ก็จะเกิดปัญหาที่กล่าวไปตอนแรกอยู่ คือ เราต้องมาใช้วิธี Force Unwrapping ตัวแปรทั้งๆ ที่เราทำการเช็ค nil แล้ว ซึ่งจจริงๆ แล้วการใช้ Force Unwrapping เนี่ยอาจจะทำให้แอพ crash ได้ ดั้งนั้นเราควร หลีกเลียงการใช้ Force Unwrapping ให้ได้มากที่สุด หรือไม่มีการใช้ Force Unwrapping ในโปรเจคเลยจะดีมากครับ

Guard

จากปัญหา Nested-If ทำให้เกิดคำสั่งที่ชื่อว่า guard — คำสั่ง guard เปิดตัวใน Apple’s Platform State of the Union และถูกเพิ่มเข้ามาใน Swift เวอร์ชั่น 2 เป็นต้นไป

Guard เป็น Statement Control ตัวหนึ่ง ทำหน้าที่เลือกว่าจะทำงานต่อหรือทำอย่างอื่น จากเงื่อนไขที่กำหนด คล้ายกับ If-Statment แต่สิ่งที่ต่างกันชัดเจนก็คือโค้ดที่อยู่ในบล็อกคำสั่งจะทำงานก็ต่อเมื่อ เงื่อนไขของ Guard เป็นเท็จ (ลักษณะจะตรงข้ามกับ If-Statement) หรือคล้ายๆ กับ Assert นั่นเอง — แต่สิ่งที่แตกต่างจาก Assert คือเราสามารถนำตัวแปรที่ประกาศในคำสั่ง guard มาใช้งานได้ ลองดูตัวอย่างต่อไปนี้ และเปรียบเทียบกับโค้ดตัวอย่างข้างบน

guard let user = self.user else { return }   if let display = user.display {
// Do something with display
}
guard let profile = user.profile else { return }if let name = profile.name {
// Do something with name
}
if let address = profile.address {
// Do something with address
}
Do something with user and user's profile

จะเห็นว่าลักษณะของโค้ดคล้ายกับตัวอย่างล่าสุด แต่ในกรณีนี้เราสามารถเรียกใช้ตัวแปรที่เกิดจากคำสั่ง guard ได้เลย ไม่จำเป็นต้องใช้ Forced Unwrapping ตัวแปรประเภท Optional อีกต่อไป และปลอดภัยจากการ Crash

เรายังสามารถนำตัวแปลที่สร้างจาก Guard condition มาใช้ซ้ำได้ ต่างกับ If-let ที่ตัวแปรนั้นจะชีวิตอยู่แค่ใน Scope

ถ้าหากใครยังสับสบว่าเราจะเขียน Guard condition ได้อย่างไร ลองดูตัวอย่างต่อไปนี้ครับ อธิบายง่ายๆ คือให้เราเขียน Condition ของ If statement

let password = "myPassword1234"
let isPasswordLongerThan6Characters = password.characters.count > 6
if isPasswordLongerThan6Characters {
// Statement ส่วนที่เราต้องการ
print("Valided")
} else {
// Statement ส่วนที่เราไม่ต้องการ
print("Invalid, the password is too short")
}
guard isPasswordLongerThan6Characters else {
// Statement ส่วนที่เราไม่ต้องการ
print("Invalid, the password is too short")
return
}
// Statement ส่วนที่เราต้องการ
// เขียนให้ตรงกับ Statement ใน if
print("Valided")

และผลลัพธ์ที่ได้หลังจากรันโค้ดข้างบนคือมีการแสดง Valided สองครั้งที่หน้าจอครับ

Guard — Avoiding the Pyramid of Doom

ประโยชน์หลักๆ ของ Guard คือป้องกันการเกิด Pyramid of Doom หรือการใช้ If-Statement ซ้อนกันไปเรื่อยๆ จนเป็น Nested-If หลายชั้น ลองดูตัวอย่างต่อไปนี้

enum CircleAreaCalculationError: Error {
case notSpecified
case invalid
}
func calculateCirlceArea(radius:Double?) throws -> Double { if let radius = radius {
if radius > 0 {
return radius * radius * Double.pi
} else {
throw CircleAreaCalculationError.invalid
}
} else {
throw CircleAreaCalculationError.notSpecified
}
}

จะเห็นว่าเราจำเป็นต้องใช้ Nested-If เพื่อใช้ในการ Throws error ออกไป ซึ่งหลายคนมองว่าการใช้ Nested-If ทำให้โค้ดอ่านได้ยากขึ้น เช่น เราไม่รู้ว่า else นี้คือเงื่อนไขอะไร? เราควรจะรู้ได้ทันทีว่า ทำไมถึง throws error invalid ออกไป ในตอนนที่เราอ่านโค้ด Else-Statment แต่ในกรณีนี้ เราต้องกลับอ่านเงื่อนไขในส่วนของ If-Statement ทำให้เสียเวลาและเข้าใจยากมากยิ่งขึ้น— ลองจินตนาการว่าเรามีหลาย ErrorType มากกว่านี้ เราก็ทำเป็นต้องใช้ Neted-If หลายชั้นมากยิ่งขึ้น ยิ่งทำให้เราปวดหัวเข้าใจไปอีกจริงไหมครับ

enum CircleAreaCalculationError: Error {
case notSpecified
case invalid
}
func calculateCirlceArea(radius: Double?) throws -> Double { guard let radius = radius else {
throw CircleAreaCalculationError.notSpecified
}
guard radius > 0 else {
throw CircleAreaCalculationError.invalid
}
return radius * radius * Double.pi
}

หลังจากที่เราเปลี่ยนใช้ Guard แทน If-Statement จะเห็นได้ว่าโค้ดสามารถอ่านได้ง่ายมากยิ่งขึ้น เราสามารถเข้าใจได้ทันทีว่าาก Redius ไม่มีค่าให้ return notSpecified หรือ Radius เป็น 0 ให้ return invalid แต่ถ้าไม่ใช่ทั้งสองกรณีก็ให้ทำ Radius มาคำนวนและ return ผลลัพธ์กลับไป

Guard — Early exist out of the function

จากตัวอย่างข้างต้นถ้าหากว่าใน Else-Statement นั้นไม่ได้ทำอะไรเลย นอกจาก Return ค่า ทำไมทั้งเราและคอมพิวเตอร์ต้องเสียเวลามานั่งอ่าน If-Statment ที่ไม่เป็นจริงด้วย — ดังนั้นเราควรออกจากฟังก์ชั่นให้เร็วที่สุดหากเงื่อนไขที่ต้องใช้ในฟังก์ชันชั้นเป็นเท็จ

Exiting early allows you to pop stuff off your limited mental stack

enum CircleAreaCalculationError: Error {
case notSpecified
case invalid
}
func calculateCirlceArea(radius: Double?) throws -> Double { guard let radius = radius else {
throw CircleAreaCalculationError.notSpecified
}
guard radius > 0 else {
throw CircleAreaCalculationError.invalid
}
return radius * radius * Double.pi
}

ลองกลับมาดูตัวอย่างล่าสุด เราจะเห็นว่า หากตัวแปร radius ไม่มีค่าหรือ มีค่าน้อยกว่า 0 ให้ทำการออกจากฟังก์ชัน (หรือออกจาก loop โดยใช้ break)

สรุปบทความ

คำสั่ง Guard นั้นเกิดมาเพื่อ ป้องกันการเกิด Pyramid of Doom หรือ Nested-if หลายชั้น และยังสดวกต่อการทำ Early exist out of the function อีกด้วย ซึ่งจุดประสงค์ของทั้งสองวิธีนี้คือช่วยให้โค้ดเราสามารถอ่านได้ง่ายยิ่งขึ้น

อีกหนึ่งข้อดีของการใช้ Guard คือ Optional binding เราสามารถทำตัวแปรที่เกิดจากการ Optional binding ไปใช้ที่อื่นได้ (อยู่ภายใน Scope ที่ Guard นั้นอยู่) ต่างกับการใช้ If-let ที่ตัวแปรนั้นจะมีชีวิตอยู่ได้แค่ภายใน Scope ของ If-Statment เท่านั้น

โดย การเขียน Condition ของ Gaurd คือเขียนให้ตรงกับ Condition ของ If-Statement หากมีคำถามเพิ่มเติมสามารถพูดคุยกันได้ที่ Comments เลยครับผม :D

--

--