본문 바로가기
프로그래밍/코틀린

K013. 코틀린 함수(Functions) 깊게 살펴보기 - Functions 파트4

by K-인사이터 2024. 2. 8.
반응형

안녕하세요

K-IN 입니다.

 

코틀린 함수(Functions)에 대해서 알아보겠습니다. 

전체 강의 목록은 아래의 링크를 클릭해주세요.

 

K000. 코틀린 시리즈 (연재물)

안녕하세요 K-IN 입니다. 요즘 코틀린을 이용한 개발 프로젝트가 늘어나고 있습니다. 이에, 코틀린에 대해서 상세하게 정리하는 간행물을 제작하고자 합니다. 여기에 있는 링크들은 모두 코틀린

k-in.tistory.com

 

 

코틀린 함수(Functions) 

코틀린에서 함수에 대해서 이해하려면 아래의 개념들을 숙지해야합니다. "☆" 표기는 중요도를 나타냅니다.

분량이 많아 이글에서는 9~11번 항목까지만 다룹니다. 

 

이전 내용 코틀린 함수를 자세히 배우려면 아래의 링크들을 참고해주세요.

  1. 기본적인 함수 구조 ☆
  2. 함수 사용 방법 ☆
  3. 기본 인자(Default Arguments) ☆☆
  4. 이름지정 인자(Named Arguments) ☆☆
  5. 단일 표현식 함수(Single-expression Functions) ☆☆☆
  6. 명시적 리턴 타입(Explicit Return Types)
  7. 가변인자 (varargs) ☆☆☆
  8. 중위 표기법 (Infix Notation) ☆☆☆
  9. 함수의 범위 (Function Scope) ☆☆☆
  10. 제너릭 함수 (Generic Functions) ☆☆☆
  11. 꼬리 재귀 함수 (Tail Recursive Functions)
  12. 람다 함수 (Lambdas)
  13. 인라인 함수 (Inline Functions)
  14. 연산자 오버로딩 (Operator overloading) 
  15. 빌더 (Builders)

 

코틀린 함수의 범위(Function Scope)

Java, C#, Scala 언어에서는 전역 레벨(최상위 수준)에서 함수를 선언할 수 없었습니다

따라서, 유틸리티 함수를 정의하기 위해서는 클래스를 만들어야 했습니다

 

코틀린은 전역 레벨에서 함수를 선언할 수 있으며 외에도 로컬에서 함수를 정의할 수 있습니다. 

즉, 기존 객체 지향 언어들과는 다르게 어디서나 함수를 선언해서 사용할 수 있음을 의미합니다. 

 

전역(최상위), 로컬 레벨(또는 수준)이라는 용어가 헷갈릴 수 있습니다.

아래의 정의를 통해 쉽게 이해할 수 있습니다. 

  • 최상위(전역) 레벨이란? 특정 클래스나 객체 내부가 아닌, 파일 최상단에 위치한 함수, 변수, 클래스를 말합니다. 
  • 로컬 레벨이란? 함수나 클래스의 내부에서 선언되는 변수나 함수를 말합니다. 

그렇다면 함수의 범위(Function Scope)이란 어떤 의미일까요? 

바로 함수가 정의되고 호출될 수 있는 코드의 영역을 말합니다.

예를 들어, 로컬 함수 내에 정의된 함수는 다른 함수에서 호출 할 수 없습니다. 

반면, 전역(최상위) 수준에서 정의된 함수는 어디에서나 접근이 가능합니다. 

 

함수의 범위를 구체적으로 나열하여 정의해볼 수 있습니다. 

  • 전역 함수 범위: 소스코드 최상위 레벨에 정의될 경우 전역 함수로 간주되며 어디에서든 접근이 가능합니다. 
  • 로컬 함수 범위: 코틀린은 함수 내부에서 함수를 정의할 수 있으며 이를 로컬 함수라고 부릅니다. 로컬 함수는 정의된 함수 내부에서만 호출 될 수 있습니다.
  • 멤버 함수 범위: 멤버 함수란 클래스나 객체 내부에서 정의된 함수입니다. 이 함수는 해당 클래스의 인스턴스를 통해서만 호출할 수 있습니다.
  • 확장 함수 범위: 확장 함수란 기존 클래스에서 새로운 함수를 '추가'하는 방식으로 정의됩니다. 클래스 밖에서 정의되며 this 와 같이 클래스 변수에 접근이 가능하나 private 멤버에는 접근할 수 없는 특성이 있습니다. 

지금까지 배운 개념들을 종합한 샘플 코드를 살펴보면서 개념을 쉽게 이해할 수 있습니다. 

// 소스코드 내 최상위 레벨 영역입니다.

// sayHello 는 전역 함수이며 전역 함수 범위를 가지므로 어디에서든 사용이 가능합니다. 
fun sayHello() {
    println("Hello, World!")
}


fun outerFunction() {
    val outerVar: String = "아우터 변수"
    // innerFunction 은 로컬 함수이며 함수 내에서 정의됩니다. 
    // outerFunction 함수 내에서만 호출이 가능합니다. 

    fun innerFunction() {
        println("innerFunction 은 '$outerVar'에 접근이 가능합니다. ")
    }
    innerFunction()
}

class Greeter {
    // greet은 클래스나 객체 내부에서 정의되는 함수로 "멤버 함수" 입니다. 
    fun greet() {
        println("Hello, 저는 Greeter 클래스의 멤버 함수인 greet 입니다. ")
    }
}

// 클래스 외부에서 새로운 함수를 '추가'하는 확장함수의 예시입니다. 
fun String.exclaim() {
    println(this + "!")
}

fun main() {
    outerFunction()
    // innerFunction 은 로컬 함수이므로 호출 시 에러가 발생합니다. 
    // 에러메시지: Unresolved reference: innerFunction
    // innerFunction()
    
    Greeter().greet()
    // greet 역시 외부에서 호출이 불가능합니다. 
    // Unresolved reference: greet
    // greet()
    
    // 기존 문자열 객체에 확장함수가 추가되어 호출이 가능합니다. 
	"안녕하세요".exclaim()
    
}

 

코틀린 제너릭 함수(Generic Functions)

제너릭 함수는 C++, C#, Java 등에서도 사용되는 유용한 개념입니다.

특정 변수의 타입에 종속되지 않는 구현을 할 수 있어 다양한 변수 타입에 대한 일반적인 로직을 정의하고 응용할 수 있습니다. 

이를 통해 개발자는 재사용성과 유지보수성이 향상되는 효과를 누릴 수 있습니다. 

 

예를 들어, 문자열 타입 컬렉션에 대응하는 함수를 여러분이 정의하여서 잘 사용하고 있었습니다. 

그런데, 정수 타입 컬렉션에 대응하는 동일한 로직이 필요할 경우 중복된 코드를 작성해야합니다. 

제네릭 함수를 사용하게되면 타입별로 정의된 중복된 코드를 줄여줍니다. 

 

제너릭 함수의 구조를 살펴보겠습니다.

여기서 T는 '임의의 타입'을 의미합니다. T는 상징적인 이름으로 다른 이름을 지정할 수 있습니다. 

fun <T> 함수명(파라미터이름: T): 반환타입 {
    // 함수 본문
}

 

 

아래 코드를 보면서 제너릭 함수를 이해할 수 있습니다. 

// 제너릭 함수를 사용하기 전 
fun getFirstString(list: List<String>): String? {
    if (list.isNotEmpty()) return list[0]
    return null
}

fun getFirstInt(list: List<Int>): Int? {
    if (list.isNotEmpty()) return list[0]
    return null
}

// 제너릭 함수를 사용한 후 
// 리스트의 첫 번째 요소를 반환하는 제네릭 함수
fun <T> firstElement(list: List<T>): T? {
    if (list.isNotEmpty()) {
        return list[0]
    }
    return null
}

// 정수, 문자열 컬렉션에 대한 제너릭 함수를 적용 
val intList = listOf(1, 2, 3)
val firstInt = firstElement(intList)
println(firstInt)

val stringList = listOf("a", "b", "c")
val firstString = firstElement(stringList)
println(firstString)

 

 

코틀린 꼬리 재귀 함수 (Tail Recursive Functions)

코틀린에서 꼬리 재귀 함수는 컴파일러가 최적화에 개입하는 매우 특별한 재귀 함수입니다.

우리는 일반적으로 팩토리얼 연산(수학적으로는 n!)을 할때 재귀함수를 사용합니다. 

 

그런데, 팩토리얼 연산은 n=10 일 경우 3,628,800 이라는 큰수가 연산됩니다.

만약 20! 일 경우 2,432,902,008,176,640,000 이라는 큰수가 생성되어 정수 오버플로우가 발생할 수 있습니다. 

코틀린은 꼬리 재귀 함수를 통해 컴파일러 최적화를 통해서 오버플로우 문제 없이 재귀 함수를 안전하게 실행할 수 있게 합니다.

이러한 최적화를 꼬리 재귀 최적화(Tail Recursive Optimization)이라고 합니다. 

컴파일러가 최적화를 수행하게되면 재귀함수는 컴파일러에 의해 안전한 일반 반복문으로 변환됩니다

 

꼬리 재귀 함수를 사용하기 위해서는 `tailrec`이라는 키워드를 사용해서 명시적으로 함수를 정의해야합니다. 

또한, 함수의 마지막 동작이 자기 자신을 호출하는 재귀함수이어야 합니다. 

tailrec fun factorial(n: Int, accumulator: Int = 1): Int {
    if (n == 1) return accumulator
    return factorial(n - 1, n * accumulator)
}

 

만약, 함수의 마지막 동작이 자기 자신을 호출하지 않는다면 어떻게 될까요? 

컴파일러에서 에러는 발생하지 않고 경고 수준에서 알림을 제공합니다. 

"A function is marked as tail-recursive but no tail calls are found" 라는 경고를 발생시킵니다. 

즉, 꼬리 재귀 최적화의 효과를 누리지 못하게됩니다. 컴파일러 수준의 최적화를 기대하고 코드를 작성했지만 자그마한 실수로 인해 코드가 효율적으로 동작하지 못함을 의미합니다. 

 

// 꼬리 재귀 함수(tail recursive function)의 대표적인 예시입니다. 
tailrec fun factorial(n: Int, accumulator: Int = 1): Int {
    if (n == 1) return accumulator
    return factorial(n - 1, n * accumulator)
}


// 만약, 자기자신을 호출하지 않는다면 경고가 발생합니다. 
// Warning: A function is marked as tail-recursive but no tail calls are found
tailrec fun factorial2(n: Int, accumulator: Int = 1): Int {
    if (n == 1) return accumulator
    return factorial(n - 1, n * accumulator)
}

// 꼬리 재귀 함수의 동작
println(factorial(15)) // 15!
// 잘못 정의된 꼬리 재귀함수 또한 정상적으로 동작합니다. 
// 단, 일반 반복문으로 전환하는 최적화의 효과를 누리지는 못합니다. 
println(factorial2(15)) // 15!

 

이번 시간에 배운 개념들을 종합한 코드입니다.

수정하면서 배운 개념들을 연습해봅니다. 

 

 

 

 

이상입니다.

K-IN 올림. 

반응형