선생님, 개발을 잘하고 싶어요.

[kotlin] [안드로이드 개발에 필요한 최소의 코틀린 강좌] part4 - 확장 함수 본문

강좌/kotlin 강좌

[kotlin] [안드로이드 개발에 필요한 최소의 코틀린 강좌] part4 - 확장 함수

알고싶은 승민 2021. 3. 6. 13:59

도입

다시 함수로 돌아와 볼까요? 이번 시간엔 코틀린이 제공하는 아주 강력한 기능인 확장 함수(Extension Function)에 대해서 알아보겠습니다.

저는 이 확장 함수가 코틀린을 코틀린답게 만드는 가장 큰 요소라고 생각하는데요. 같이 더 깊은 코틀린의 세계로 빠져 봅시다.

TL;DR

  • 확장 함수는 어떤 클래스의 인스턴스가 호출할 수 있는 함수를 클래스 밖에 정의하는 것이다.
  • fun 클래스이름.함수이름(...) { } 형태로 정의한다.

Toast, Toast, Toast

안드로이드를 개발하다 보면 사용자에게 Toast를 보여줘야 하는 경우가 생깁니다. 간단한 Toast를 보여주는 코드와 함께, 오늘의 포스팅을 시작해봅시다.

 

버튼을 누를 때, 사용자에게 토스트를 보여주고 싶다고 합시다.

class SampleActivity : AppCompatActivity() {

    private lateinit var sampleView: View

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_sample)

        sampleView.setOnClickListener {
            Toast.makeText(this, "sample toast message", Toast.LENGTH_SHORT).show()
        }
    }
}

 

조금 길긴 하지만 꽤나 간단합니다. 그렇죠?

하지만 Toast를 보여줘야 하는 코드가 많아졌다고 생각해 봅시다.

Toast.makeText(this, "I'm seungmin.", Toast.LENGTH_SHORT).show() 
Toast.makeText(this, "코틀린 강좌 올려야 하는데 하는데 하는데", Toast.LENGTH_SHORT).show() 
Toast.makeText(this, "가끔씩 찾아오는 강좌", Toast.LENGTH_SHORT).show() 

 

이 라인을 반복해서 쓰게 됩니다. 기본적으로 프로그래머는 귀차니스트입니다. 이런 긴 코드의 반복을 세상에서 제일 싫어하죠.

이럴 때 가장 쉽게 떠올릴 수 있는 방법은 뭘까요? 중복되는 코드에서 반복되는 부분을 때어내고, 변하는 부분을 파라미터로 하는 함수를 만드는 것입니다. 해볼까요?

 

(코틀린에서 함수를 어떻게 만드는지 모른다구요? 괜찮습니다. 조용히 안드로이드 개발에 필요한 최소의 코틀린 강좌 part2를 보고 오시면 됩니다. 🌝)

class SampleActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_sample)

        sampleView.setOnClickListener {
            showToast("sample toast message")
        }
    }

    private fun showToast(message: String) {
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
    }
}

오, 꽤 괜찮은 것 같습니다. 이렇게 변경하면 아까 그 코드는 이렇게 되겠죠.

 

showToast("I'm seungmin.")
showToast("코틀린 강좌 올려야 하는데 하는데 하는데")
showToast("가끔씩 찾아오는 강좌")

마음의 평화가 찾아오시나요?

 

조금만 나중에 마음에 평화를 가져봅시다.

그럼 다시 더 어려운 상황을 상정해 볼까요?

이제, 이 Toast가 이 SampleActivity 뿐 아니라, 다른 Activity에서도 무수히 반복된다고 생각해봅시다. 어떤 상황일까요? 코드로 간단히 보여드릴게요.

 

//SampleActivity.kt
class SampleActivity : AppCompatActivity() {
    //...

    private fun showToast(message: String) {
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
    }
}

//MainActivity.kt
class MainActivity : AppCompatActivity() {
    //...

    private fun showToast(message: String) {
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
    }
}

//HandlerActivity.kt
class HandlerActivity : AppCompatActivity() {
    //...

    private fun showToast(message: String) {
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
    }
}

이제 다시 반복되고 있는게 눈에 보이시죠? 바로 showToast 함수 자체가 반복되고 있습니다.

이럴 때 우리가 쉽게 시도할 수 있는 방법은 뭘까요? 역시 함수화입니다.

 

다만 함수의 위치가 조금 다릅니다.

기존에 Java를 사용하신 분이라면 static function을 제공하는 유틸리티 클래스를 제공해야겠다고 생각할 수 있습니다.

 

하지만 코틀린은 더 쉽습니다. 그냥 .kt 파일을 만들고 클래스의 정의 없이 함수를 정의할 수 있거든요! 그리고 그 함수는 어디에서나 접근 가능한 전역 유틸리티 함수가 됩니다.

 

코드로 보시죠.

//Toast.kt

// 이제 인자로 context도 받아야 함에 유의해주세요.
// 이 완전히 독립적인 함수에서 this는 아무런 의미가 없답니다 그렇죠?
fun showToast(context: Context, message: String) {
    Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}

//SampleActivity.kt
class SampleActivity : AppCompatActivity() {
    //... showToast(this, message) 사용
}

//MainActivity.kt
class MainActivity : AppCompatActivity() {
    //... showToast(this, message) 사용
}

//HandlerActivity.kt
class HandlerActivity : AppCompatActivity() {
    //... showToast(this, message) 사용
}

드디어 우리가 원하는 깔끔한 코드의 완성!일까요...?

 

사실 여기까지만 하셔도 좋습니다. 훌륭합니다. 하지만 코틀린의 확장 함수를 사용한다면 더 구조화되고, 잘못된 사용을 줄일 수 있으며, 깔끔한 코드를 만들 수 있게 됩니다.

 

바로 확장 함수를 사용하는 것입니다. 일단 확장 함수의 모습을 보여드리고 설명할 것이니, 천천히 따라와 주세요. 처음 보시는 분은 이해하기 어려울 수 있습니다.

//ContextExt.kt
fun Context.showToast(message: String) {
    Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}

이런 함수가 정의되어 있다면, 우리가 처음 봤던 코드는 이렇게 바뀝니다.

class SampleActivity : AppCompatActivity() {

    private lateinit var sampleView: View

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_sample)

        sampleView.setOnClickListener {
            showToast("sample toast message")
        }
    }
}

우와! SampleActivity는 더 이상 showToast 함수를 가지고 있지 않은데도, 마치 showToast private함수가 있던 것처럼 코드를 작성할 수 있습니다. this를 넘겨주지 않고도요!

 

어떻게 이게 가능할까요? 다시 확장 함수의 정의로 돌아갑시다.

//ContextExt.kt
fun Context.showToast(message: String) {
    Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}

확장 함수를 정의하는 방법

함수 정의를 잘 보면 이렇게 써져있습니다.

fun Context.showToast(...) { ... }

 

어라? 함수 이름에는 .이 못 들어가는 것 아닌가요? 맞습니다. 여기서 .은 독특한 의미로 쓰였습니다.

 

.앞의 클래스의 인스턴스가 호출 가능한 함수 showToast를 정의한다.라는 의미입니다.

간단히 구조화하면 확장 함수는 다음과 같은 형태로 정의합니다.

악필 죄송...

 

그러니 우리가 위에서 정의한 함수는 Context instance에게 showToast라는 함수를 추가해줘 라는 요청입니다.

 

Context의 행동을 Context Class 수정 없이 추가했다는 점을 눈여겨보세요.

확장 함수 내부에서 this

함수의 정의는 이해가 됩니다. 그냥 그렇게 쓰면 되는구나 할 수 있습니다. 그렇다면 this는 도대체 뭘까요?

 

이 함수가 실행되는 시점을 살펴봅시다.

class SampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        sampleView.setOnClickListener {
            // this는 SampleActivity의 실제화된 instance를 가리킵니다.
            this.showToast("sample toast message")
        }
    }
}

이 코드에서 this는 무엇을 의미하나요? onCreate를 호출하는 코드는 어떤 형태를 취하고 있을까요?

val sampleActivity = SampleActivity()

sampleActivity.onCreate(null)

class에 적어놓은 this는 sampleActivity라는 실제 instance를 나타냅니다.

class 정의는 실제 instance가 아니고 instance의 틀을 정의하는 것이니, 실제 instance 주소를 참조할 수 없고, this라는 키워드로 대체하는 겁니다.

 

자, 이제 위의 확장 함수 내부에서 this는 무슨 의미일까요?

바로, showToast를 호출하는 실제 Context instance를 가리킵니다. class의 정의 시점에 실제 instance에 대한 참조를 제공하려고 this 키워드를 만든 것 처럼, 확장 함수 정의 시점에도 호출 시점에 결정될 class instance에 대해서 알 방법을 this 키워드로 제공한 겁니다.

이 클래스는 우리가 정의한 클래스든, 원래 정의되어 있던 클래스든 상관없습니다.

 

실제로, 코틀린 라이브러리엔 무수히 많은 확장 함수들이 포함되어 있습니다. 몇 개 같이 보시죠.

//_Colllections.kt
public fun List<Int>.sum(): Int {
    var sum: Int = 0
    for (element in this) {
        sum += element
    }
    return sum
}

// 우리의 코드 어딘가...

val ages: List<Int> = listOf(25,26)

// List<Int> 클래스의 인스턴스인 ages는 List<Int>.sum() 함수를 호출할 수 있습니다.
// 51
println(ages.sum())
//_Colllections.kt
public fun List<Int>.average(): Double {
    var sum: Double = 0.0
    var count: Int = 0
    for (element in this) {
        sum += element
        checkCountOverflow(++count)
    }
    return if (count == 0) Double.NaN else sum / count
}

// 우리의 코드 어딘가...

val ages: List<Int> = listOf(25,26)

// List<Int> 클래스의 인스턴스인 ages는 List<Int>.average() 함수를 호출할 수 있습니다.
// 25.5
println(ages.average())

 

마지막으로, 처음 코드로 돌아가면 어떤 일이 일어났던 건지 알 수 있습니다. 주석을 자세히 봐주세요.

//ContextExt.kt
fun Context.showToast(message: String) {
    Toast.makeText(this, message, Toast.LENGTH_SHORT).show()  
}

//SampleActivity.kt
class SampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        sampleView.setOnClickListener {
            // SampleActivity는 Context의 인스턴스 이기 때문에 Context.showToast()를 호출할 수 있다.
            showToast("sample toast message")
        }
    }
}

마무리

DRY - Don't Repeat Yourself (반복하지 마라) - 실용주의 프로그래머, Tip 11

간단한 Toast를 보여주는 함수라도 앱 전체에 반복될 때, 우리는 뭔가 염증을 느끼게 됩니다. 그리고 모든 언어들은 이러한 염증을 해소하기 위해서 함수 기능을 제공합니다.

 

하지만 기존의 함수는 아주 기본적인 해결책일 뿐, 우아한 해결책이 아니라는 것을 봤습니다.

 

그리고 코틀린은 이 문제를 우아하게 풀기 위해서 확장 함수라는 대안을 내놓았고, 실제로 그 자신의 라이브러리 코드에서 활용하고 있다는 걸 확인했습니다.

 

확장 함수와 함께라면 누구라도 자신만의 라이브러리를 만들 수 있습니다. 남의 라이브러리에서 제공하는 클래스를 변경할 순 없지만, 상속을 하지 않고도 그 클래스에 동작을 추가하는 것이 가능해졌습니다.

 

다른 사람이 만든 클래스 위에서 자신의 프로젝트에 적합한 라이브러리를 만들어낼 힘을 가지게 된 겁니다.

 

확장 함수는 매우 실용적인 친구입니다. 이번 포스팅으로 확장 함수를 왜 사용하고 어떻게 사용해야 하는지 아주 조금의 감을 느끼시고, 본인의 프로젝트에 적용해 보면서 자신만의 사용법을 만들어 나가시길 바랍니다.

 

감사합니다. 🙇‍♂️

 

다음 강의는 이러한 확장 함수를 이용한 코틀린의 중요하고 빈번하게 쓰이는 범위 지정 함수 (let, apply, also, with, run)에 대해서 다루겠습니다.

추신, 상속과의 비교.

//SampleActivity.kt
class SampleActivity : AppCompatActivity() {
    //...

    private fun showToast(message: String) {
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
    }
}

//MainActivity.kt
class MainActivity : AppCompatActivity() {
    //...

    private fun showToast(message: String) {
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
    }
}

//HandlerActivity.kt
class HandlerActivity : AppCompatActivity() {
    //...

    private fun showToast(message: String) {
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
    }
}

만약 part3을 보신 분은 이 코드를 보고 확장 함수 없이도 쉽게 해결할 문제라고 생각하시겠죠? 바로 상속으로도 이 문제를 해결할 수 있습니다.

 

공통 부모를 만들고, 해당 부모가 showToast 함수를 protected로 제공하면 되겠죠?

 

//BastActivity.kt

// open 키워드를 써야 상속 가능합니다.
open class BaseActivity : AppCompatActivity() {
    //...

    // protected 를 써야 파생 클래스에서 사용이 가능하겠죠?
    protected fun showToast(message: String) {
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
    }
}

//SampleActivity.kt
class SampleActivity : AppCompatActivity() {
    //... showToast(message) 사용
}

//MainActivity.kt
class MainActivity : AppCompatActivity() {
    //... showToast(message) 사용
}

//HandlerActivity.kt
class HandlerActivity : AppCompatActivity() {
    //... showToast(message) 사용
}

어떤가요? 어느 쪽이 더 마음에 드시나요? 혹시 상속이 더 마음에 드시나요? 그래도 여전히 확장 함수는 유용합니다.

 

지금은 동작을 추가하려는 클래스가 운이 좋게도 우리가 만든 클래스에, 상속을 변경할 수 있는 클래스라서 가능한 거였습니다.

 

만약 우리가 String에 대해서 이런 동작을 추가하고 싶다고, 모든 String을 CustomString으로 변경할 수 있을까요? 그럴 순 없죠. 상속만으론 해결하기 어려운 이런 문제를 코틀린의 확장 함수가 해결해 준다고 생각하시면 됩니다.

 


[part1] 선언, 자료형, 리스트

[part2] 흐름 제어, 함수 정의, 코틀린만의 클래스

[part3] 클래스 심화, 확장

[part4] 확장 함수 (we're here)

Comments