Ý 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 Option
là Some
, 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 Option
là None
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