Scala – Option

Ý tưởng cơ bản

Nếu bạn đã từng làm việc với Scala, trong một vài trường hợp rất có thể bạn đã gặp NullPointerException  (Các ngôn ngữ khác cũng đưa ra một lỗi  tương tự trong trường hợp này). Thông thường điều này xảy ra khi các phương thức/hàm trả về null  và do đó trong client code của bạn không xử lý khi gặp trường hợp này. Giá trị null thường được sử dụng để miêu tả cho việc giá trị lựa chọn không tồn tại

Một vài ngôn ngữ xử lý các giá trị null theo một cách đặc biệt hoặc cho phép bạn làm việc an toàn với các giá trị có thể bị null. Ví dụ, Groovy có toán tử null-safe để truy cập các thuộc tính, để foo?.bar?.baz không đưa ra một lỗi, khi food hoặc thuộc tính bar của nó null, thay vào đó gía trị trả về trực tiếp là null. Tuy nhiên, bạn sẽ dễ dàng bị lỗi nếu quên sử dụng toán tử này và không có gì bắt bạn phải sử dụng nó.

Clojure về cơ bản xử lý giá trị nil như một thứ trống rỗng, ví dụ là một list trống nếu được truy cập như một list, hoặc là một map trống nếu được truy cập như một map. Điều này có nghĩa là giá trị nil được đưa lên call hierarchy. Điều này thường là ổn, nhưng đôi khi điều này chỉ dẫn đến một Exception ở mức cao hơn trong call hierarchy, nơi mà một số đoạn code không phải là nil-thân thiện với tất cả.

Scala cố gắng giải quyết vấn đề này bằng cách loại bỏ hoàn toàn các giá trị null, và cung cấp một kiểu riêng để miêu tả các giá trị không bắt buộc, ví dụ các giá trị có thể được tồn tại hoặc không: Option[A] trait

Option[A] là một container cho giá trị không bắt buộc với kiểu A. Nếu giá trị của kiểu A là tồn tại, thì Option[A] là đối tượng của Some(A), đang chứa giá trị hiện tại kiểu A.  Nếu không có giá trị, thì Option[A] là đối tượng None.

Bằng cách nói rằng một giá trị có thể tồn tại hoặc không trên mức kiểu dữ liệu. Bạn hoặc lập trình viên khác cùng làm việc trên code của bạn bị trình biên dịch yêu cầu phải xử lý với trường hợp này, nhưng bạn không có cách nào có thể dễ dàng xử lý sự tồn tại của giá trị không bắt buộc.

Option bắt buộc!  Không sử dụng null để áp dụng cho giá trị không được gán giá trị.

Tạo Option

Thông thường, bạn có thể tạo một Option[A] cho giá trị hiện tại bằng cách gán giá trị trực tiếp cho case class Some

val greeting: Option[String] = Some("Hello world")

Hoặc nếu bạn biết rằng giá trị không được gán, thì bạn đơn giản gán hoặc trả về một object None

val greeting: Option[String] = None

Tuy nhiên, thời gian và một lần nữa bạn sẽ cần tương thích với thư viện Java hoặc code trong các ngôn ngữ JVM khác, vui vẻ sử dụng null để biểu diễn các giá trị không được gán. Với lý do này, một Option Companion Object (Object và Class cùng tên, cùng file) cung cấp một phương thức factory để tạo None nếu tham số truyền vào là null, nếu không thì giá trị sẽ được đưa vào trong Some

val absentGreeting: Option[String] = Option(null) // absentGreeting will be None
val presentGreeting: Option[String] = Option("Hello!") // presentGreeting will be Some("Hello!")

Làm việc với giá trị Option

Hình dung là bạn đang làm việc dự án quản lý người dùng, và điều đầu tiên mà bạn cần phải thực hiện là một repository của user. Chúng ta cần hỗ trợ tìm kiếm một User bằng id duy nhất. Thỉnh thoảng, các request đến với Id không có thật. Điều này đòi hỏi kiểu trả về là Option[User] cho hàm tìm kiếm. Đoạn code của User Repository sẽ trông như thế này:

case class User(
  id: Int,
  firstName: String,
  lastName: String,
  age: Int,
  gender: Option[String])

object UserRepository {
  private val users = Map(1 -> User(1, "John", "Doe", 32, Some("male")),
                          2 -> User(2, "Johanna", "Doe", 30, None))
  def findById(id: Int): Option[User] = users.get(id)
  def findAll = users.values
}

Bây giờ, nếu bạn nhận được một instace Option[User] từ UserRepository và cần phải làm điều gì đó với nó, thì bạn sẽ làm như thế nào?

Có một cách để kiểm tra giá trị tồn tại hay không bằng cách sử dùng phương thức isDefined của Option, và, nếu nó trả về true, thì sử dụng phương thức get để lấy giá trị.

val user1 = UserRepository.findById(1)
if (user1.isDefined) {
  println(user1.get.firstName)
} // will print "John"

Cái này rất giống với kiểu Option trong thư viện Guava được sử dụng trong Java. Nếu bạn cho rằng cách này thật là rườm rà và mong đợi điều gì đó đơn giản hơn ở Scala, thì bạn đang đi đúng hướng. Quan trọng hơn, nếu bạn sử dụng get, bạn có thể quên kiểm tra isDefined trước đó, dẫn đến một Exception khi chạy, nên bạn sẽ không đạt được nhiều lợi ích hơn sử dụng null

Bạn nên tránh xa sử dụng cách này để truy cập giá trị Option bất cứ khi nào có thể

Cung cấp giá trị mặc định

Rất thường xuyên, bạn mong muốn làm việc với giá trị mặc định hoặc fallback khi giá trị của Option không tồn tại. Trong trường hợp xử lý này bạn có thể sử dụng phương thức getOrElse được định nghĩa trên Option.

val user = User(2, "Johanna", "Doe", 30, None)
println("Gender: " + user.gender.getOrElse("not specified")) // will print "not specified"

Chú ý rằng giá trị mặc định bạn có thể chỉ định như một tham số cho phương thức getOrElse  là một tham số by-name. Nghĩa rằng nó chỉ được tính toán khi phương thức getOrElse được gọi với trường hợp giá trị là None. Vì thế bạn sẽ không phải lo lắng nếu việc tạo giá trị mặc định gây tốn kém vì một lý do nào đấy hoặc lý do khác

Pattern matching

Some là một case class, nên hoàn toàn có thể sử dụng nó trong một pattern, có thể là trong một biểu thức pattern matching chính quy hoặc trong chỗ khác nơi mà pattern được cho phép. Chúng ta sẽ thử viết lại ví dụ trên sử dụng pattern matching

val user = User(2, "Johanna", "Doe", 30, None)
user.gender match {
  case Some(gender) => println("Gender: " + gender)
  case None => println("Gender: not specified")
}

Hoặc là nếu bạn muốn loại bỏ việc sử dùng hàm print nhiều lần và sử dụng một cách thực tế với biểu thức pattern matching

val user = User(2, "Johanna", "Doe", 30, None)
val gender = user.gender match {
  case Some(gender) => gender
  case None => "not specified"
}
println("Gender: " + gender)

Hy vọng bạn sẽ nhận thấy rằng việc sử dụng pattern matching trên một Option instance là khá dài dòng, đó cũng là vì sao cách này không đặc biệt để xử lý Option. Vì thế, ngay cả khi bạn đang rất thích thú về pattern matching, hãy sử dụng các lựa chọn thay thế khác khi làm việc với Option

Có một cách khá đơn giản khi sử dụng các pattern với Option, mà bạn sẽ được học trong phần tiếp theo là for comprehension, bên dưới

Option có thể coi như collections

Cho đến nay, thì bạn vẫn chưa thấy được nhiều sự đơn giản hoặc cách đặc biệt khi làm việc với Option. Chúng ta sẽ được biết ngay bây giờ.

Như tôi đã đề cập ở trên, Option[A] là một container cho giá trị kiểu A, chính xác hơn, bạn có thể coi nó như một kiểu của collection – điểm khác biệt so với collection là nó không chứa hoặc chứa phần tử với kiểu A. Đây là một ý tưởng rất hữu dụng.

Mặc dù trên mức độ kiểu, Option không phải là kiểu collection trong Scala. Option đi kèm với các tính chất tốt nhất mà bạn ưu thích về Scala collection ví dụ như List Set – và nếu bạn thực sự cần, bạn có thể biến đổi một Option thành một List, ví dụ

Vậy thì điều này cho phép bạn làm gì ?

Thực hiện một side-effect nếu giá trị tồn tại

Nếu bạn chỉ cần thực hiện một vài side-effect và nếu giá trị Option là xác định, sử dụng phương thức foreach mà bạn biết đến từ Scala collection sẽ hữu ích:

UserRepository.findById(2).foreach(user => println(user.firstName)) // prints "Johanna"

Hàm được truyền vào foreach sẽ được gọi đúng một lần với OptionSome, hoặc không bao giờ nếu là None

Mapping một Option

Điều thực sự tốt khi dùng Option giống như một collection là bạn có thể làm việc với chúng theo cách rất functional, và cách bạn làm giống hệt như với list, set…

Cũng như bạn có thể map một List[A] thành một List[B], bạn có thể map Option[A] thành Option[B]. Điều này có nghĩa là nếu instance của Option[A] được định nghĩa, ví dụ là Some[A], thì kết quả là Some[B], ngoài ra thì nó là None.

Nào chúng ta sẽ thử lấy age của một Option[User]:

val age = UserRepository.findById(1).map(_.age) // age is Some(32)

flatMap và Option

Tương tự, chúng ta sẽ thử lấy gender

val gender = UserRepository.findById(1).map(_.gender) // gender is an Option[Option[String]]

Kiểu của kết quả trả về là Option[Option[String]]. Tại sao vậy?

Hãy nghĩ về nó như thế này: Bạn có một container Option cho một User và bên trong container bạn đang map từ đối tượng User thành một Option[String], vì nó là kiểu của thuộc tính gender trên lớp User

Những Option được lồng vào nhau gây phiền toái? Tại sao, không vấn đề, giống như tất cả các collection, Option cũng cung cấp phương thức flatMap. Cũng giống như bạn có thể flatMap một List[List[A]] thành List[A], bạn có thể làm tương tự với Option[Option[A]]

val gender1 = UserRepository.findById(1).flatMap(_.gender) // gender is Some("male")
val gender2 = UserRepository.findById(2).flatMap(_.gender) // gender is None
val gender3 = UserRepository.findById(3).flatMap(_.gender) // gender is None

Kiểu kết quả trả về bây giờ là Option[String]. Nếu User được định nghĩa, gender được định nghĩa, chúng ta sẽ có được nó như một Some được làm phẳng. Nếu hoặc User hoặc gender của nó không xác định, chúng ta sẽ thu được None.

Để hiểu cách thức hoạt động, chúng ta hãy xem điều gì xảy ra, khi map làm phẳng một danh sách của danh sách các String, hãy luôn nhớ rằng Option cũng chỉ là một collection, giống như một List

val names: List[List[String]] =
  List(List("John", "Johanna", "Daniel"), List(), List("Doe", "Westheide"))
names.map(_.map(_.toUpperCase))
// results in List(List("JOHN", "JOHANNA", "DANIEL"), List(), List("DOE", "WESTHEIDE"))
names.flatMap(_.map(_.toUpperCase))
// results in List("JOHN", "JOHANNA", "DANIEL", "DOE", "WESTHEIDE")

Nếu chúng ta sử dụng flatMap, các phần tử được map ở bên trong List bị biến đổi thành một List phẳng đơn của String.  Chắc chắn, các List trống bên trong sẽ biến mất sau khi dùng flatMap.

Để dẫn chúng ta quay lại kiểu Option, cân nhắc những gì sẽ xảy ra nếu bạn map một List các Option của String

val names: List[Option[String]] = List(Some("Johanna"), None, Some("Daniel"))
names.map(_.map(_.toUpperCase)) // List(Some("JOHANNA"), None, Some("DANIEL"))
names.flatMap(xs => xs.map(_.toUpperCase)) // List("JOHANNA", "DANIEL")

Nếu bạn chỉ map một List các Option thì kiểu kết quả trả về giữ nguyên List[Option[String]]. Sử dụng flatMap, tất cả các phần tử của collections bên trong được đưa vào một List phẳng: một phần tử của Some[String] bất kỳ trong List ban đầu được bóc tách ra và đặt trong một kết quả là List. Trong khi đó bất kỳ giá trị None nào trong List ban đầu sẽ không chứa bất kỳ phần tử nào cần được bóc tách. Do đó, các giá trị None được loại bỏ hiệu quả

Filter một Option

Bạn có thể filter một Option giống như bạn filter một List. Nếu Option[A] được định nghĩa, ví dụ nó là một Some[A] và điều kiện filter được truyền vào trả về true, Some được trả về. Nếu OptionNone hoặc điều kiện filter là false cho giá trị được đặt trong Some thì kết quả là None

UserRepository.findById(1).filter(_.age > 30) // Some(user), because age is > 30
UserRepository.findById(2).filter(_.age > 30) // None, because age is <= 30
UserRepository.findById(3).filter(_.age > 30) // None, because user is already None

For comprehensions

Nào bây giờ thì bạn đã biết rằng Option có thể được sử dụng như một collection và cung cấp map, flatMap, filter và các phương thức khác như bạn biết trong collection, bạn có thể sẽ nghi ngờ rằng Option có thể được sử dụng trong for comprehension. Thường đây là cách dễ đọc nhất khi làm việc với Option, đặc biết nếu bạn phải ngoắc nối gọi nhiều map, flatMap và filter. Nếu chỉ là một map đơn, dùng như thế là thích hợp hơn vì nó it rườm rà hơn.

Nếu bạn muốn lấy gender của một User, chúng ta có thể áp dụng for comprehension

for {
  user <- UserRepository.findById(1)
  gender <- user.gender
} yield gender // results in Some("male")

Như bạn có thể đã biết khi làm việc với List, đoạn trên tương đương với việc dùng flatMap. Nếu UserRepository trả về None hoặc gender trả về None thì For Comprehension trả về None. Đối với User trong ví dụ, gender được định nghĩa, nên nó trả về Some

Nếu bạn muốn thu được toàn bộ gender của tất cả User đã xác định, chúng ta có thể lặp tất cả các User và với mỗi User thì đưa ra một gender, nếu nó được định nghĩa

for {
  user <- UserRepository.findAll
  gender <- user.gender
} yield gender

Sau khi chúng ta làm phẳng giá trị hiệu quả, kiểu kết quả trả về là List[String] và List kết quả là List("male"), vì  gender chỉ tồn tại trong User đầu tiên.

Cách sử dụng left side của generator

Chúng ta có thể viết lại ví dụ trên như sau

for {
  User(_, _, _, _, Some(gender)) <- UserRepository.findAll
} yield gender

Sử dụng pattern Some dùng left side (<-) của Generator giúp loại bỏ tất cả các phần tử khỏi kết qủa mà giá trị tương ứng là None

 

 

 

 

 

You May Also Like

About the Author: Nguyen Dinh Thuc

Leave a Reply

Your email address will not be published.