학습할 내용

  • 클래스에 제네릭 타입 파라미터를 정의하는 방법
  • 제네릭 클래스를 인스턴스화하는 방법
  • enum과 data class를 언제 사용해야 하는지
  • 특정 인터페이스를 구현해야 하는 제네릭 타입 파라미터를 정의하는 방법
  • 스코프 함수를 사용해 클래스의 프로퍼티와 메서드에 접근하는 방법
  • 클래스에 싱글턴 객체와 컴패니언 객체를 정의하는 방법
  • 기존 클래스에 새로운 프로퍼티와 메서드를 확장하여 추가하는 방법

제네릭을 사용하여 재사용 가능한 클래스 만들기

Kotlin 플레이그라운드로 이동합니다.

main() 함수 위에서 FillInTheBlankQuestion이라는 빈칸 채우기 질문 클래스를 정의합니다.

class FillInTheBlankQuestion(
    val questionText: String,
    val answer: String,
    val difiiculty: String
)

FillInTheBlankQuestion 클래스 아래에 TrueOrFalseQuestion이라는 참 또는 거짓 질문 클래스를 정의합니다. 답변은 Boolean으로 표시됩니다.

class TrueOrFalseQuestion(
    val questionText: String,
    val answer: Boolean,
    val difiiculty: String
)

마지막으로 NumericQuestion이라는 수학 문제 클래스를 정의합니다. 답변은 Int로 표시됩니다.

class NumericQuestion(
    val questionText: String,
    val answer: Int,
    val difiiculty: String
)

반복되는 부분을 발견하셨나요? 세 클래스 모두 똑같은 속성 questionText, answer, difficulty가 포함되어 있습니다. 유일한 차이점은 answer 속성의 데이터 유형입니다. 특정 속성이 서로 다른 데이터 유형을 가질 필요가 있을 때 Kotlin은 제네릭 타입이라는 기능을 제공하여 특정 사용 사례에 따라 서로 다른 데이터 유형을 가질 수 있는 단일 속성을 구현할 수 있게 합니다.

제네릭 데이터 타입이란 무엇인가요?

제네릭 타입, 또는 줄여서 제네릭은 클래스와 같은 데이터 타입이 그 속성과 메서드에서 사용할 알 수 없는 데이터 타입을 위한 플레이스홀더를 지정할 수 있도록 해줍니다.

아래는 클래스에 제네릭 타입을 정의할 때 사용하는 문법입니다.

class className <genericDataType> (
    properties
)

제네릭 타입은 클래스의 인스턴스화 시점에 실제 타입을 넘겨주기 때문에 클래스 선언부에서 함께 정의해야 합니다.

플레이스홀더는 클래스 내부에서 실제 데이터 타입이 필요한 모든 위치에서 일반 타입처럼 사용할 수 있습니다.

class className <genericDataType> (
    val propertyName: genericDataType
)

코드를 리팩터링하여 제네릭 사용

Question이라는 단일 클래스를 사용하도록 코드를 리팩터링하고, answer 프로퍼티를 제네릭 타입으로 변경하세요.

Question이라는 새 클래스를 만듭니다.

class Question()

클래스 이름 뒤 괄호 앞에 제네릭 타입 매개변수를 추가합니다.

class Question<T>()

위 예시처럼 제네릭 타입 이름으로 T(type의 약자)를 자주 사용하고, 클래스에 여러 제네릭 타입이 있을 경우 다른 대문자를 사용하기도 합니다. 하지만 반드시 그렇게 해야 하는 규칙이 있는 것은 아닙니다. 상황에 맞게 더 의미 있는 이름을 제네릭 타입으로 사용하는 것도 가능합니다.

questionText, answer, difficulty 속성을 추가합니다.

class Question<T>(
    val questionText: String,
    val answer: T,
    val difficulty: String
)

어떻게 작동하는지 확인하려면 main()에서 Question 클래스의 인스턴스를 세 개 만듭니다.

fun main() {
    val question1 = Question<String>("Quoth the raven ___", "nevermore", "medium")
    val question2 = Question<Boolean>("The sky is green. True or false", false, "easy")
    val question3 = Question<Int>("How many days are there between full moons?", 28, "hard")
}

enum 클래스 사용

enum 클래스는 가질 수 있는 값의 범위가 제한된 타입을 만들 때 사용합니다. 예를 들어 동, 서, 남, 북 네 가지 방향은 enum 클래스로 표현할 수 있습니다.

아래는 enum 클래스를 선언하는 기본 문법입니다.

enum class enumName {
    CASE1, CASE2, CASE3
}

enum이 가질 수 있는 각각의 값은 enum 상수라고 합니다. enum 상수는 중괄호 안에 콤마로 구분해 나열합니다. 관례적으로 enum 상수 이름은 모든 글자를 대문자로 작성합니다. enum 상수에 접근할 때는 .연산자를 사용합니다.

enumName.CASE1

enum 상수 사용

String 대신 enum 상수를 사용하도록 코드를 수정하여 난이도를 나타냅니다.

Question 클래스 아래에서 Difficulty라는 enum 클래스를 정의합니다.

enum class Difficulty {
    EASY, MEDIUM, HARD
}

Question 클래스에서 difficulty 속성의 데이터 유형을 Difficulty로 변경합니다.

class Question<T>(
    val questionText: String,
    val answer: T,
    val difficulty: Difficulty
)

질문 3개를 초기화할 때 난이도에 enum 상수를 전달합니다.

fun main() {
    val question1 = Question<String>("Quoth the raven ___", "nevermore", Difficulty.MEDIUM)
    val question2 = Question<Boolean>("The sky is green. True or false", false, Difficulty.EASY)
    val question3 = Question<Int>("How many days are there between full moons?", 28, Difficulty.HARD)
}

데이터 클래스 사용

Question 클래스처럼 데이터만 포함하고 작업을 실행하는 메서드는 없는 클래스를 데이터 클래스로 정의할 수 있습니다. 클래스를 데이터 클래스로 정의하면 Kotlin 컴파일러에서 일부 메서드를 자동으로 구현할 수 있습니다. 예를 들어 toString() 및 기타 메서드가 클래스의 속성에 따라 자동으로 구현됩니다.

데이터 클래스를 정의하려면 class 키워드 앞에 data 키워드를 추가하기만 하면 됩니다.

Question을 데이터 클래스로 변환

main()에서 question1toString()을 호출한 결과를 출력합니다.

fun main() {
    val question1 = Question<String>("Quoth the raven ___", "nevermore", Difficulty.MEDIUM)
    val question2 = Question<Boolean>("The sky is green. True or false", false, Difficulty.EASY)
    val question3 = Question<Int>("How many days are there between full moons?", 28, Difficulty.HARD)
    println(question1.toString())
}

출력에는 클래스의 이름과 객체의 고유 식별자만 표시됩니다.

Question@49097b5d

data 키워드를 사용하여 Question을 데이터 클래스로 만듭니다.

data class Question<T>(
    val questionText: String,
    val answer: T,
    val difficulty: Difficulty
)

코드를 다시 실행합니다.

Question(questionText=Quoth the raven ___, answer=nevermore, difficulty=MEDIUM)

클래스가 데이터 클래스로 정의되면 다음 메서드가 구현됩니다.

  • equals()
  • hashCode()
  • toString()
  • componentN(): component1(), component2()
  • copy()

데이터 클래스에는 생성자에 매개변수가 하나 이상 있어야 하며 모든 생성자 매개변수는 val또는 var로 표시되어야 합니다.

데이터 클래스는 abstract, open, sealed, inner 일 수 없습니다.

싱글톤 객체 사용

객체를 싱글톤으로 정의하여 객체에는 인스턴스가 하나만 있어야 함을 코드에서 명확하게 전달할 수 있습니다. 싱글톤은 인스턴스를 하나만 가질 수 있는 클래스입니다.

싱글톤 객체 정의

object objectName {
    classBody1
}

class 키워드 대신 object 키워드를 사용하기만 하면 됩니다. 싱글톤 객체에는 생성자를 포함할 수 없습니다. 개발자가 인스턴스를 직접 만들 수 없기 때문입니다.

퀴즈의 경우 총질문 수와 학생이 지금까지 답변한 질문 수를 추적하는 방법이 있으면 좋습니다. 이 클래스의 인스턴스는 하나만 있으면 되므로 싱글톤 객체로 선언합니다.

StudentProgress라는 객체를 만듭니다.

object StudentProgress {

}

질문이 총 10개이고 그중 3개를 지금까지 답변했다고 가정합니다.

object StudentProgress {
    var total: Int = 10
    var answered: Int = 3
}

싱글톤 객체에 액세스

객체 자체의 이름, .연산자, 속성 이름을 차례로 참조하여 속성에 액세스합니다.

objectName.propertyName

main()에서 StudentProgress 객체의 answeredtotal을 출력하는 코드를 추가합니다.

fun main() {
    val question1 = Question<String>("Quoth the raven ___", "nevermore", Difficulty.MEDIUM)
    val question2 = Question<Boolean>("The sky is green. True or false", false, Difficulty.EASY)
    val question3 = Question<Int>("How many days are there between full moons?", 28, Difficulty.HARD)
    println(question1.toString())
    println("${StudentProgress.answered} of ${StudentProgress.total} answered.")
}

코드를 실행하여 출력을 확인합니다.

Question(questionText=Quoth the raven ___, answer=nevermore, difficulty=MEDIUM)
3 of 10 answered.

객체를 컴패니언 객체로 선언

Kotlin에서는 클래스나 객체를 다른 타입 안에 정의할 수 있으며, 이를 통해 코드를 더 체계적으로 구성할 수 있습니다. 클래스 내부에 컴패니언 객체를 사용해 싱글톤 객체를 정의할 수도 있습니다.

컴패니언 객체는 그 안에 정의된 프로퍼티와 메서드가 해당 클래스에 속하는 경우, 클래스 내부에서 더 간결한 문법으로 접근할 수 있도록 해줍니다.

컴패니언 객체를 선언하려면 object 키워드 앞에 companion 키워드를 추가하기만 하면 됩니다.

companion object objectName

퀴즈 질문을 저장할 Quiz라는 클래스를 만들고 StudentProgressQuiz 클래스의 컴패니언 객체로 만듭니다.

class Quiz {

}

question1, question2, question3main()에서 Quiz 클래스로 이동하고 println(question1.toString())을 삭제합니다.

class Quiz {
    val question1 = Question<String>("Quoth the raven ___", "nevermore", Difficulty.MEDIUM)
    val question2 = Question<Boolean>("The sky is green. True or false", false, Difficulty.EASY)
    val question3 = Question<Int>("How many days are there between full moons?", 28, Difficulty.HARD)
}

StudentProgress 객체를 Quiz 클래스로 이동합니다.

class Quiz {
    val question1 = Question<String>("Quoth the raven ___", "nevermore", Difficulty.MEDIUM)
    val question2 = Question<Boolean>("The sky is green. True or false", false, Difficulty.EASY)
    val question3 = Question<Int>("How many days are there between full moons?", 28, Difficulty.HARD)
    
    companion object StudentProgress {
        var total: Int = 10
        var answered: Int = 3
    }
}

main()println() 코드를 다음과 같이 수정합니다.

fun main() {
    println("${Quiz.answered} of ${Quiz.total} answered.")
}

코드를 실행하여 출력을 확인합니다.

3 of 10 answered.

StudentProgress 객체에서 선언되지만 Quiz 클래스의 이름만 사용하여 액세스할 수 있습니다.

새 속성 및 메서드로 클래스 확장

클래스를 작성할 때 다른 개발자가 이를 어떻게 사용하거나 사용할 계획인지 정확히 알 수 없는 경우가 많습니다.

Kotlin 언어의 기능을 통해 다른 개발자는 기존 데이터 유형을 확장하여 해당 데이터 유형의 일부인 것처럼 점 문법으로 액세스할 수 있는 속성과 메서드를 추가할 수 있습니다.

확장 속성 추가

확장 속성을 정의하려면 변수 이름 앞에 유형 이름과 .연산자를 추가합니다.

val typeName.propertyName: dataType
    propertyGetter

Quiz 클래스 아래에서 String 유형의 progressText라는 Quiz.StudentProgress 확장 속성을 정의합니다.

val Quiz.StudentProgress.progressText: String

getter를 정의합니다.

val Quiz.StudentProgress.progressText: String
    get() = "${Quiz.answered} of ${Quiz.total} answered."

main() 함수의 코드를 progressText를 출력하는 코드로 바꿉니다.

fun main() {
    println(Quiz.progressText)
}

코드를 실행하여 출력을 확인합니다.

3 of 10 answered.

확장 함수 추가

fun typeName.functionName(parameters): returnType {
    functionBody
}

확장 함수를 추가하여 퀴즈 진행 상황을 진행률 표시줄로 출력합니다.

printProgressBar()라는 확장 함수를 StudentProgress 객체에 추가합니다.

fun Quiz.StudentProgress.printProgressBar() {

}

repeat()을 사용하여 문자를 answered 횟수로 출력합니다.

fun Quiz.StudentProgress.printProgressBar() {
    repeat(Quiz.answered) { print("▓") }
}

남은 질문을 문자로 표시합니다.

fun Quiz.StudentProgress.printProgressBar() {
    repeat(Quiz.answered) { print("▓") }
    repeat(Quiz.total - Quiz.answered) { print("▒") }
}

인수 없이 println()을 사용하여 새 줄을 출력하고 progressText를 출력합니다.

fun Quiz.StudentProgress.printProgressBar() {
    repeat(Quiz.answered) { print("▓") }
    repeat(Quiz.total - Quiz.answered) { print("▒") }
    println()
    println(Quiz.progressText)
}

main()에서 printProgressBar()를 호출합니다.

fun main() {
    Quiz.printProgressBar()
}

코드를 실행하여 출력을 확인합니다.

▓▓▓▒▒▒▒▒▒▒
3 of 10 answered.

인터페이스를 사용하여 확장 함수 다시 작성

이미 정의된 클래스에 기능을 추가할 때 확장 속성과 확장 함수를 사용하는 방식은 유용하지만, 소스 코드를 수정할 수 있는 상황이라면 굳이 확장을 사용할 필요는 없습니다. 또한 어떤 기능의 구현 방식은 모르지만, 특정 메서드나 속성이 반드시 존재해야 하는 경우도 있습니다. 만약 여러 클래스가 동일한 속성이나 메서드를 가져야 하고, 그 동작 방식만 다르게 구현하고 싶다면, 이런 요구 사항을 인터페이스로 정의할 수 있습니다.

예를 들어, 퀴즈뿐 아니라 설문조사, 요리 레시피 단계, 또는 진행 단계가 필요한 다른 순차적 데이터가 있다고 해봅시다. 이런 경우, 각 클래스가 반드시 가져야 할 메서드와 속성을 명시한 인터페이스를 정의할 수 있습니다.

인터페이스는 interface 키워드를 사용해 정의하며, 이름은 UpperCamelCase로 작성합니다. 그 다음 중괄호 {}를 열고 닫습니다. 중괄호 안에는 인터페이스를 구현하는 클래스가 반드시 구현해야 하는 메서드 시그니처나 읽기 전용(get-only) 속성을 정의할 수 있습니다.

interface InterfaceName {
    interfaceBody
}

인터페이스는 일종의 계약입니다. 인터페이스를 따르는 클래스는 해당 인터페이스를 확장한다고 표현합니다. 클래스가 특정 인터페이스를 확장하려면, 클래스 선언에서 콜론(:) 뒤에 공백을 넣고 인터페이스 이름을 작성하면 됩니다.

class ClassName: InterfaceName {
    classBody
}

인터페이스를 구현하는 클래스는 인터페이스에서 정의된 모든 속성과 메서드를 반드시 구현해야 합니다. 이를 통해 인터페이스를 구현하는 모든 클래스가 동일한 메서드를 동일한 시그니처로 제공하도록 강제할 수 있습니다. 또한 구현 방식은 각 클래스가 자유롭게 결정할 수 있습니다.

인터페이스를 사용하도록 진행률 표시줄을 다시 작성하고 퀴즈 클래스가 그 인터페이스를 확장하도록 하는 방법을 살펴보겠습니다.

Quiz 클래스 위에 ProgressPrintable이라는 인터페이스를 정의합니다.

interface ProgressPrintable {
    val progressText: String
}

ProgressPrintable 인터페이스를 확장하도록 Quiz 클래스의 선언을 수정합니다.

class Quiz : ProgressPrintable {
    ...
}

Quiz 클래스에서 ProgressPrintable 인터페이스에 지정된 대로 progressText 속성을 추가합니다. 이전 progressText 확장 속성에서 getter를 복사합니다.

class Quiz : ProgressPrintable {
    override val progressText: String
        get() = "${answered} of ${total} answered."
    ...
}

이전 progressText 확장 속성을 삭제합니다.

// 삭제합니다.
val Quiz.StudentProgress.progressText: String
	get() = "${Quiz.answered} of ${Quiz.total} answered."

ProgressPrintable 인터페이스에서 printProgressBar라는 메서드를 추가합니다.

interface ProgressPrintable {
    val progressText: String
    fun printProgressBar()
}

Quiz 클래스에서 printProgressBar() 메서드를 추가합니다.

class Quiz : ProgressPrintable {
    override val progressText: String
        get() = "${answered} of ${total} answered."
    
    override fun printProgressBar() {

    }
    ...
}

이전 printProgressBar() 확장 함수의 코드를 인터페이스의 새 printProgressBar()로 이동합니다.

override fun printProgressBar() {
    repeat(Quiz.answered) { print("▓") }
    repeat(Quiz.total - Quiz.answered) { print("▒") }
    println()
    println(progressText)
}

확장 함수 printProgressBar()를 삭제합니다.

fun Quiz.StudentProgress.printProgressBar() {
    repeat(Quiz.answered) { print("▓") }
    repeat(Quiz.total - Quiz.answered) { print("▒") }
    println()
    println(Quiz.progressText)
}

main()의 코드를 업데이트합니다. 이제 printProgressBar() 함수가 Quiz 클래스의 메서드이므로 먼저 Quiz 객체를 인스턴스화한 다음 printProgressBar()를 호출해야 합니다.

fun main() {
    Quiz().printProgressBar()
}

범위 함수를 사용하여 클래스 속성 및 메서드에 액세스

범위 함수로 반복 객체 참조 제거

범위 함수는 고차 함수의 한 종류로, 객체의 이름을 반복해서 명시하지 않고도 해당 객체의 속성과 메서드에 접근할 수 있게 해줍니다.

let()을 사용하여 긴 객체 이름 바꾸기

let() 함수를 사용하면 객체의 실제 이름 대신 식별자 it을 사용하여 람다 표현식의 객체를 참조할 수 있습니다.

let()을 사용하여 question1, question2, question3 속성에 액세스 해봅시다.

Quiz 클래스에 printQuiz()라는 함수를 추가합니다.

fun printQuiz() {

}

질문의 questionText, answer, difficulty를 출력하는 코드를 추가합니다.

fun printQuiz() {
    println(question1.questionText)
    println(question1.answer)
    println(question1.difficulty)
    println()
    println(question2.questionText)
    println(question2.answer)
    println(question2.difficulty)
    println()
    println(question3.questionText)
    println(question3.answer)
    println(question3.difficulty)
    println()
}

questionText, answer, difficulty 속성에 액세스 하는 코드를 let() 함수 호출로 둘러쌉니다. 각 람다 표현식의 변수 이름을 it으로 바꿉니다.

fun printQuiz() {
    question1.let {
        println(it.questionText)
        println(it.answer)
        println(it.difficulty)
    }
    println()
    question2.let {
        println(it.questionText)
        println(it.answer)
        println(it.difficulty)
    }
    println()
    question3.let {
        println(it.questionText)
        println(it.answer)
        println(it.difficulty)
    }
    println()
}

main()의 코드를 업데이트 합니다.

fun main() {
    val quiz = Quiz()
    quiz.printQuiz()
}

코드를 실행합니다.

Quoth the raven ___
nevermore
MEDIUM

The sky is green. True or false
false
EASY

How many days are there between full moons?
28
HARD

apply()를 사용하여 변수 없이 객체의 메서드 호출

main()의 코드를 업데이트하여 apply() 함수를 호출합니다.

Quiz 클래스의 인스턴스를 만들 때 apply()를 호출합니다.

fun main() {
    val quiz = Quiz().apply {

    }
}

람다 표현식 내에서 printQuiz() 함수를 호출합니다. quiz 변수를 참조하거나 점 표기법을 사용할 필요가 없습니다.

fun main() {
    val quiz = Quiz().apply {
        printQuiz()
    }
}

apply() 함수는 Quiz 클래스의 인스턴스를 반환하지만 어디에서도 사용하지 않으므로 quiz 변수를 삭제합니다.

fun main() {
    Quiz().apply {
        printQuiz()
    }
}

코드를 실행합니다.

Quoth the raven ___
nevermore
MEDIUM

The sky is green. True or false
false
EASY

How many days are there between full moons?
28
HARD

📚 참고: Android Developers 공식 Kotlin 학습 자료 본 글은 Android Developers의 Kotlin 교육 콘텐츠를 참고하여 재구성했습니다.

카테고리:

업데이트:

댓글남기기