제네릭, 객체, 확장

1. 소개

수십 년에 걸쳐 프로그래머는 더 나은 코드를 작성하는 데 도움이 되는 여러 프로그래밍 언어 기능을 고안했습니다. 더 적은 코드로 같은 아이디어를 표현하고 추상화로 복잡한 아이디어를 표현하고 다른 개발자가 우연히 실수하지 않도록 코드를 작성하는 것은 몇 가지 예일 뿐입니다. Kotlin 언어도 예외는 아니며 개발자가 표현력이 더 높은 코드를 작성할 수 있도록 하는 여러 기능이 있습니다.

안타깝지만 이러한 기능은 프로그래밍이 처음인 개발자의 경우에는 작업하기 까다로울 수 있습니다. 이러한 기능이 유용하다고 생각할 수 있지만 유용함의 정도와 해결되는 문제가 항상 명확하지는 않을 수도 있습니다. Compose와 기타 라이브러리에서 사용되는 일부 기능은 이미 확인했을 수 있습니다.

경험을 대신할 수 있는 것은 없지만 이 Codelab을 통해 더 큰 앱을 구조화하는 데 도움이 되는 여러 Kotlin 개념을 접해볼 수 있을 것입니다.

  • 제네릭
  • 다양한 종류의 클래스(enum 클래스, 데이터 클래스)
  • 싱글톤 및 컴패니언 객체
  • 확장 속성 및 함수
  • 범위 함수

이 Codelab을 마치면 이 과정에서 이미 본 코드를 더 깊이 이해하게 되고 이러한 개념을 자체 앱에서 접하거나 사용하는 시점에 관한 예를 알게 됩니다.

기본 요건

  • 상속을 포함한 객체 지향 프로그래밍 개념에 관한 지식
  • 인터페이스를 정의하고 구현하는 방법에 대한 이해

학습할 내용

  • 클래스의 일반 유형 매개변수를 정의하는 방법
  • 일반 클래스를 인스턴스화하는 방법
  • enum 및 데이터 클래스를 사용하는 시점
  • 인터페이스를 구현해야 하는 일반 유형 매개변수를 정의하는 방법
  • 범위 함수를 사용하여 클래스 속성 및 메서드에 액세스하는 방법
  • 클래스의 싱글톤 객체 및 컴패니언 객체를 정의하는 방법
  • 새 속성과 메서드를 사용하여 기존 클래스를 확장하는 방법

필요한 항목

  • Kotlin 플레이그라운드에 액세스할 수 있는 웹브라우저

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

이 과정에서 살펴본 퀴즈와 비슷한 온라인 퀴즈를 위한 앱을 작성한다고 가정해 보겠습니다. 퀴즈에는 '빈칸 채우기', '참 또는 거짓' 등 다양한 유형의 질문이 있는 경우가 많습니다. 개별 퀴즈 질문은 여러 속성이 있는 클래스로 나타낼 수 있습니다.

퀴즈의 질문 텍스트는 문자열로 표시될 수 있습니다. 퀴즈 질문은 답변도 나타내야 합니다. 그러나 참 또는 거짓 등 질문 유형에 따라 서로 다른 데이터 유형을 사용하여 답변을 나타내야 할 수 있습니다. 세 가지 유형의 질문을 정의해 보겠습니다.

  • 빈칸 채우기 질문: 답변은 String으로 표시되는 단어입니다.
  • 참 또는 거짓 질문: 답변은 Boolean으로 표시됩니다.
  • 수학 문제: 답변은 숫자 값입니다. 간단한 산술 문제의 답변은 Int로 표시됩니다.

또한 이 예의 퀴즈 질문에는 질문 유형과 관계없이 난이도도 포함됩니다. 난이도는 가능한 세 가지 값("easy", "medium", "hard")이 포함된 문자열로 표시됩니다.

각 퀴즈 질문 유형을 나타내는 클래스를 정의합니다.

  1. Kotlin 플레이그라운드로 이동합니다.
  2. main() 함수 위에서 questionTextString 속성과 answerString 속성, difficultyString 속성으로 구성된 FillInTheBlankQuestion이라는 빈칸 채우기 질문의 클래스를 정의합니다.
class FillInTheBlankQuestion(
    val questionText: String,
    val answer: String,
    val difficulty: String
)
  1. FillInTheBlankQuestion 클래스 아래에서 참 또는 거짓 질문을 위한 TrueOrFalseQuestion이라는 또 다른 클래스를 정의합니다. 이 클래스는 questionTextString 속성과 answerBoolean 속성, difficultyString 속성으로 구성됩니다.
class TrueOrFalseQuestion(
    val questionText: String,
    val answer: Boolean,
    val difficulty: String
)
  1. 마지막으로 다른 두 클래스 아래에서 questionTextString 속성, answerInt 속성, difficultyString 속성으로 구성된 NumericQuestion 클래스를 정의합니다.
class NumericQuestion(
    val questionText: String,
    val answer: Int,
    val difficulty: String
)
  1. 작성한 코드를 살펴봅니다. 반복이 보이나요?
class FillInTheBlankQuestion(
    val questionText: String,
    val answer: String,
    val difficulty: String
)

class TrueOrFalseQuestion(
    val questionText: String,
    val answer: Boolean,
    val difficulty: String
)
class NumericQuestion(
    val questionText: String,
    val answer: Int,
    val difficulty: String
)

세 클래스에 모두 똑같은 속성, questionText, answer, difficulty가 포함되어 있습니다. 유일한 차이점은 answer 속성의 데이터 유형입니다. 명확한 방법은 questionTextdifficulty로 상위 클래스를 만들고 각 서브클래스가 answer 속성을 정의하는 것이라고 생각할 수 있습니다.

그러나 상속을 사용하면 위와 동일한 문제가 발생합니다. 새로운 유형의 질문을 추가할 때마다 answer 속성을 추가해야 합니다. 유일한 차이점은 데이터 유형입니다. 또한 답변 속성이 없는 상위 클래스 Question이 있는 것도 이상하게 보입니다.

속성의 데이터 유형을 다르게 하고 싶을 때 서브클래스는 답이 아닙니다. 대신 Kotlin은 일반 유형이라는 것을 제공하며, 이를 통해 특정 사용 사례에 따라 데이터 유형이 다를 수 있는 단일 속성을 가질 수 있습니다.

일반 데이터 유형이란 무엇인가요?

일반 유형(또는 줄여서 제네릭)은 클래스와 같은 데이터 유형이 속성 및 메서드와 함께 사용할 수 있는 알 수 없는 자리표시자 데이터 유형을 지정하도록 합니다. 이는 정확히 무엇을 의미할까요?

위의 예에서 가능한 각 데이터 유형의 답변 속성을 정의하는 대신 질문을 나타내는 단일 클래스를 만들고 answer 속성의 데이터 유형에 관한 자리표시자 이름을 사용할 수 있습니다. 실제 데이터 유형(String, Int, Boolean 등)은 이 클래스가 인스턴스화될 때 지정됩니다. 자리표시자 이름이 사용될 때마다 이 클래스에 전달된 데이터 유형이 대신 사용됩니다. 클래스의 일반 유형을 정의하는 문법은 다음과 같습니다.

67367d9308c171da.png

일반 데이터 유형은 클래스를 인스턴스화할 때 제공되므로 클래스 서명의 일부로 정의해야 합니다. 클래스 이름 뒤에 왼쪽 꺾쇠괄호(<)가 오고 그 뒤에 데이터 유형의 자리표시자 이름, 오른쪽 꺾쇠괄호(>)가 차례로 옵니다.

그러면 속성과 같이 클래스 내에서 실제 데이터 유형을 사용할 때마다 자리표시자 이름을 사용할 수 있습니다.

81170899b2ca0dc9.png

이는 자리표시자 이름이 데이터 유형 대신 사용된다는 점을 제외하고는 다른 속성 선언과 동일합니다.

사용할 데이터 유형을 클래스에서 최종적으로 어떻게 알 수 있을까요? 일반 유형에서 사용하는 데이터 유형은 클래스를 인스턴스화할 때 꺾쇠괄호로 묶여 매개변수로 전달됩니다.

9b8fce54cac8d1ea.png

클래스 이름 뒤에 왼쪽 꺾쇠괄호(<)가 오고 그 뒤에 실제 데이터 유형(String, Boolean, Int 등)과 오른쪽 꺾쇠괄호(>)가 옵니다. 일반 속성으로 전달하는 값의 데이터 유형은 꺾쇠괄호로 묶인 데이터 유형과 일치해야 합니다. 답변이 String 또는 Boolean, Int이든 임의의 데이터 유형이든 상관없이 하나의 클래스를 사용하여 모든 유형의 퀴즈 질문을 나타낼 수 있도록 답변 속성을 제네릭으로 만듭니다.

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

코드를 리팩터링하여 일반 답변 속성과 함께 Question이라는 단일 클래스를 사용합니다.

  1. FillInTheBlankQuestion, TrueOrFalseQuestion, NumericQuestion의 클래스 정의를 삭제합니다.
  2. Question이라는 새 클래스를 만듭니다.
class Question()
  1. 클래스 이름 뒤, 괄호 앞에 왼쪽 및 오른쪽 꺾쇠괄호를 사용하여 일반 유형 매개변수를 추가합니다. 일반 유형 T를 호출합니다.
class Question<T>()
  1. questionText, answer, difficulty 속성을 추가합니다. questionTextString 유형이어야 합니다. answerT 유형이어야 합니다. Question 클래스를 인스턴스화할 때 데이터 유형이 지정되기 때문입니다. difficulty 속성은 String 유형이어야 합니다.
class Question<T>(
    val questionText: String,
    val answer: T,
    val difficulty: String
)
  1. 빈칸 채우기, 참 또는 거짓 등 여러 질문 유형에서 어떻게 작동하는지 확인하려면 아래와 같이 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")
}
  1. 코드를 실행하여 모든 것이 제대로 작동하는지 확인합니다. 이제 다른 클래스 세 개이거나 상속을 사용하는 대신 Question 클래스의 인스턴스가 세 개(각각 답변의 데이터 유형이 다름) 있습니다. 다른 답변 유형으로 질문을 처리하려는 경우 동일한 Question 클래스를 재사용하면 됩니다.

3. enum 클래스 사용

이전 섹션에서는 가능한 값 세 가지인 "easy", "medium", "hard"로 난이도 속성을 정의했습니다. 이는 작동은 하지만 몇 가지 문제가 있습니다.

  1. 가능한 세 가지 문자열 중 하나를 실수로 잘못 입력할 경우 버그가 발생할 수 있습니다.
  2. 값이 변경되는 경우(예: "medium"의 이름이 "average"로 바뀐 경우) 문자열의 모든 사용을 업데이트해야 합니다.
  3. 여러분이나 다른 개발자가 세 가지 유효한 값 중 하나가 아닌 다른 문자열을 실수로 사용하는 것을 방지하는 방법이 없습니다.
  4. 난이도 수준을 더 추가하면 코드를 관리하기가 더 어려워집니다.

Kotlin을 사용하면 enum 클래스라는 특수한 유형의 클래스를 통해 이러한 문제를 해결할 수 있습니다. enum 클래스는 가능한 값 집합이 제한되어 있는 유형을 만드는 데 사용됩니다. 예를 들어 실제로 네 가지 기본 방향(동쪽, 서쪽, 남쪽, 북쪽)을 enum 클래스로 나타낼 수 있습니다. 추가 방향을 사용할 필요가 없습니다(코드에서도 허용하지 않음). 다음은 enum 클래스의 문법입니다.

f4bddb215eb52392.png

가능한 각 enum 값을 enum 상수라고 합니다. enum 상수는 중괄호 안에 배치되며 쉼표로 구분되어 있습니다. 상수 이름의 모든 문자를 대문자로 표기하는 것이 규칙입니다.

점 연산자를 사용하여 enum 상수를 참조합니다.

f3cfa84c3f34392b.png

enum 상수 사용

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

  1. Question 클래스 아래에서 Difficulty라는 enum 클래스를 정의합니다.
enum class Difficulty {
    EASY, MEDIUM, HARD
}
  1. Question 클래스에서 difficulty 속성의 데이터 유형을 String에서 Difficulty로 변경합니다.
class Question<T>(
    val questionText: String,
    val answer: T,
    val difficulty: Difficulty
)
  1. 질문 3개를 초기화할 때 난이도의 enum 상수를 전달합니다.
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)

4. 데이터 클래스 사용

Activity의 서브클래스와 같이 지금까지 작업한 클래스에는 대부분 다른 작업을 실행하는 메서드가 여러 개 있습니다. 이러한 클래스는 데이터만 나타내는 것이 아니라 여러 기능도 포함하고 있습니다.

반면 Question 클래스와 같은 클래스는 데이터만 포함합니다. 작업을 실행하는 메서드는 없습니다. 이를 데이터 클래스로 정의할 수 있습니다. 클래스를 데이터 클래스로 정의하면 Kotlin 컴파일러에서 특정 가정을 하고 일부 메서드를 자동으로 구현할 수 있습니다. 예를 들어 toString()println() 함수에 의해 내부적으로 호출됩니다. 데이터 클래스를 사용하면 toString() 및 기타 메서드가 클래스의 속성에 따라 자동으로 구현됩니다.

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

e7cd946b4ad216f4.png

Question을 데이터 클래스로 변환

먼저 데이터 클래스가 아닌 클래스에서 toString()과 같은 메서드를 호출하려고 하면 어떻게 되는지 살펴봅니다. 그런 다음 Question을 데이터 클래스로 변환하여 이 메서드와 기타 메서드가 기본적으로 구현되도록 합니다.

  1. 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())
}
  1. 코드를 실행합니다. 출력에는 클래스 이름과 객체의 고유 식별자만 표시됩니다.
Question@37f8bb67
  1. data 키워드를 사용하여 Question을 데이터 클래스로 만듭니다.
data class Question<T>(
    val questionText: String,
    val answer: T,
    val difficulty: Difficulty
)
  1. 코드를 다시 실행합니다. 이를 데이터 클래스로 표시하여 Kotlin은 toString()을 호출할 때 클래스의 속성을 표시하는 방법을 결정할 수 있습니다.
Question(questionText=Quoth the raven ___, answer=nevermore, difficulty=MEDIUM)

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

  • equals()
  • hashCode(): 특정 컬렉션 유형을 사용할 때 이 메서드가 표시됩니다.
  • toString()
  • componentN(): component1(), component2()
  • copy()

5. 싱글톤 객체 사용

개발자가 클래스에 인스턴스가 하나만 포함되기를 바라는 시나리오가 많이 있습니다. 예를 들면 다음과 같습니다.

  1. 현재 사용자를 대상으로 한 모바일 게임의 플레이어 통계
  2. 단일 하드웨어 기기와 상호작용(예: 스피커를 통해 오디오 전송)
  3. 원격 데이터 소스(예: Firebase 데이터베이스)에 액세스하는 객체
  4. 한 번에 한 사용자만 로그인해야 하는 인증

위의 시나리오에서는 클래스를 사용해야 할 수 있습니다. 그러나 해당 클래스의 인스턴스를 하나만 인스턴스화하면 됩니다. 하드웨어 기기가 하나뿐이거나 한 번에 사용자가 한 명만 로그인되었다면 인스턴스를 두 개 이상 만들 이유가 없습니다. 동일한 하드웨어 기기에 동시에 액세스하는 객체가 두 개 있으면 아주 이상한 동작이나 버그가 발생할 수 있습니다.

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

싱글톤 객체 정의

645e8e8bbffbb5f9.png

객체의 문법은 클래스의 문법과 유사합니다. class 키워드 대신 object 키워드를 사용하기만 하면 됩니다. 싱글톤 객체에는 생성자를 포함할 수 없습니다. 개발자가 인스턴스를 직접 만들 수 없기 때문입니다. 대신 모든 속성이 중괄호 안에 정의되며 초깃값이 부여됩니다.

특히 특정 하드웨어 기기로 작업하지 않았거나 앱에서 아직 인증을 처리하지 않은 경우 앞서 주어진 예 중 일부가 명확하지 않을 수 있습니다. 그러나 Android 개발을 계속 학습하다 보면 싱글톤 객체가 나오는 것을 확인할 수 있습니다. 인스턴스가 하나만 필요한 사용자 상태의 객체를 사용하는 간단한 예를 통해 실제로 살펴보겠습니다.

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

  1. StudentProgress라는 객체를 만듭니다.
object StudentProgress {
}
  1. 이 예에서는 질문이 총 10개이고 그중 3개를 지금까지 답변했다고 가정합니다. Int 속성 두 개를 추가합니다. 값이 10total과 값이 3answered입니다.
object StudentProgress {
    var total: Int = 10
    var answered: Int = 3
}

싱글톤 객체에 액세스

싱글톤 객체의 인스턴스를 직접 만들 수 없다는 것을 기억하시나요? 그렇다면 그 속성에는 어떻게 액세스할 수 있을까요?

한 번에 StudentProgress 인스턴스가 하나만 있으므로 객체 자체의 이름, 점 연산자(.), 속성 이름을 차례로 참조하여 속성에 액세스합니다.

1b610fd87e99fe25.png

main() 함수를 업데이트하여 싱글톤 객체의 속성에 액세스합니다.

  1. main()에서 StudentProgress 객체의 answeredtotal 질문을 출력하는 println() 호출을 추가합니다.
fun main() {
    ...
    println("${StudentProgress.answered} of ${StudentProgress.total} answered.")
}
  1. 코드를 실행하여 모든 것이 제대로 작동하는지 확인합니다.
...
3 of 10 answered.

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

Kotlin의 클래스와 객체는 다른 유형 내에서 정의할 수 있고 코드를 구성하는 좋은 방법이 될 수 있습니다. 컴패니언 객체를 사용하여 다른 클래스 내에서 싱글톤 객체를 정의할 수 있습니다. 컴패니언 객체를 사용하면 클래스 내에서 속성과 메서드에 액세스할 수 있으므로(객체의 속성과 메서드가 해당 클래스에 속한 경우) 문법이 더 간결해집니다.

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

68b263904ec55f29.png

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

  1. Difficulty enum 아래에서 Quiz라는 새 클래스를 정의합니다.
class Quiz {
}
  1. 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)

}
  1. 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)

    object StudentProgress {
        var total: Int = 10
        var answered: Int = 3
    }
}
  1. StudentProgress 객체를 companion 키워드로 표시합니다.
companion object StudentProgress {
    var total: Int = 10
    var answered: Int = 3
}
  1. Quiz.answeredQuiz.total이 있는 속성을 참조하도록 println() 호출을 업데이트합니다. 이러한 속성은 StudentProgress 객체에서 선언되지만 Quiz 클래스의 이름만 사용하여 점 표기법으로 액세스할 수 있습니다.
fun main() {
    println("${Quiz.answered} of ${Quiz.total} answered.")
}
  1. 코드를 실행하여 출력을 확인합니다.
3 of 10 answered.

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

Compose로 작업할 때 UI 요소의 크기를 지정하면 몇 가지 흥미로운 문법을 발견할 수 있습니다. Double과 같은 숫자 유형은 크기를 지정하는 dpsp와 같은 속성을 갖는 것으로 보입니다.

a25c5a0d7bb92b60.png

Kotlin 언어의 디자이너가 특히 Android UI를 빌드하기 위해 내장된 데이터 유형에 관한 속성과 함수를 포함하는 이유는 무엇일까요? 미래를 예측할 수 있었을까요? Compose가 존재하기 전에도 Kotlin은 Compose와 함께 사용하도록 설계되었을까요?

물론 아닙니다. 클래스를 작성할 때 다른 개발자가 앱에서 이를 어떻게 사용하거나 사용할 계획인지 정확히 알 수 없는 경우가 많습니다. 향후 모든 사용 사례를 예측할 수는 없으며 예기치 않은 사용 사례를 위해 코드에 불필요한 팽창을 추가하는 것도 현명하지 않습니다.

Kotlin 언어의 기능을 통해 다른 개발자는 기존 데이터 유형을 확장하여 해당 데이터 유형의 일부인 것처럼 점 문법으로 액세스할 수 있는 속성과 메서드를 추가할 수 있습니다 Kotlin에서 부동 소수점 유형을 작업하지 않은 개발자(예: Compose 라이브러리를 빌드하는 개발자)는 UI 크기와 관련된 속성과 메서드를 추가할 수 있습니다.

처음 두 단원에서 Compose를 학습할 때 이 문법을 확인했으므로 이제 내부적으로 어떻게 작동하는지 알아봐야 합니다. 일부 속성과 메서드를 추가하여 기존 유형을 확장합니다.

확장 속성 추가

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

1e8a52e327fe3f45.png

확장 속성을 사용하여 퀴즈 진행 상황을 출력하도록 main() 함수의 코드를 리팩터링합니다.

  1. Quiz 클래스 아래에서 String 유형의 progressText라는 Quiz.StudentProgress 확장 속성을 정의합니다.
val Quiz.StudentProgress.progressText: String
  1. main()에서 이전에 사용한 것과 동일한 문자열을 반환하는 확장 속성의 getter를 정의합니다.
val Quiz.StudentProgress.progressText: String
    get() = "${answered} of ${total} answered"
  1. main() 함수의 코드를 progressText를 출력하는 코드로 바꿉니다. 컴패니언 객체의 확장 속성이므로 클래스 이름 Quiz를 사용하여 점 표기법으로 액세스할 수 있습니다.
fun main() {
    println(Quiz.progressText)
}
  1. 코드를 실행하여 작동하는지 확인합니다.
3 of 10 answered.

확장 함수 추가

확장 함수를 정의하려면 함수 이름 앞에 유형 이름과 점 연산자(.)를 추가합니다.

879ff2761e04edd9.png

확장 함수를 추가하여 퀴즈 진행 상황을 진행률 표시줄로 출력합니다. Kotlin 플레이그라운드에서 진행률 표시줄을 실제로 만들 수는 없으므로 텍스트를 사용하여 복고 스타일의 진행률 표시줄을 출력합니다.

  1. 확장 함수를 printProgressBar()라는 StudentProgress 객체에 추가합니다. 함수는 매개변수를 사용하지 않고 반환 값이 없어야 합니다.
fun Quiz.StudentProgress.printProgressBar() {
}
  1. repeat()를 사용하여 문자를 answered 횟수로 출력합니다. 진행률 표시줄에서 어두운 음영으로 처리된 이 부분은 답변된 질문의 수를 나타냅니다. 각 문자 뒤에 새 줄이 필요하지 않기 때문에 print()를 사용합니다.
fun Quiz.StudentProgress.printProgressBar() {
    repeat(Quiz.answered) { print("▓") }
}
  1. repeat()을 사용하여 totalanswered 간 차이와 같은 횟수로 문자를 출력합니다. 밝은 음영으로 처리된 이 부분은 진행률 표시줄의 나머지 질문을 나타냅니다.
fun Quiz.StudentProgress.printProgressBar() {
    repeat(Quiz.answered) { print("▓") }
    repeat(Quiz.total - Quiz.answered) { print("▒") }
}
  1. 인수 없이 println()을 사용하여 새 줄을 출력하고 progressText를 출력합니다.
fun Quiz.StudentProgress.printProgressBar() {
    repeat(Quiz.answered) { print("▓") }
    repeat(Quiz.total - Quiz.answered) { print("▒") }
    println()
    println(Quiz.progressText)
}
  1. main()의 코드를 업데이트하여 printProgressBar()를 호출합니다.
fun main() {
    Quiz.printProgressBar()
}
  1. 코드를 실행하여 출력을 확인합니다.
▓▓▓▒▒▒▒▒▒▒
3 of 10 answered.

이러한 작업을 반드시 해야 하는 것은 아닙니다. 그러나 확장 속성 및 메서드를 사용할 수 있으면 다른 개발자에게 코드를 노출할 수 있는 옵션이 늘어납니다. 다른 유형에 점 문법을 사용하면 여러분과 다른 개발자가 모두 코드를 더 쉽게 읽을 수 있습니다.

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

이전 페이지에서는 코드를 직접 추가하지 않고 확장 속성 및 확장 함수를 사용하여 StudentProgress 객체에 속성과 메서드를 추가하는 방법을 알아봤습니다. 이는 이미 정의된 특정 클래스에 기능을 추가하는 좋은 방법이지만 소스 코드에 액세스할 수 있다면 클래스를 확장하는 것이 항상 필요하지는 않습니다. 특정 메서드나 속성이 존재해야 한다는 점을 제외하고는 어떤 구현이 필요한지 모르는 경우도 있습니다. 동일한 추가 속성과 메서드를 갖는 여러 클래스(동작은 다를 수 있음)가 필요한 경우 인터페이스를 사용하여 그러한 속성과 메서드를 정의할 수 있습니다.

예를 들어 퀴즈 외에도 진행률 표시줄을 사용할 수 있는 설문조사, 레시피 단계 또는 순서가 지정된 데이터에 대한 클래스가 있다고 가정해 보겠습니다. 이러한 각 클래스에 포함해야 하는 메서드 또는 속성을 지정하는 인터페이스라는 것을 정의할 수 있습니다.

eeed58ed687897be.png

인터페이스는 interface 키워드로 시작하고 UpperCamelCase로 표기한 이름, 여는 중괄호와 닫는 중괄호를 사용하여 정의합니다. 중괄호 내에 인터페이스를 준수하는 모든 클래스가 구현해야 하는 메서드 서명 또는 get-only 속성을 정의할 수 있습니다.

6b04a8f50b11f2eb.png

인터페이스는 일종의 계약입니다. 인터페이스를 준수하는 클래스를 인터페이스를 확장한다고 말합니다. 클래스는 콜론(:)과 공백, 인터페이스 이름을 차례로 사용하여 인터페이스를 확장하려고 함을 선언할 수 있습니다.

78af59840c74fa08.png

대신에 클래스는 인터페이스에 지정된 모든 속성과 메서드를 구현해야 합니다. 그러면 인터페이스를 확장해야 하는 모든 클래스가 정확히 동일한 메서드 서명으로 정확히 동일한 메서드를 구현할 수 있게 됩니다. 속성이나 메서드를 추가 또는 삭제하거나 메서드 서명을 변경하는 식으로 인터페이스를 수정하는 경우 컴파일러에서는 코드의 일관성과 간편한 유지 관리를 위해 인터페이스를 확장하는 모든 클래스를 업데이트해야 합니다.

인터페이스를 사용하면 인터페이스를 확장하는 클래스의 동작을 다르게 변경할 수 있습니다. 구현을 제공하는 것은 각 클래스의 책임입니다.

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

  1. Quiz 클래스 위에 ProgressPrintable이라는 인터페이스를 정의합니다. ProgressPrintable 이름을 선택한 이유는 이 인터페이스를 확장하는 모든 클래스가 진행률 표시줄을 출력할 수 있게 해주기 때문입니다.
interface ProgressPrintable {
}
  1. ProgressPrintable 인터페이스에서 progressText라는 속성을 정의합니다.
interface ProgressPrintable {
    val progressText: String
}
  1. ProgressPrintable 인터페이스를 확장하도록 Quiz 클래스의 선언을 수정합니다.
class Quiz : ProgressPrintable {
    ...
}
  1. Quiz 클래스에서 ProgressPrintable 인터페이스에 지정된 대로 String 유형의 progressText라는 속성을 추가합니다. 속성이 ProgressPrintable에서 비롯되었으므로 override 키워드가 val 앞에 오게 합니다.
override val progressText: String
  1. 이전 progressText 확장 속성에서 속성 getter를 복사합니다.
override val progressText: String
        get() = "${answered} of ${total} answered"
  1. 이전 progressText 확장 속성을 삭제합니다.

삭제할 코드:

val Quiz.StudentProgress.progressText: String
    get() = "${answered} of ${total} answered"
  1. ProgressPrintable 인터페이스에서 매개변수를 사용하지 않고 반환 값을 갖지 않는 printProgressBar라는 메서드를 추가합니다.
interface ProgressPrintable {
    val progressText: String
    fun printProgressBar()
}
  1. Quiz 클래스에서 override 키워드를 사용하여 printProgressBar() 메서드를 추가합니다.
override fun printProgressBar() {
}
  1. 이전 printProgressBar() 확장 함수의 코드를 인터페이스의 새 printProgressBar()로 이동합니다. Quiz 참조를 삭제하여 인터페이스의 새 progressText 변수를 참조하도록 마지막 행을 수정합니다.
override fun printProgressBar() {
    repeat(Quiz.answered) { print("▓") }
    repeat(Quiz.total - Quiz.answered) { print("▒") }
    println()
    println(progressText)
}
  1. 확장 함수 printProgressBar()를 삭제합니다. 이 기능은 이제 ProgressPrintable을 확장하는 Quiz 클래스에 속합니다.

삭제할 코드:

fun Quiz.StudentProgress.printProgressBar() {
    repeat(Quiz.answered) { print("▓") }
    repeat(Quiz.total - Quiz.answered) { print("▒") }
    println()
    println(Quiz.progressText)
}
  1. main()의 코드를 업데이트합니다. 이제 printProgressBar() 함수가 Quiz 클래스의 메서드이므로 먼저 Quiz 객체를 인스턴스화한 다음 printProgressBar()를 호출해야 합니다.
fun main() {
    Quiz().printProgressBar()
}
  1. 코드를 실행합니다. 출력된 내용은 변경되지 않았지만 이제 코드가 좀 더 모듈화되었습니다. 코드베이스가 커짐에 따라 슈퍼클래스에서 상속받지 않고도 동일한 인터페이스를 준수하는 클래스를 쉽게 추가하여 코드를 재사용할 수 있습니다.
▓▓▓▒▒▒▒▒▒▒
3 of 10 answered.

코드 구조화에 인터페이스가 도움이 되는 사용 사례가 매우 많으므로, 인터페이스가 공통 단원에서 자주 사용되는 모습을 보게 될 것입니다. 다음은 Kotlin을 계속 사용할 때 경험할 수 있는 인터페이스의 예입니다.

  • 종속 항목 수동 삽입. 종속 항목의 모든 속성과 메서드를 정의하는 인터페이스를 만듭니다. 인터페이스를 구현하는 클래스의 인스턴스를 사용할 수 있도록 종속 항목(활동, 테스트 사례 등)의 데이터 유형에 해당하는 인터페이스가 필요합니다. 이를 통해 기본 구현을 교체할 수 있습니다.
  • 자동화된 테스트 모의 처리. 모의 클래스와 실제 클래스는 모두 동일한 인터페이스를 따릅니다.
  • Compose Multiplatform 앱에서 동일한 종속 항목에 액세스. 예를 들어 기본 구현이 플랫폼마다 다르더라도 Android와 데스크톱에 공통된 속성 및 메서드 집합을 제공하는 인터페이스를 만듭니다.
  • Compose의 몇몇 데이터 유형(예: Modifier)이 인터페이스임. 그러면 기본 소스 코드에 액세스하거나 이를 수정하지 않고도 새 수정자를 추가할 수 있습니다.

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

이미 살펴본 바와 같이 Kotlin에는 코드를 더 간결하게 하는 많은 기능이 포함되어 있습니다.

Android 개발을 계속 학습하다 보면 접하게 되는 이러한 한 가지 기능이 범위 함수입니다. 범위 함수를 사용하면 변수 이름에 반복적으로 액세스하지 않고도 클래스의 속성과 메서드에 간결하게 액세스할 수 있습니다. 이는 정확히 무엇을 의미할까요? 예를 살펴보겠습니다.

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

범위 함수는 개발자가 객체의 이름을 참조하지 않고 객체의 속성 및 메서드에 액세스할 수 있도록 하는 고차 함수입니다. 이를 범위 함수라고 하는 이유는 전달된 함수의 본문이 범위 함수가 호출되는 객체의 범위를 가져오기 때문입니다. 예를 들어 일부 범위 함수를 사용하면 마치 함수가 클래스의 메서드로 정의된 것처럼 해당 클래스의 속성과 메서드에 액세스할 수 있습니다. 이렇게 하면 객체 이름을 포함하는 것이 중복될 때 객체 이름을 생략할 수 있어 코드를 더 쉽게 읽을 수 있습니다.

이해를 돕기 위해 이 과정의 후반부에서 알아볼 몇 가지 범위 함수를 살펴보겠습니다.

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

let() 함수를 사용하면 객체의 실제 이름 대신 식별자 it을 사용하여 람다 표현식의 객체를 참조할 수 있습니다. 이렇게 하면 두 개 이상의 속성에 액세스할 때 길고 좀 더 구체적인 객체 이름을 반복적으로 사용하지 않아도 됩니다. let() 함수는 점 표기법을 사용하여 모든 Kotlin 객체에서 호출할 수 있는 확장 함수입니다.

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

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

}
  1. 질문의 questionText, answer, difficulty를 출력하는 다음 코드를 추가합니다. question1, question2, question3의 경우 여러 속성에 액세스하는 동안 전체 변수 이름이 매번 사용됩니다. 변수 이름이 변경된 경우 모든 사용을 업데이트해야 합니다.
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()
}
  1. questionText, answer, difficulty 속성에 액세스하는 코드를 question1, question2, question3let() 함수 호출로 둘러쌉니다. 각 람다 표현식의 변수 이름을 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()
}
  1. main()의 코드를 업데이트하여 quiz라는 Quiz 클래스의 인스턴스를 만듭니다.
fun main() {
    val quiz = Quiz()
}
  1. printQuiz()를 호출합니다.
fun main() {
    val quiz = Quiz()
    quiz.printQuiz()
}
  1. 코드를 실행하여 모든 것이 제대로 작동하는지 확인합니다.
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()를 사용하여 변수 없이 객체의 메서드 호출

범위 함수의 멋진 기능 중 하나는 객체가 변수에 할당되기도 전에 객체에서 호출할 수 있다는 점입니다. 예를 들어 apply() 함수는 점 표기법을 사용하여 객체에서 호출할 수 있는 확장 함수입니다. 또한 apply() 함수는 변수에 저장될 수 있도록 해당 객체에 대한 참조를 반환합니다.

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

  1. Quiz 클래스의 인스턴스를 만들 때 닫는 괄호 뒤에 apply()를 호출합니다. apply()를 호출할 때 괄호를 생략하고 후행 람다 문법을 사용할 수 있습니다.
val quiz = Quiz().apply {
}
  1. 람다 표현식 내에서 printQuiz() 호출을 이동합니다. 더 이상 quiz 변수를 참조하거나 점 표기법을 사용할 필요가 없습니다.
val quiz = Quiz().apply {
    printQuiz()
}
  1. apply() 함수는 Quiz 클래스의 인스턴스를 반환하지만 더 이상 어디에서도 사용하지 않으므로 quiz 변수를 삭제합니다. apply() 함수를 사용하면 Quiz의 인스턴스에서 메서드를 호출하는 변수도 필요하지 않습니다.
Quiz().apply {
    printQuiz()
}
  1. 코드를 실행합니다. Quiz 인스턴스를 참조하지 않고도 이 메서드를 호출할 수 있었습니다. apply() 함수는 quiz에 저장된 객체를 반환했습니다.
Quoth the raven ___
nevermore
MEDIUM

The sky is green. True or false
false
EASY

How many days are there between full moons?
28
HARD

원하는 결과를 얻기 위해 범위 함수를 반드시 사용할 필요는 없지만 위 예는 범위 함수를 통해 코드를 더 간결하게 하고 동일한 변수 이름을 반복하지 않는 방법을 보여줍니다.

위의 코드는 두 가지 예만 보여주지만 과정의 후반부에서 사용하게 되므로 범위 함수 문서를 북마크에 추가하여 참조하는 것이 좋습니다.

9. 요약

여러 가지 새로운 Kotlin 기능의 작동 방식을 살펴봤습니다. 제네릭을 사용하면 데이터 유형을 클래스에 매개변수로 전달할 수 있고 enum 클래스는 가능한 값의 제한된 집합을 정의하며 데이터 클래스를 사용하면 클래스에 유용한 메서드를 자동으로 생성할 수 있습니다.

인스턴스 하나로 제한되는 싱글톤 객체를 만드는 방법과 이를 다른 클래스의 컴패니언 객체로 만드는 방법, 새 get-only 속성 및 새 메서드로 기존 클래스를 확장하는 방법도 살펴봤습니다. 마지막으로 범위 함수가 속성과 메서드에 액세스할 때 더 간단한 문법을 제공하는 방법을 보여주는 몇 가지 예를 확인했습니다.

Kotlin, Android 개발, Compose에 관해 자세히 알아보면서 이후 단원에 걸쳐 이러한 개념을 확인합니다. 이제 작동하는 방식과 코드의 재사용성 및 가독성을 개선하는 방식을 더 잘 알게 됐습니다.

10. 자세히 알아보기