안드로이드/안드로이드 Kotlin

[코틀린-Kotlin Bootcamp] 4. Object-oriented programming

요빈 2023. 3. 8. 16:00

다른 객체 지향 언어와 비슷하지만 코드의 양을 줄이기 위해 코틀린에서만 사용하는 주요 차이점들이 있다. 

본격적인 내용에 앞서 용어를 정리해보려 한다.

 

   - 클래스(Class): 개체의 청사진

   - 객체(Object): 클래스의 인스턴스

   - 속성(Property): 클래스의 특성(예를 들면, 수족관의 길이, 너비 및 높이)

   - 멤버함수(Method, member function): 클래스의 기능, 객체가 할 수 있는 것

   - 인터페이스(Interface): 클래스가 구현할 수 있는 사양

 ex) 청소는 수족관 이외의 객체에도 공통적이며 유사한 방식으로 수행됨 -> Clean 인터페이스를 상속받아 수족관 전용 clean() 메서드 정의

   - 패키지 : 패키지 내 코드와 클래스를 재사용할 수 있음

 

진행 단계는 다음과 같다

패키지 생성 -> 속성을 포함한 클래스 생성 -> main() 함수 생성 -> 메소드 추가 

클래스 생성 

내부적으로 코틀린은 클래스에서 정의한 속성에 대한 getter 및 setter를 자동으로 생성하므로 속성에 직접 액세스 할 수 있다.

val 변수는 값 변경이 불가능하기 때문에 getter만 자동 생성되며, var 변수는 둘 다 생성된다.

 

인스턴스를 만들기 위해 함수처럼 클래스를 참조해야 한다.

클래스의 생성자를 호출하고 다른 언어에서 new를 사용하는 것과 비슷하게 Aquarium 클래스의 인스턴스를 만든다.

fun buildAquarium(){
    val myAquarium = Aquarium()
}

생성자 생성

Aquarium 클래스 내에 생성자를 추가해 Aquarium 의 모든 인스턴스를 동일한 속성으로 설정한다.

자바에서 생성자는 클래스와 동일한 이름을 가진 클래스 내에서 메서드를 생성해 정의되지만,

코틀린에서는 클래스 선언 자체에서 생성자를 직접 정의하고 클래스가 메서드인 것처럼 괄호 내 매개변수를 지정한다.

즉, 자바는 클래스 선언과 생성자 선언이 별개 / 코틀린은 동시에!

아래 생성자는 속성을 선언하고 식의 값을 속성에 할당한다.

class Aquarium(length: Int = 100, width: Int = 20, height: Int = 40) {
	var width: Int = length
	var height: Int = width
	var length: Int = height
}

class Aquarium(var length: Int = 100, var width: Int = 20, var height: Int = 40) {
...
}

fun buildAquarium(){
	val aqua = Aquarium(width = 25)
	val aqua2 = Aquarium(height = 39, width = 10)
}

위 코드에서 하단 크래스를 보면 보다 간결하게 작성되어 있는 것을 확인할 수 있다.

코틀린에서는 var 또는 val을 사용해 생성자와 속성을 직접 정의할 수 있으며,  getter와 setter도 자동으로 생성한다.

인스턴스 생성은 val a = Aquarium(with = 25)와 같이 따로 값을 지정해 선언할 수 있다.

 

생성자에 더 많은 초기화 코드가 필요한 경우 초기화 블록(init block)을 배치할 수 있다.

생성자를 호출하면 init 블록 내 코드가 가장 먼저 실행되며 클래스 정의 시 배치한 순서대로 실행된다.

 

* 보조 생성자

 

하나 이상의 초기화 블록을 가질 수 있는 기본 생성자 외에도 코틀린 클래스에는 생성자 오버로드를 허용하는 보조 생성자, 

즉 다른 인수가 있는 생성자가 있을 수 있다. (하지만 하나만 있는 걸 권장함)

모든 보조 생성자는 직접 this()를 사용하거나, 다른 보조 생성자를 호출하여 간접적으로 기본 생성자를 먼저 호출해야 한다.

이는 모든 생성자에 대해 기본의 모든 초기화 블록이 호출되고 기본 생성자의 모든 코드가 먼저 실행됨을 의미합니다.

 

보조 생성자는 constructor 키워드를 통해 추가할 수 있다.

// Aquarium.kt
constructor(numberOfFish: Int) : this() {
    val tank = numberOfFish * 2000 * 1.1
}

// main.kt
fun buildAquarium() {
	val aquarium6 = Aquarium(numberOfFish = 29)
}

아래 aquarium6를 호출하면 기존 생성자의 init 블록에 작성한 코드들이 먼저 실행된다는 점 다시 한번 유의!

 

명시적 속성 getter 

코틀린은 속성 정의 시 getter와 setter를 자동으로 정의해 aqua.height = 5와 같이 값을 받아옴과 동시에 지정이 가능하다.

하지만 속성 값을 조정하거나 계산해야할 때 사용한다.

즉, 기존 속성값을 이용해 새로운 값을 구할 때 사용!

var volume: Int
    get() = width * height * length / 1000 // 1000 cm^3 = 1 l
    set(value) {
        height = (value*1000) / (width * length)
    }

 

접근 제한자(visibility modifiers, 가시성 수정자)

기본적으로 코틀린의 모든 항목은 public이므로 클래스, 메서드, 속성 및 멤버 변수를 비롯한 모든 항목에 액세스 할 수 있다.

코틀린에서 클래스, 객체, 인터페이스, 생성자, 함수, 속성 및 해당 setter는 접근 제한자를 가질 수 있습니다.

접근 제한자의 종류는 다음과 같다.

   - public: 클래스 변수와 메서드를 포함한 모든 것이 공개되어 클래스 외부에서 볼 수 있음

   - internal: 해당 모듈 내에서만 볼 수 있음 (* 모듈:  함께 컴파일된 Kotlin 파일 집합(예: 라이브러리 또는 애플리케이션))

   - private: 해당 클래스(또는 함수로 작업하는 경우 소스 파일)에서만 볼 수 있음

   - protected: private와 동일하지만 모든 하위 클래스에서도 볼 수 있습니다.

 

멤버 변수는 기본적으로 public이며, var은 읽고 쓸 수 있고, val은 읽기 전용으로 사용된다.

코드에서 읽거나 쓸 수 있지만 외부 코드에서는 읽기만 가능한 속성을 원하는 경우 

아래와 같이 속성과 해당 getter를 공개로 두고 setter를 비공개로 선언할 수 있습니다.

var volume: Int
    get() = width * height * length / 1000
    private set(value) {
        height = (value * 1000) / (width * length)
    }

 

하위 클래스와 상속

코틀린에서는 기본적으로 클래스를 하위 클래스로 분류할 수 없다. 

마찬가지로 속성 및 멤버 변수는 하위 클래스에서 재정의할 수 없다.

 

클래스를 하위 클래스로 만들려면 클래스에 open 키워드를 붙여야 하고, 속성과 멤버 변수를 하위 클래스에서 재정의하려면 open 표시를 해주어야 한다. 클래스 인터페이스의 일부로 구현 세부 정보가 실수로 유출되는 것을 방지하려면 open 키워드가 필요하다. 

open class Aquarium (open var length: Int = 100, open var width: Int = 20, open var height: Int = 40) {
    open var volume: Int
        get() = width * height * length / 1000
        set(value) {
            height = (value * 1000) / (width * length)
        }

 

하위 클래스에서 속성을 재정의 하려면 override 키워드를 사용해야 한다.

또한 하위 클래스는 생성자 매개변수를 명시적으로 선언해야 한다.

class TowerTank (override var height: Int, var diameter: Int): Aquarium(height = height, width = diameter, length = diameter) {

 

인터페이스 / 추상 클래스

일부 관련 클래스 간에 공유할 공통 동작 또는 속성을 정의하려는 경우가 있다.

이를 위해 Kotlin은 인터페이스와 추상 클래스라는 두 가지 방법을 제공한다.

   - 추상 클래스나 인터페이스는 자체적으로 인스턴스화할 수 없다. 즉, 해당 유형의 개체를 직접 만들 수 없다.

   - 추상 클래스에는 생성자가 있지만 인터페이스는 생성자나 어떠한 상태를 저장할 수 없다.

   * 추상 클래스는 항상 열려 있기 때문에 open 키워드가 필요하지 않다.

 

[ 추상 클래스 ]

 

추상클래스 내 속성과 메서드는 abstract 키워드로 명시적인 표시를 하지 않는 이상 추상적이지 않다.

즉, 하위 클래스에서 주어진 대로 사용할 수 있다.

하지만 abstract 키워드가 선언되어 있다면 하위 클래스 이를 구현해야 한다.

아래 Shark 클래스가 AquariumFish()를 상속받고 있으며 추상 속성인 color를 override하고 있다.

abstract class AquariumFish {
    abstract val color: String
}

class Shark: AquariumFish() {
    override val color = "gray"
}

위 추상 클래스와 하위 클래스를 그림으로 나타내면 다음과 같다.

[인터페이스]

 

인터페이스는 생성자 없이 구현해야 할 함수를 명시한다.

이를 상속받은 클래스는 명시된 함수를 무조건 재정의 해야 한다.

interface FishAction  {
    fun eat()
}

class Shark: AquariumFish(), FishAction {
    override val color = "gray"
    override fun eat() {
        println("hunt and eat fish")
    }
}

추상 클래스와 인터페이스를 사용하면 상호 관련된 클래스가 많은 경우 디자인을 보다 깔끔하고 체계적이며, 쉽게 유지 및 관리할 수 있다.

추상 클래스와 인터페이스는 매우 유사하다. 그렇다면 각각 언제 사용해야 할까?

 

인터페이스를 사용해 클래스를 구성(compose)하면 클래스에 포함된 클래스 인스턴스를 통해 클래스의 기능이 확장된다. 

구성(Composition)은 추상 클래스에서 상속하는 것보다  코드를 더 쉽제 재사용하고, 추론하는 경향이 있다.

또한 한 클래스에서 인터페이스는 여러개 사용할 수 있지만 추상 클래스는 하나만 상속받을 수 있다.

 

컴포지션(Composition)은 종종 더 나은 캡슐화, 더 낮은 결합(상호 의존성), 깔끔한 인터페이스 및 더 유용한 코드로 이어진다.

이러한 이유로 인터페이스와 함께 컴포지션을 사용하는 것이 선호되곤 한다.

* 상속: 모든 요소를 물려받아 변수나 메소드를 다시 구현할 필요가 없어 편리하지만 객체의 유연성이 떨어진다는 단점이 있다.

   구성: 상속이 아닌 객체 소유로 상위 클래스의 요소를 활용한다. 특정 클래스가 어느 한 클래스의 부분이 되는 것.

* 상속: is - a / 구성: has - a

* 상속 / 구성과 관련된 내용은 다음 글에 잘 설명되어 있다. 

https://thdev.tech/kotlin/2020/12/01/kotlin_effective_13/

 

상속! 악마의 속삭임, 그 속에 숨겨진 문제점, Kotlin에서는 Delegation을 활용해 보자. |

I’m an Android Developer.

thdev.tech

메서드가 많고 적은 수의 구현이 있는 경우 인터페이스를 사용한다.

클래스를 완료하지 못할 때 추상 클래스를 사용한다. ....?

 

인터페이스 위임(Interface delegation)

인터페이스 위임은 인터페이스의 메서드가 도우미 개체에 의해 구현된 다음 클래스에서 사용되는 고급 기술이다.

이 기술은 일련의 관련 없는 클래스에서 인터페이스를 사용할 때 유용하다.

필요한 인터페이스 기능을 별도의 도우미 클래스에 추가하고 각 클래스는 도우미 클래스의 인스턴스를 사용하여 기능을 구현한다.

 

도우미 클래스가 여러 인스턴스를 만드는 것은 모두 동일한 작업을 수행하기 때문에 이치에 맞지 않다.

따라서 class 대신 object 키워드를 사용해 하나의 인스턴스만 생성할 수 있는 클래스를 선언한다.

코틀린은 하나의 인스턴스를 생성하고 해당 인스턴스는 클래스 이름으로 참조되고, 다른 모든 개체가 이 인스턴스 하나만 사용할 수 있다.

 

클래스 선언에 인터페이스 by 도우미 클래스(Object)를 추가해 위임을 생성한다.

즉, 인터페이스를 구현하는 대신 도우미 클래스에서 제공하는 구현을 사용하라는 것이다.

interface FishColor { // AquariumFish 대신 사용할 인터페이스
    val color: String
}

object GrayColor: FishColor{ // 도우미 클래스
    override val color = "gray"
}

class Shark: FishAction,FishColor by GrayColor {
    override fun eat() {
        println("hunt and eat fish")
    }
}

// 위와 같이 인터페이스를 위임받으면 모든 Shark는 gray가 된다.
// 하지만 모든 물고기가 같은 색은 아니므로 아래와 같이 매개변수로 받는 방식으로 수정한다.
class Plecostomus2(fishColor: FishColor = GrayColor):  FishAction, FishColor by fishColor {
    override fun eat() {
        println("eat algae")
    }
}

위 이미지는 인터페이스 위임에 사용한 예시를 그림으로 나타낸 것이다.

 

인터페이스 위임은 강력하다.

각각 다른 방식으로 특화된 많은 하위 클래스를 사용하는 대신 구성(Composition)을 사용해 동작을 플러그인 할 수 있다.

 

데이터 클래스

데이터 클래스는 일부 다른 언어의 구조체와 유사하지만 주로 일부 데이터를 보유하기 위해 존재한다.

코틀린 데이터 클래스 개체에는 인쇄 및 복사 유틸리티와 같은 몇 가지 추가 이점이 있다.

데이터 클래스는 다음과 같이 클래스 앞에 data 키워드를 붙여준다.

data class Decoration(val rocks: String) {
}

데이터 객체의 속성을 가져오고 변수에 할당하려면 한 번에 하나씩 할당할 수 있다.

이 외에도 각 속성에 대해 하나씩 변수를 만들고 데이터 객체를 변수 그룹에 할당할 수도 있다. 

아래 경우 decoration이 3개의 속성을 가져야 한다.

val rock = decoration.rock
val wood = decoration.wood
val diver = decoration.diver

val (rock, wood, diver) = decoration
# wood는 필요 없는 경우 다음과 같이 작성할 수 있다.
val (rock, _, diver) = decoration

이것을 구조분해라고 하며 유명한 속기이다. 변수의 개수는 속성의 개수와 일치해야 하며 변수는 클래스에 선언된 순서대로 할당된다.

 

특수 목적 클래스(Singleton class, Enums, Sealed class)

[ Singleton Class ]

 

인터페이스 위임 예제에서 모든 인스턴스가 동일한 작업을 수행하기 때문에 싱글톤으로 사용하기 위해 클래스가 아닌 객체로 선언했었다.

즉, 하나의 인스턴스만 존재할 수 있다는 의미이다.

 

[ Enums ]

 

코틀린은 Enum도 지원하므로 다른 언어와 마찬가지로 무언가를 열거하고 이름으로 참조할 수 있다.

선언 앞에 enum 키워드를 붙여 Enum을 선언한다.

enum class Color(val rgb: Int) {
   RED(0xFF0000), GREEN(0x00FF00), BLUE(0x0000FF);
}

열거형의 각 값은 하나만 있을 수 있으며 여러 속성을 통해 데이터에 접근할 수 있다.

ordinal 속성을 통해 enum의 서수(순서) 값을, name 속성을 통해 해당 이름을, degrees 속성을 통해 해당 변수의 값을 가져올 수 있다.

 

[ Sealed Class ]

 

Sealed Class는 서브클래싱할 수 있지만 선언된 파일 내부에서만 가능한 클래스이다. 다른 파일에서 실행 시 오류가 발생한다.

sealed class Seal
class SeaLion : Seal()
class Walrus : Seal()

fun matchSeal(seal: Seal): String {
   return when(seal) {
       is Walrus -> "walrus"
       is SeaLion -> "sea lion"
   }
}

Seal 클래스는 다른 파일에서 서브 클래싱할 수 없다. 씰 유형을 추가하려면 동일한 파일에 추가해야 하며 이렇게 하면 Seal 클래스가 고정된 수의 유형을 나타내는 안전한 방법이 된다. 예를 들어 네트워크 API에서 성공 또는 오류를 반환하는 데 유용하다.

 

 

* 다음 과정을 공부하며 작성한 글입니다.

https://developer.android.com/codelabs/kotlin-bootcamp-classes#0

 

Kotlin Bootcamp for Programmers 4: Object-oriented programming  |  Android Developers

Use IntelliJ IDEA to learn about classes and inheritance in Kotlin.

developer.android.com