"황영덕 저자"의 코틀린 프로그래밍을 읽고 정리한 글입니다.
보통 클래스
는 속성과 동작을 가지기 때문에 프로퍼티
와 메소드
를 멤버로 가진다. 먄약 특정 동작을 가지지 않고 오로지 데이터 저장을 위해 사용한다면 일반적인 클래스에서의 구현 부분은 필요가 없을 것이다. 구현부를 작성하지 않으면 그만큼 메모리를 덜 사용하게 되고 자원의 낭비를 막을 수 있게 될 것이다. 이처럼 코틀린에서는 데이터 저장을 위한 클래스가 있는데 바로 데이터 클래스
이다.
보통 데이터 전달을 위한 객체를 DTO(Data Transfer Object)
라고 부른다. 구현 로직을 가지고 있지 않고 순수한 데이터 객체를 표현하기 때문에 속성과 속성에 접근하고자 하는 게터와 세터
를 가진다. 추가적으로 toString(), equals()
과 같은 데이터를 표현하거나 비교하는 메소드를 가져야 한다. 자바에서 이를 모두 정의하려면 클래스의 코드가 아주 길어지는데 코틀린에서는 간략하게 표현할 수 있다.
그럼 코틀린에서는 어떻게 표현하나요? 🤔
코틀린은 DTO(Data Transfer Object)
를 위해 데이터 클래스를 정의할 때 위에서 말한 게터와 세터, toString(), equals()
와 같은 메소드를 자동으로 생성해준다. 결과적으로 데이터를 위한 프로퍼티
만 작성해주면 된다.
코틀린에서 자동적으로 생성해주는 메소드는 아래와 같다.
굳이 DTO를 써야하나요? 클래스 하나가 추가되는 거니까 좀 번거로울 거 같은데요? 😮
DTO
는 일종의 표준이다. 프로그램을 개발하다보면 다양한 클래스가 있고 이런 클래스들 사이에 데이터를 주고 받는 일은 당연할 것이다. 프로그램을 좀 더 구조적으로 만들기 위해서는 제어를 위한 컨트롤러(Controller), 사용자와 상호작용을 하는 뷰(View), 데이터 표현을 위한 모델(Model)로 구조화할 수 있는데 (MVC 모델이라고 하는데 요즘엔 좀 바뀌었다. MVVM 쪽으로 가는듯...?), 이 때 DTO는 데이터를 주고 받는 표준 방법이 된다.
데이터 클래스를 선언하기 위해선 data 키워드
가 필요하다.
data class Customer(var name : String, var eamil : String)
또한 아래의 조건을 만족해야한다.
data class Customer(var name : String, var email : String) {
var job : String = "Unknown"
constructor(name : String, email : String, _job : String) : this(name, email) {
job = _job
}
init {
// 객체가 생성될 때 실행할 로직
}
}
하지만 필요에 따라 부생성자나 init 블록은 사용이 가능하다.
데이터 클래스
로 정의된 Customer는 주생성자로 name과 email을 프로퍼티로 가지고 있고, 부생성자를 통해 job을 초기화시키고 있다. 코드상에는 보이지 않지만 코틀린에서 자동으로 생성되는 메소드가 있다. 위에서 말한 메서드들이다. 각 메소드들에 대한 기능은 아래와 같다.
그럼 한번 메소드를 사용해보자. equals
와 hashCode
는 객체를 비교하는 메소드이다.
val cus1 = Customer("kodo", "k906506@naver.com")
val cus2 = Customer("kodo", "k906506@naver.com")
println(cus1 == cus2)
println(cus1.equals(cus2))
println(cus1.hashcode())
println(cus2.hascode())
어떠한 실행 결과가 출력될까?
true
true
-1208413004, -1208413004
예상과 다르게 true와 같은 해시 값이 반환된다. 서로 다른 객체니까 false와 다른 해시 값이 출력될 것 같지만 두 메소드는 객체의 정보(?) 를 비교하는 메소드이다. 따라서 객체에 담긴 정보, 즉 kodo
와 k906506@naver.com
이라는 두 개의 문자열이 같으므로 true를 반환하는 것이다.
copy()
는 프로퍼티만 변경해서 객체를 복사하는 메소드이다.
val cus3 = cus1.copy(name = "dohyeon") // name에 해당하는 프로퍼티 값만 변경해서 객체 복사
println(cus1.toString())
println(cus3.toString())
// 아래는 실행 결과
Customer(name = kodo, email = k906506@naver.com)
Customer(name = dohyeon, email = k906506@naver.com)
객체가 가지고 있는 프로퍼티를 개별 변수로 분해할 수 있다. 아래 코드를 보자
val (name, email) = cus1
println("name = $name, email = $email)
val (_, email) = cus1
cus1 객체의 프로퍼티를 선언한 두 개의 변수 name과 email로 가져올 수 있다. 하나의 프로퍼티만 필요한 경우_(언더바)
를 이용해서 제외할 수 있다.
이런 식으로도 사용할 수 있다.
fun myFunc() : Customer {
return Customer("kodo", "k906506@naver.com")
}
val (name, email) = myFunc()
val myLamda = {
(name, email) : Customer ->
println(name)
println(email)
}
myLamda(cus1)
객체를 소괄호로 감싸두었으므로 하나의 객체로 람다식에 전달하고, 람다식에 있는 두 개의 매개변수가 프로퍼티를 출력하게 된다. 데이터 클래스
를 이용하면 부가적인 메소드를 이용하면서 데이터에 집중할 수 있으므로 유용한 클래스 기법이다.
코틀린
은 두 가지의 내부 클래스 기법이 있다. 하나는 중첩 클래스
이고, 다른 하나는 이너 클래스
이다. 두 가지 모두 특정 클래스 내부에 있는 것이 특징이지만 사용 방법이 약간 다르다. 이러한 내부 클래스는 독립적인 클래스로 정의하기 모호하거나 다른 클래스에서 잘 사용하지 않아 외부에서 접근할 필요가 없을 때 사용하면 좋다.
하지만 너무 남용하면 클래스 간의 의존성이 커지고 코드가 읽기 어려우지니 주의!
코틀린
에서의 중첩 클래스
는 기본적으로 정적(static)
처럼 다뤄진다. 즉, 객체의 생성 없이 접근할 수 있다는 것이다. 외부 클래스에서 중첩 클래스의 프로퍼티에는 접근할 수 있지만 반대로 중첩 클래스에서 외부 클래스로의 접근은 불가능하다는 특징이 있다.
class Outer {
val ov = 5
class Nested {// 중첩 클래스의 프로퍼티
val nv = 10
fun greeting() = "Nested"
}
fun outside() {
val msg = Nested().greeting() // 외부 클래스에서 중첩 클래스로는 접근 가능
println(Nested().nv)
}
}
fun main() {
val output = Outer.Nested().greeting() // 중첩 클래스의 프로퍼티이므로 객체 생성 없이 접근 가능
println(output)
Outer.outside() // 오류! 외부 클래스는 객체를 생성해야함
val outer = Outer()
println(outer.outside())
}
어 근데, 지난번에 컴패니언 객체는 객체 생성 없이 접근할 수 있다고 했는데, 혹시 컴패니언 객체도 중첩 클래스에서 접근이 안되나요? 🤔
좋은 질문이다. 중첩 클래스에서 외부 클래스로의 접근이 불가능하다 했는데 만약 외부 클래스에서의 프로퍼티가 컴패니언 객체의 프로퍼티인 경우 접근이 가능하다.
class Outer {
class Nested {
fun accessOuterMethod() {
println(country)
getSomething()
}
}
companion object {
const val country = "Korea" // const는 컴파일 시간 상수
fun getSomething() = println("Get Country")
}
이렇게 컴패니언 객체인 경우 중첩 클래스에서도 접근이 가능하다.
그럼, 중첩 클래스에서 외부 클래스에 접근할 수 있는 방법은 컴패니언 객체 뿐인가요? 😂
내부 클래스가 중첩 클래스인 경우 그렇다. 하지만 중첩 클래스가 아닌 이너 클래스로 선언하면 외부 클래스에 접근이 가능하다!
이너 클래스
는 내부에 작성된 중첩 클래스
와는 좀 다른 역할을 한다. 위에서 말한 것처럼 이너 클래스는 외부 클래스에 접근이 가능하다. private로 선언한 경우에도 말이다.
class Smartphone(val model : String) {
private val cpu = "i5-8900"
inner class Inner { // 이너 클래스는 외부 클래스의 프로퍼티에 접근 가능
println(cpu)
}
}
지역 클래스
는 특정 메소드 블럭이나 init 블럭과 같이 범위 이내에서만 유효한 클래스이다. 지역 클래스 역시 이너 클래스처럼 외부 클래스의 프로퍼티에 접근이 가능하다. 하지만 지역 클래스의 경우 범위를 벗어나면 사용하지 못한다는 특징이 있다.
실드 클래스
는 미리 만들어 놓은 자료형을 묶어서 제공하는 클래스이다. Enum 클래스
와 유사하다. 실드 클래스는 sealed
라는 키워드를 사용하며 추상 클래스
와 같이 때문에 객체를 만들 수 없다. 또한 생성자도 기본적으로 private이며 private가 아닌 다른 생성자는 허용하지 않는다. 실드 클래스는 같은 파일 안에서는 상속이 가능하나 다른 파일에선 불가능하다. 이 때 open 키워드를 사용하면 된다. 사용 방법은 아래와 같다.
// 첫 번째 방법
sealed class Result {
open class Success(val message : String) : Result() // 상속을 위해 open으로 선언, 주생성자
class Error(val code : Int, val message : String) : Result()
}
class Status : Result()
class Inside : Result().Success("Status")
// 두 번째 방법
sealed class Result
open class Success(val message : String) : Result() // 상속을 위해 open으로 선언
class Error(val code : Int, val message : String) : Result()
class Status : Result()
class Inside : Success("Status") // 첫 번째 방법과 다르게 점(.)을 사용하지 않는다.
// 첫 번째 방법을 사용했다고 가정하고
fun main() {
val result = Result.Success("Good") // Result 객체
val msg = eval(result)
println(msg)
}
fun eval(result : Result) : String = when(result) {
is Status -> "in progress"
is Result.Success -> result.message
is Result.Error -> result.message
}
Enum 클래스
는 실드 클래스와 동일하다. 하지만 한 가지 자료형만 나열할 수 있다.
enum class DayOfWeek(val num : Int) { // 주생성자
Monday(1), Tuesday(2), ... , Sunday(7)
}
val day = DayOfWeek.Saturday
when(day.num) {
1, 2, 3, 4, 5 -> println("WeekDay")
6, 7 -> println("Weekend")
}
어노테이션 클래스
는 조금 난이도가 있으니 나중에 따로 작성해야겠다. 🤣