본문 바로가기

Kotlin

함수형 언어의 타입시스템

  • 코틀린의 타입 시스템을 다루지 않는다.
  • 특정 언어의 타입 시스템보다는 함수형 프로그래밍에 초점을 맞춘 포괄적인 관점의 타입 시스템을 설명한다.
    • 타입 시스템의 종류와 특징
    • 함수형 프로그래밍에서는 어떤 타입 시스템을 기반으로 하는지
    • 대수적 타입의 개념과 종류
    • 함수형 프로그래밍에서 대수적 데이터 타입
    • 타입 변수, 값 생성자, 타입 생성, 타입 매개변수
    • 타입 클래스 : 행위를 가진 타입
    • 재귀적 자료구조와 장점

타입 시스템

타입 시스템의 종류와 특징

훌륭한 타입 시스템은 런타임에 발생할 수 있는 오류를 컴파일 타임에 발생시킨다. 또한 IDE를 비롯한 다양한 도구에게 프로그램에 대한 정보를 제공한다.

  • 동적 타입 시스템
  • 정적 타입 시스템

동적 타입 시스템

  • 런 타임에 데이터의 타입이 결정되는 시스템
    • 변수를 만들거나 값을 할당할 때마다 매번 타입을 작성하는 건 비효율적이다.
    • 타입을 강제한다고 해서 오류의 발생 자체를 차단할 수 있는 건 아니다.

정적 타입 시스템

  • 컴파일 타임에 데이터의 타입이 결정되는 시스템
    • 타입 추론기능을 잘 활용하면 타입을 명시하지 않고도 동적 타입 시스템의 장점을 얻을 수 있다.

정도의 차이

  • 같은 타입 시스템이라고 해도 타입 규칙을 얼마나 엄격하게 지켜야 하는지의 차이는 있다.
  • Weak Type
    • 자유도 high, 런타임 오류 가능성 high
  • Strong Type
    • 런타임 오류 가능성 low, 언어가 복잡해지고 컴파일이 어려워 진입 장벽이 높음
  • 최근 언어들은 견고한 타입 시스템을 추구하는 편이다.

함수형 언어의 정적 타입 시스템

함수형 언어에서는 객체뿐만 아니라 표현식(expression)도 타입을 가진다.

  • 함수도 타입을 가진다.
  • 타입에는 다른 언어에 비해서 더 많은 것을 기술해야 한다.
//kotlin
fun product(x: Int, y: Int): Int {
	doDangerousIO()
	return x * y
}
//haskell
product Num n => n -> n -> IO n // IO작업이 있음을 타입 선언에 명시
product x y = do
	doDangerousIO
	return (x*y)
  • 하스켈은 모든 것에 타입이 있고, 타입을 통해서 더 많은 정보를 얻을 수 있도록 설계되어 있다.
  • 이로써 컴파일 타임에 더 많은 오류를 잡고, 안전한 코드를 작성할 수 있다.
  • 강력한 타입 추론 기능을 제공한다.

대수적 데이터 타입 (Algebraic data type)

  • 다른 서브 타입들을 모아서 형성되는 합성 타입의 종류
  • 기존의 타입들을 결합하여 새로운 타입을 정의하는 것
    • 곱 타입(product type)
    • 합 타입(sum type)

Title

곱 타입의 예와 한계

  • 두 개 이상의 타입을 AND로 결합한 형태
class Circle(val name: String, val x: Float, val y: Float, val radius: Float)
  • Circle 클래스는 String 타입의 name과 Float타입의 x, y, radius를 AND로 결합하여 새로운 타입을 정의하였다.
  • when 을 사용할 때 else를 반드시 사용해야 한다.

합 타입 사용한 OR 결합

  • sealed class
  • enum
sealed class Shape
data class Circle(val name: String, val x: Float, val y: Float, val radius: Float): Shape()
data class Square(val name: String, val x: Float, val y: Float, val length: Float): Shape()
data class Line(val name: String, val x1: Float, val y1: Float, val x2: Float, val y2: Float): Shape()
  • 대수적 타입의 각 값(Circle, Square, Line)은 자신만의 생성자를 가지고, 생성자를 얻기 위해서는 패턴 매칭을 사용한다.
  • 함수형 프로그래밍에서는 패턴 매칭이 쉽고, 부수효과(else 구문)을 처리하지 않아도 된다.

함수형 프로그래밍에서의 대수적 데이터 타입

  • 대표적인 대수적 데이터 타입 ⇒ 리스트
  • 리스트 내의 값은 타입이 모두 동일하지만, 타입들을 결합하여 새로운 타입을 정의할 수 있기 때문에 대수적 타입이다.
  • 함수형 프로그래밍에서 대수적 데이터 타입은 FunList와 같이 합 타입으로 정의한다.
sealed class FunList<out T> {
    object Nil : FunList<Nothing>()
    data class Cons<out T>(val head: T, val tail: FunList<T>) : FunList<T>()
}

//else 가 필요없음
fun sum(list: FunList<Int>): Int = when (list) {
    Nil -> 0
    is Cons -> list.head + sum(list.tail)
}
public interface List<out E>; Collection<E> {
	override val size: Int
 // ...
}

fun sum(list: List<Int>): Int = when {
	list.isEmpty() -> 0
	else-> list.first() + sum(list.drop(1))
}
  • 두 리스트는 기능적으로 동일하지만 함수형 프로그래밍에서는 FunList와 같이 합 타입으로 정의한다. 타입에 포함되는 모든 타입에 대한 정의가 명확해서 컴파일러가 타입을 예측하기 쉽기 때문이다.
  • 이점
    • 더 쉽게 타입을 결합하고 확장할 수 있음
    • 생성자 패턴 매칭을 활용해서 간결한 코드를 작성할 수 있음
    • 철저한 타입 체크로 더 안전한 코드를 작성할 수 있음

타입의 구성요소

타입 변수

fun <T> head(list: List<T>): T = list.first()
  • 제네릭으로 선언된 T를 타입 변수(type variable)라 한다.
  • 타입 변수를 가진 함수들을 다형 함수(polymorphic function)라 한다.
  • 구체적 타입이 결정되는 것은 다음과 같이 head 함수를 사용할 때다.
head(listOf(1,2,3,4)) // List<Int>, T는 Int가 된다.
heaad(listOf("ab","cd")) //List<String>, T는 String이 된다.
  • 타입 변수는 새로운 타입을 정의할 때도 사용된다.
class Box<T>(t: T) {
	val value = t
}
  • 매개변수 t의 타입으로 Box의 타입이 결정된다.
val box = Box(1)
//box의 타입은 Box<Int>로 결정된다.

값 생성자

class Box<T>(t: T) // Box 값 생성자
  • 타입에서 값 생성자(value constructor)는 타입의 값을 반환하는 것이다.
  • class나 sealed class에서 값 생성자는 그 자체로도 타입으로 사용될 수 있다.
  • 그러나 enum의 경우 값 생성자는 값으로만 사용되고, 타입으로 사용될 수 없다.
sealed class Shape
data class Circle(val name: String, val x: Float, val y: Float, val radius: Float): Shape()
data class Square(val name: String, val x: Float, val y: Float, val length: Float): Shape()
data class Line(val name: String, val x1: Float, val y1: Float, val x2: Float, val y2: Float): Shape()

fun getCircle(): Circle {}
// Circle은 타입으로 사용될 수 있다.
enum class Color(val rgb: Int) {
	RED(0xFF0000),
	GREEN(0x00FF00),
	BLUE(0x0000FF)
}

// compile error
// red는 값으로만 사용될 수 있다. 타입 X
fun getRed(): Color.RED {
	return Color.RED
}

타입 생성자와 타입 매개변수

  • 타입 생성자(type constructor)는 새로운 타입을 생성하기 위해서 매개변수화된 타입을 받을 수 있다.
  • class Box<T>(t: T)에서 Box는 타입 생성자이고, T는 타입매개변수다
sealed class Maybe<T>
object Nothing: Maybe<kotlin.Nothing>()
data class Just<T>(val value: T): Maybe<T>()
  • 위의 코드에서 Maybe는 타입 생성자고, T는 타입 매개변수다.
  • Maybe는 타입이 아니라 타입 생성자이기 때문에, 구체적 타입이 되려면 모든 매개변수가 채워져야 한다.
  • Maybe<Int>, Maybe<String>, Maybe<Double> 등의 타입을 생성할 수 있다.
sealed class FunList<out T> {
    object Nil : FunList<Nothing>()
    data class Cons<out T>(val head: T, val tail: FunList<T>) : FunList<T>()
}
  • FunList 도 타입생성자고, T는 타입 매개변수이다.
  • FunList<Int>, FunList<String> 과 같이 타입을 생성할 수 있다.

타입 추론

val maybe1: Maybe<Int> = Just<Int>(5)
val maybe2 = Just(5)

val list1: FunList<Int> = Cons<Int>(1, Cons<Int>(2, Nil))
val list2 = Cons(1, Cons(2, Nil))
  • 컴파일러가 타입을 추론하기 때문에 maybe1, list1 처럼 타입 매개변수를 직접 명시하지 않아도 된다.
  • 타입 매개변수가 여러가지 이점을 가지고 있으나, 늘 사용할 수 있는 것은 아니다.
data class Person1(val name: String, val age: Int)
// name과 age는 타입 매개변수로 적합하지 않다.
data class Person2<T1, T2>(val name: T1, val age: T2)
  • 일반적으로 타입을 구성하는 값 생성자에 포함된 타입들이, 타입을 동작시키는데 중요하지 않은 경우 타입 매개변수를 사용한다.
  • Maybe나 FunList는 포함된 타입 T가 Maybe나 List의 동작에 영향을 미치지 않는다.

행위를 가진 타입 정의하기

인터페이스 vs. 트레이트 vs. 추상 클래스 vs. 믹스인

인터페이스는 클래스의 기능 명세다.

  • 클래스의 행위를 메서드의 서명(signiture)으로 정의하고, 구현부는 작성하지 않는다.
  • 다중 상속이 가능하며 자체로서는 인스턴스화될 수 없고, 인터페이스를 상속한 클래스에서 반드시 함수의 구현부를 작성해야 한다.
interface {
	val language: String
	fun writeCode()
}

트레이트는 인터페이스와 유사하지만, 구현부를 포함한 메서드를 정의할 수 있다.

  • 트레이트에 구현부까지 정의된 메서드는 트레이트를 상속한 클래스에서 구현부를 작성하지 않아도 된다.
interface Developer {
	val language: String
	fun writeCode()
	fun findBugs(): String {
		return "findBugs"
	}
}
  • 코틀린에서 인터페이스는 트레이트이다.

추상 클래스는 상속 관계에서의 추상적인 객체를 나타내기 위해서 사용되는 것이다

  • 인터페이스나 트레이트와는 사용 목적이 다르다.
  • 모든 종류의 프로퍼티와 생성자를 가질 수 있고, 다중 상속이 불가능하다.
abstract class Developer {
	abstract val language: String
	abstract fun writeCode()
	open fun findBugs(): String {
		return "findBugs"
	}
}

믹스인은 클래스들 간에 어떤 프로퍼티나 메서드를 결합하는 것이다.

interface Developer {
	val language: String
	fun writeCode() {
		println("write $language")
	}
}

interface Backend: Developer {
	fun operateEnvironment(): String {
		return "operateEnvironment"
	}
	override val language: String
		get() = "Haskell"
}

interface Frontend: Developer {
	fun drawUI(): String {
		return "drawUI"
	}
	override val language: String
		get() = "Elm"
}

interface FullStack: Frontend, Backend {
	override val language: String
		get() = super<Frontend>.language + super<Backend>.language
}

  • FullStack은 Frontend와 Backend를 다중 상속하고있다. 그리고 language 프로퍼티만 오버라이드해서 Frontend와 Backend의 language를 믹스인했다.

타입 클래스와 타입 클래스의 인스턴스 선언하기

  • 하스켈에서는 타입의 행위를 선언하는 방법을 타입 클래스라 한다.
  • 코틀린의 인터페이스와 유사

타입클래스

  • 행위에 대한 선언을 할 수 있다.
  • 필요시, 행위의 구현부도 포함할 수 있다.

타입 클래스는 행위를 선언한다는 관점에서 코틀린의 인터페이스와 유사하다. 하지만 타입 글래스는 코틀린의 인터페이스와 달리 타입의 선언 부분과 인스턴스로 정의하는 부분이 분리되어 있다. 코틀린의 인터페이스는 엄밀히 말하면 타입 클래스는 아니다. 그러나 지금부터는 편의상 타입 클래스처럼 사용한 인터페이스를 타입 클래스라고 지칭할 것이다.

// 두 값이 같은지 다른지 판단하는 타입 클래스
interface Eq<in T> {
	fun equal(x: T, y: T): Boolean
	fun notEqual(x: T, y: T): Boolean
}

// 타입 클래스 내에서 직접 구현
interface Eq<in T> {
	fun equal(x: T, y: T): Boolean = x == y
	fun notEqual(x: T, y: T): Boolean = x != y
}
  • Eq 타입 클래스의 행위를 가진 대수적 타입
sealed class TrafficLight: Eq<TrafficLight>
object Red: TrafficLight()
object Yellow: TrafficLight()
object Green: TrafficLight()

fun main(args: Array<String>) {
	println(Red.equal(Red, Yellow)) // "false" 출력
	println(Red.notEqual(Red, Yellow)) // "true" 출력
}
  • 타입 클래스가 함수 구현을 포함하고 있지 않다면 TrafficLight 타입이나 각 값 중 하나에서 구현되어야 한다.
interface Ord<in T>: Eq<T> {
	fun compare(t1: T, t2: T): Int
}
  • 예제에서 Ord 타입 클래스는 Eq 타입 클래스를 포함한다. 따라서 Ord 타입 클래스의 인스턴스인 타입은 Eq의 행위도 가진다.

재귀적 자료구조

sealed class FunList<out T>
object Nil: FunList<Nothing>()
data class Cons<out T>(val head: T, val tail: FunList<T>): FunList<T>()
  • tail의 타입을 보면 자기 자신을 나타내는 FunList인 것을 알 수 있다.
  • 이렇게 대수적 데이터 타입에서 구성하는 값 생성자의 필드에 자신을 포함하는 구조를 재귀적인 자료구조라고 한다.
fun <T> reverse(list: FunList<T>, acc: FunList<T>): FunList<T> = when (list) {
	Nil -> acc
	is Cons -> reverse(list.tail, acc.addHead(list.head))
}
// when 절 생성자 패턴매칭
  • reverse 함수에서 FunList를 구성하는 값 생성자에 의한 패턴 매칭을 이용했다.
  • 재귀적 자료구조가 아닌 코틀린 기본 리스트는 이러한 생성자 패턴 매칭을 사용할 수 없다.

실전 응용

대수적 합 타입의 장점

  • 곱타입
interface LanguageInterface
class Java: LanguageInterface
class Kotlin: LanguageInterface
class Scala: LanguageInterface

fun caseLanguageInterface(language: LanguageInterface) = when (language) {
	is Java -> {
		// doSomething
	}
	is Kotlin -> {
		// doSomething
	}
	is Scala -> {
		// doSomething
	}
	else -> {
		throw IllegalArgumentException("invalid type : $language")
	}
}
  • 합타입
enum class Lanuage {
	JAVA, KOTLIN, SCALA
}

fun caseLanguageEnum(language: Language) = when (language) {
	Language.JAVA ->{
		// doSomething
	}
	Language.KOTLIN ->{
		// doSomething
	}
	Language.SCALA ->{
		// doSomething
	}
}
  • enum 타입은 합 타입이기 때문에 else 구문을 처리할 필요가 없다.

만약 합 타입과 곱 타입에 새로운 값을 추가해야 한다면 어떤 일이 발생할까?

interface LanguageInterface
class Java: LanguageInterface
class Kotlin: LanguageInterface
class Scala: LanguageInterface
class Haskell: LanguageInterface
  • LanguageInterface를 상속하는 또 다른 값 Haskell을 추가한 후 caseLanguageInterface를 변경하지 않아도 정상적으로 컴파일된다.
  • 하지만 만약 런타임에 Haskell이 입력으로 들어온다면 예외가 발생한다.
  • 동일한 상황에서 합 타입인 Language의 경우는 when 구문에서 컴파일 오류가 발생한다.
enum class Lanuage {
	JAVA, KOTLIN, SCALA, HASKELL
}

장점

  • 추가적인 else 구문을 작성하지 않아도 되고, 호출자에서도 예외에 대한 처리를 할 필요가 없다. 즉, 부수효과가 없고, 참조 투명한 함수를 설계할 수 있다.
  • 타입의 값이 변경될 때 해당 값에 대한 처리가 되어 있지 않은 비즈니스 로직에 컴파일 오류를 발생시킨다.

'Kotlin' 카테고리의 다른 글

자바 개발자를 위한 코틀린 입문  (0) 2022.08.06