Lớp (class) là khái niệm cơ bản nhất trong lập trình hướng đối tượng. Nó đặc trưng cho 1 loại đối tượng xác định, với những thuộc tính và hành động xác định. Lớp bao gồm các thuộc tính và các phương thức. Hôm nay, hãy cùng mình tìm hiểu về khái niệm cơ bản nhưng cũng là quan trọng nhất của lập trình hướng đối tượng này nhé.
Định nghĩa lớp
Ta định nghĩa lớp qua từ khóa class, điều này khá giống với Java:
class Human
Dòng code trên vừa khai báo 1 lớp rỗng, lớp đơn giản nhất trong lập trình hướng đối tượng. Nó không có thuộc tính cũng chẳng có thân hàm, tuy nhiên ta vẫn có thể khởi tạo 1 đối tượng của lớp Human trên như sau:
val human = Human()
Như vậy là không giống với Java, ta không cần từ khóa new để khởi tạo 1 đối tượng mới.
Thuộc tính và hàm khởi tạo của lớp
Lớp ta vừa mới khởi tạo ở phần trước còn khá đơn giản, giờ ta sẽ thêm vài thuộc tính cho nó, đồng thời sẽ thêm cách khởi tạo mới cho class này:
class Human(var name: String, var age: Int) { }
Lớp Human đã được bổ sung 2 thuộc tính là name và age. Và nó có hàm khởi tạo gồm 2 tham số đầu vào kiểu String và int. (var name: String, var age: Int) được gọi là primary constructor (hàm khởi tạo chính).
Vậy là với đúng 1 dòng, ta đã vừa khai báo được các thuộc tính của class Human, vừa định nghĩa được hàm khởi tạo ứng với 2 thuộc tính đó. Rất ngắn gọn và dễ hiểu đúng không. Giờ ta sẽ thử khởi tạo 1 đối tượng Human bằng hàm khởi tạo ta vừa định nghĩa:
var me: Human = Human("Dương Vũ", 25)
Đơn giản là khai báo biến có kiểu dữ liệu là Human và khởi tạo cho nó! Theo đúng định nghĩa lớp viết ra ở trên, mình dùng hàm khởi tạo có các tham số đầu vào là String và Int, tương ứng với tên và tuổi. Giờ mình sẽ lấy ra giá trị các thuộc tính của thằng me mà mình vừa khởi tạo:
print("name = ${me.name}, age = ${me.age}") // In ra: name = Dương Vũ, age = 25
Cách truy xuất đến thuộc tính của đối tượng rất đơn giản dễ dàng đúng ko. Ta sử dụng cú pháp:
<tên_đối_tượng>.<tên_thuộc_tính>
để truy xuất đến thuộc tính của đối tượng me.
Định nghĩa phương thức cho lớp
Từ đầu đến giờ ta đã định nghĩa class Human, có các thuộc tính của riêng nó, nhưng chưa định nghĩa các hành động cho nó thực hiện. Ta sẽ viết định nghĩa 1 phương thức nằm trong class Human như thế này:
fun introduce() { print("Hello, I am $name, I am $age years old") }
Như vậy việc định nghĩa phương thức trong class đơn giản chỉ là định nghĩa hàm để xử lý các thuộc tính trong class đó. Như ở trên ta đã xây dựng 1 phương thức cho phép Human giới thiệu về bản thân mình. Phương thức này sẽ log ra tên, tuổi của đối tượng gọi phương thức:
var me: Human = Human("Dương Vũ", 25) me.introduce() // in ra: Hello, I am Dương Vũ, I am 25 years old
Như vậy là phương thức introduce đã hoạt động. Ta thấy, khi ở bên trong thân class, thì truy xuất đến các thuộc tính đơn giản là gọi thẳng tên thuộc tính đó ra: $name, $age. Còn ở bên ngoài class ta cần sử dụng cú pháp <tên_đối_tượng>.<tên thuộc tính>. Mình sẽ có 1 bài viết riêng về các quy tắc truy xuất đến thuộc tính và phương thức của 1 lớp để các bạn hiểu rõ hơn. Còn giờ thì ta cứ biết cơ bản là vậy đã, ok 😉
Ok, giờ ta sẽ tạo 1 phương thức khác cho quen tay. Thêm hàm sau vào lớp Human:
fun greeting(){ print("From $name with love") }
Ta sẽ gọi hàm greeting này:
var harry = Human("Harry", 15) // Khởi tạo đối tượng harry harry.greeting() //In ra: From Harry with love
Block init
Trong Kotlin class, ta có thể định nghĩa ra 1 block init, bao gồm các câu lệnh được thực hiện ngay sau khi đối tượng của lớp đó được khởi tạo. Ví dụ nhé, ta thêm khối lệnh sau vào lớp Human:
init { println("$name has been born") }
Block init này thực hiện việc log ra dòng thông báo ngay sau khi mỗi đối tượng của class Human được khởi tạo. Thử khởi tạo 1 đối tượng mới:
var peter = Human("Peter", 15) // In ra: Peter has been born peter.introduce() // in ra: Hello, I am Peter, I am 15 years old
Như ta thấy các câu lệnh trong block init đã được gọi ngay sau khi đối tượng peter được tạo ra. Và tất nhiên là trước khi phương thức introduce được thực hiện.
Từ khóa this
Trong 1 lớp , từ khoá this trỏ tới đối tượng hiện tại của lớp đó. Nghe hơi trúc trắc nhỉ, mình sẽ demo ngay để các bạn dễ hình dung. Ta viết 1 phương thức như sau:
fun increaseAge() { this.age += 1 }
Phương thức này tăng giá trị của thuộc tính age lên 1 đơn vị. Ở đây, từ khoá this trỏ đến đối tượng hiện tại, tức là đối tượng đang gọi phương thức increaseAge này, và thay đổi thuộc tính age của đối tượng đó. Ta thử gọi phương thức:
var human = Human("Nam", 20) human.increaseAge() human.introduce() //In ra: Hello, I am Nam, I am 21 years old
Secondary constructor
Một lớp có thể có nhiều hàm khởi tạo khác nhau. Secondary constructor là các hàm khởi tạo khác với hàm khởi tạo chính (primary constructor). Mình sẽ lấy thêm 1 ví dụ khác cho các bạn hiểu rõ hơn nữa nhé. Ta sẽ tạo 1 lớp Animal. Đầu tiên, khai báo lớp và hàm khởi tạo:
class Animal(var kind:String, var name: String){ }
Animal là lớp đặc trưng cho động vật, có các thuộc tính là kind (loài) và name (tên). Giờ mình sẽ thêm 1 thuộc tính nữa:
var age:Int?=null
Thuộc tính age có kiểu Int. Thuộc tính này có thể có giá trị hoặc null. Nếu bạn còn chưa rõ các khái niệm về nullable type, hãy đọc lại bài hướng dẫn về Nullable type – kiểu dữ liệu có thể null của mình nhé.
Thêm 1 phương thức:
fun getInfo() { print("Hello, i am a $kind, my name is $name") age?.let { print(", i am $age years old") } }
Đơn giản là log ra câu chào ứng với các giá trị của kind và name. Nếu age được gán giá trị (khác null) thì log thêm cả giá trị của age. Toàn bộ lớp Animal của ta sẽ như thế này:
class Animal(var kind: String, var name: String) { var age: Int? = null fun getInfo() { print("Hello, i am a $kind, my name is $name") age?.let { print(", i am $age years old") } } }
Thử khởi tạo 1 đối tượng, và gọi phương thức getInfo:
var cat = Animal("cat", "Miu Miu") cat.age = 1 cat.getInfo() //In ra: Hello, i am a cat, my name is Miu Miu, i am 1 years old
Tạo 1 đối tượng Animal khác:
var dog = Animal("dog", "Milu") dog.getInfo() // In ra: Hello, i am a dog, my name is Milu
Lần này, dòng log ko có thông báo age vì age đang có giá trị null. Các bạn có thể thấy rằng mỗi khi khởi tạo 1 đối tượng Animal, ngoài kind và name là các thuộc tính bắt buộc phải có trong hàm khởi tạo. Thì thuộc tính age là không bắt buộc. Nếu ta quên không set giá trị cho age thì đương nhiên nó sẽ null. Giờ ta muốn đưa age vào 1 hàm khởi tạo khác mà vẫn muốn giữ hàm khởi tạo ta định nghĩa lúc đầu, phải làm thế nào. Ta sẽ định nghĩa thêm 1 hàm khởi tạo nữa, tất cả (những) hàm khởi tạo được định nghĩa thêm được gọi là Secondary constructor:
constructor ( kind: String, name: String,age: Int) : this(kind, name) { this.age = age }
Ở đây có 2 điều cần nói:
- Secondary constructor phải gọi đến primary constructor: this(kind, name) hoặc gọi đến Secondary constructor nào có gọi primary constructor. Tóm lại bằng mọi giá phải tham chiếu đến primary constructor.
- Secondary constructor không cho phép định nghĩa các thuộc tính, tức là ta không thể sử dụng các từ khóa var, val khi định nghĩa Secondary constructor.
Ta thử khởi tạo 1 phương thức dùng hàm Secondary constructor mới được viết xem sao:
var bird = Animal("bird", "Kiki", 3) bird.getInfo() // In ra: Hello, i am a bird, my name is Kiki, i am 3 years old
Ta sẽ thêm 1 thuộc tính nữa:
var favorite: String? = null
Và thêm 1 hàm khởi tạo nữa:
constructor(kind: String, name: String, age: Int, favorite: String) : this(kind, name, age) { this.favorite = favorite }
Hàm khởi tạo này gọi đến hàm khởi tạo thứ 2 ta định nghĩa ở trên, do hàm khởi tạo thứ 2 đã tham chiếu đến primary constructor, nên hàm khởi tạo thứ 3 này hoàn toàn hợp lệ. Ok, hi vọng các bạn đã hiểu được về secondary constructor.
Getter và setter
Khác với java, mỗi thuộc tính trong các Kotlin class đều được khởi tạo 1 hàm getter và 1 hàm setter mặc định. Hàm getter của 1 thuộc tính được gọi đến khi ta truy xuất đến giá trị của thuộc tính đó. Hàm setter thì được gọi khi ta thay đổi giá trị của thuộc tính. Để ví dụ mình sẽ định nghĩa thêm 1 thuộc tính trong lớp Animal trên và viết getter setter cho nó. Các bạn hãy nhớ kỹ rằng, nếu ta ko viết getter setter cho 1 thuộc tính thì thuộc tính đó sẽ có các hàm getter và setter mặc định, các hàm đó đơn giản chỉ lấy và gán giá trị cho thuộc tính của chúng ta.
var isSafe: Boolean get() { println("Getter of property isSafe called") if (kind.equals("tiger", true) || kind.equals("puma", true)) { return false } else { return true } } set(value) { println("Setter of property isSafe called") }
Giờ thử tạo 1 đối tượng và lấy giá trị của thuộc tính isSafe:
var dog = Animal("dog", "Lili") println("${dog.isSafe}")
Kết quả:
Getter of property isSafe called true
Như vậy khi lấy giá trị của isSafe thì hàm getter ứng với thuộc tính isSafe đã được gọi. Ta sẽ thử thay đổi giá trị của isSafe xem sao:
dog.isSafe = false // In ra: Setter of property isSafe called
Vậy khi thay đổi giá trị của isSafe thì hàm setter đã được gọi.
Lưu ý: Hàm getter và setter thường được viết lại cho các thuộc tính mà giá trị của nó phụ thuộc vào gía trị của các thuộc tính khác. Như ví dụ trên, giá trị của thuộc tính isSafe phụ thuộc vào gía trị của kind. Nếu trong hàm getter hay setter mà các bạn truy xuất đến thuộc tính tương ứng của nó thì sẽ xảy ra lỗi. Mình ví dụ như sau, ta sẽ thay đổi lại nội dung hàm getter ở trên:
get() { return isSafe }
Hàm getter của isSafe trả về đúng giá trị của isSafe nghe hợp lý đúng ko. Thử xem:
var dog = Animal("dog", "Lili") println("${dog.isSafe}")
Kết quả là dòng log đỏ chói : Exception in thread “main” java.lang.StackOverflowError 😀 . Lý do vì bản thân hàm getter ở trên đã return về giá trị của isSafe, mà mỗi khi truy xuất đến isSafe thì hàm getter lại được gọi, điều đó tạo ra 1 vòng lặp không có điểm dừng, đây chính là nguyên nhân exception java.lang.StackOverflowError bị văng ra. Vì vậy, hãy luôn nhớ, chỉ viết getter và setter cho các thuộc tính mà giá trị của nó phụ thuộc vào các thuộc tính khác. Còn nếu không, setter và getter đã được mặc định viết cho chúng ta rồi, chúng ta ko nên chọc vào chúng nữa.