[kotlin fp] 함수형 타입 시스템

'코틀린으로 배우는 함수형 프로그래밍'을 읽고 정리하는 내용입니다.

이 장에서는 코틀린의 타입 시스템에 대해서가 아닌 함수형 언어는 어떤 타입 시스템을 기반으로 하는지 설명한다.

타입 시스템

정적 타입 시스템은 런타임에서 발생할 수 있는 오류를 컴파일 타임에 발생시키며 IDE는 정적인 정보들을 바탕으로 생산성을 높이는 다양한 기능을 제공한다.

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

함수형 언어에서는 객체뿐만 아니라 표현식(expression)도 타입을 가진다. 함수도 타입을 가진다.
다음 함수의 타입은 fun product(x: Int, y:Int): Int 이다.

fun product(x: Int, y: Int): Int {
  doDangerousIo()
  return x * y
}

대수적 데이터 타입

대수적 타입이란 다른 타입들을 모아서 형성되는 합성 타입을 의미하며 곱 타입(product type)과 합 타입(sum type)이 존재한다.
대수적 데이터 타입(algebraic data type)의 핵심은 기존의 타입들을 결합하여 새로운 타입을 정의하는 것이다.

곱 타입의 예와 한계

곱타입이란 하나의 자료구조에 여러 가지 타입을 한번에 정의하는 것을 의미하며 튜플이나 레코드를 예시로 들 수 있다. 두 개 이상의 타입을 AND로 결합한 형태이다.

class Circle(val name: String, val x: Float, val y: Float, val radius: Float)

Circle 클래스는 String 타입의 name과 Float 타입의 x, y, radius를 And로 결합하여 새로운 타입을 정의하였다.

Square라는 클래스와 Circle을 나타내는 단일 타입을 만들기 위해서는 어떻게 해야 할까.
클래스에서는 타입 간의 AND 결합만 가능하기 때문에 추상화 클래스를 만들어야 한다. 추상화 클래스를 추가해서 AND 결합을 하면 다음과 같다.

open class Shape(name: String, x: Float, y: Float)
class Circle(name: String, x: Float, y: Float, val radius: Float): Shape(name, x, y)
class Square(name: String, x: Float, y: Float, val radius: Float): Shape(name, x, y)

Circle과 Square가 Shape를 상속하는 계층 구조를 만들어서 두 클래스를 단일 타입으로 표현했다. 공통 프로퍼티와 메서드는 Shape 클래스에 구현하고, 각 하위 클래스에는 차이점만 구현하는 일반적인 상속 구조다.

여기에 Line까지 Shape라는 단일 타입으로 표현하기 위해서는 어떻게 해야 할까

open class Shape(val name: String)
class Circle(name: String, val x: Float, val y: Float, val radius: Float): Shape(name)
class Square(name: String, val x: Float, val y: Float, val length: Float): Shape(name)
class Line(name: String, val x1: Float, val y1: Float, val x2: Float, val y2: Float): Shape(name)

점점 계층 구조가 복잡해진다. 복잡한 계층 구조는 유지보수나 유연성에 악영향을 끼친다.

곱 타입의 또 다른 특징은 when 구문을 사용할 때 나타난다.

fun main(args: Array<String>) {
  println(getGirthLength(Circle("원", 1.0f, 1.0f, 1.0f)))
  println(getGirthLength(Square("정사각형", 1.0, 1.0, 1.0f)))
|

fun getGirthLength(shape: Shape): Double = when (shape) {
  is Circle -> 2 * Math.PI * shape.radius
  is Square -> 4 * shape.length.toDouble()
  is Line -> {
    val x2 = Math.pow(shape.x2 - shape.x1.toDouble(), 2.0)
    val y2 = Math.pow(shape.y2 - shape.y1.toDouble(), 2.0)
    Math.sqrt(x2 + y2)
  }
  else -> throw IllegalArgumentException()
}

여기서 when 구문을 사용할 떄는 else 구문을 반드시 작성해야 한다. 곱 타입은 타입을 구성하는 Circle, Square, Line의 합이 전체를 의미하지 않는다. 컴파일러는 Shape의 하위 클래스가 얼마나 있을지 예측할 수 없기 때문에 else 구문에서 다른 입력이 들어오는 경우를 대비해야 한다.

합 타입 사용한 OR 결합

합 타입은 곱 타입과 달리 두 개 이상의 타입을 OR로 결합한다. 코틀린은 sealed class를 사용해서 합타입을 만든다. sealed class를 사용해 다시 Shape를 정의해보자.

sealed class Shape
data class Circle(var 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()

여기서 Shape은 Circle 또는 Square 또는 Line이다. 이렇게 합 타입은 여러 가지 타입을 OR로 결합하여 새로운 단일 타입을 만들 수 있다.

코틀린의 열거형(enum) 타입은 모든 값의 매개변수 타입이 동일해야 하며 한가지 생성자만 가질 수 있는 제한적인 합 타입이다.

sealed class를 사용하면 앞서 작성했던 getGirthLength는 다음과 같이 재 작성된다.
합 타입에서는 부분의 합이 전체가 되기 때문에 else 구문을 작성할 필요가 없다.
컴파일러는 세 가지 타입 외에 다른 타입이 들어오지 않을 것이라는 예측을 할 수 있다.
합 타입은 패턴 매칭이 쉽고, 부수 효과(else 구문)을 처리하지 않아도 된다는 장점이 있다. 또한 합 타입은 복잡한 상속 구조를 피하면서 확장이 용이한 타입을 정의할 수 있다.

fun getGirthLength(shape: Shape): Double = when (shape) {
  is Circle -> 2 * Math.PI * shape.radius
  is Square -> 4 * shape.length.toDouble()
  is Line -> {
    val x2 = Math.pow(shape.x2 - shape.x1.toDouble(), 2.0)
    val y2 = Math.pow(shape.y2 - shape.y1.toDouble(), 2.0)
    Math.sqrt(x2 + y2)
  }
}

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

리스트는 리스트 내의 값의 타입은 모두 동일하지만 타입들을 결합하여 새로운 타입을 정의할 수 있기 때문에 대수적 타입이다.

sealed class FnList<out T> {
  object Nil: FunList<Nothing>()
  data class: Cons<out T>(val head: T, val tail: FunList<T>) : FunList<T>()
}

FunList의 타입이 sealed class를 통해 선언되어 있으므로 FunList는 합타입이다. Nil과 Cons의 OR 결합으로 작성되어 있고, 두 타입의 합은 FunList이다.

함수형 프로그래밍에서 대수적 데이터 타입은 FunList와 같이 합 타입으로 정의한다. 타입에 포함되는 모든 타입에 대한 정의가 명확해서 컴파일러가 타입을 예측하기 쉽기 떄문이다. 따라서 다음과 같인 이점이 있다.

  • 더 쉽게 타입을 결합하고 확장할 수 있음
  • 생성자 패턴 매칭을 활용해서 간결한 코드를 작성할 수 있음
  • 철저한 타입 체크로 더 안전한 코드를 작성할 수 있음

타입의 구성요소

타입은 표현식(expression)이 어떤 범주에 포함되는지 알려주는 라벨과 같다. 함수형 프로그래밍에서는 함수의 타입을 이해하거나 선언하는 것이 중요하다.
아래에서 타입을 이해하기 위한 용어와 타입을 만드는 방법을 알아본다.

타입 변수

fun <T> head(list: List<T>): T = list.first()

코틀린에서는 제너릭으로 선언된 T를 타입 변수(type variable)라 한다. 타입 변수를 가진 함수를 다형 함수(polymorphic function)라 한다.

다형 함수의 구체적 타입이 결정되는 것은 다음과 같이 head 함수를 사용할 때다.

head(listOf(1, 2, 3, 4))

여기서 함수의 타입은 타입 추론에 의해서 fun head(list: List<Int>): Int가 된다.

타입 변수는 새로운 타입ㅇ르 정의할 때도 사용된다.

class Box<T>(t: T) {
  val value = t
}

val box = Box(1)

Box(1)이 호출되는 순간 box의 타입은 Box<Int>로 결정된다. 컴파일러에 의해서 타입이 추론되기 때문에 명시적으로 타입을 작성하지 않아도 된다는 장점이 있지만 타입이 복잡해지면 코드를 통해서 타입을 유추하기 어렵다는 단점이 있다.
다음은 유의해야 할 타입 추론의 예시이다.

val listOfBox = listOf(Box(1), Box("String"))

리스트에는 Int와 String 타입을 모두 포함한다. 이때 컴파일러는 코틀린의 최상위 오브젝트인 List<Any>로 추론한다. 이를 프로그래머가 인지하지 못하면 의도하지 않은 동작의 함수를 호출하거나 원하는 함수를 찾지 못할 수 있다.

값 생성자

타입에서 값 생성자(value constructor)는 타입의 값을 반환하는 것이다. Box 예제에서는 class Box<T>(t: T)가 값 생성자이다. class나 sealed class에서 값 생성자는 그 자체로도 타입으로 사용될 수 있다. enum의 경우 값 생성자는 값으로만 사용되고 타입으로 사용될 수 없다.

sealed class Expr
data class Const(val number: Double): Expr()
data class Sum(val e1: Expr, val e2: Expr): Expr()
object NotANumber: Expr()

fun getSum(p1: Double, p2: Double): Sum {
  return Sum(Const(p1), Const(p2))
}

Sum은 Expr의 값 생성자이지만, getSum 함수의 타입 선언에 사용될 수 있다.

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

fun getRed(): Color {
  return Color.RED
}
// compile error 
fun getRed(): Color.RED {
  return Color.RED
}

enum의 경우 Color는 타입이지만 값 생성자인 Color.RED는 값으로만 사용될 수 있다.

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

값 생성자가 값 매개변수를 받아서 새로운 값을 생성한 것처럼, 타입 생성자(type constructor)는 새로운 타입을 생성하기 위해서 매개변수화된 타입을 받을 수 있다.

아래에서 Box는 타입 생성자고 T는 타입 매개변수이다.

class Box<T>(t: T)

다음을 보자

sealed class Maybe<T>
object Nothing: Maybe<kotlin.Nothing>()
data class Just<T>(val value: T): Maybe()

위에서 Maybe는 타입 생성자고 T는 타입 매개변수이다. Maybe는 타입이 아니라 타입 생성자이기 때문에 구체적 타입이 되려면 모든 매개변수가 채워져야 한다.

행위를 가진 타입 정의하기

코틀린과 같은 모던 언어에서는 행위를 가진 타입을 정의하는 방법을 제공한다. 객체지향 프로그래밍에서 행위를 가진 타입을 정의하는 방법에는 인터페이스, 추상 클래스, 트레이트(trait), 믹스인(mixin)등이 있다.

인터페이스

인터페이스는 클래스의 기능 명세다. 클래스의 행위를 메서드의 서명(signature)로 정의하고 구현부는 작성하지 않는다. 다중 상속이 가능하며 자체로서는 인스턴스화될 수 없고 인터페이스를 상속한 클래스에서 반드시 함수의 구현부를 작성해야 한다.

interface Developer {
  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 "findBus"
  }
}

믹스인

믹스인은 클래스들 간에 어떤 프로퍼티나 메서드를 결합하는 것이다. 메서드 재사용성이 높고 유연하고 다중 상속에서 발생하는 모호성(diamond problem)도 해결된다.

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

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

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

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

Frontend와 Backend의 language를 믹스인했다.

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

함수형 언어에서 타입 클래스는 타입의 행위를 선하는 방법을 의미한다.
코틀린에서는 인터페이스와 유사하며 아래에 대한 기능을 가진다.

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

코틀린의 타입 클래스 선언을 살펴보자

interface Eq<in T> {
  fun equal(x: T, y: T): Boolean
  fun notEqual(x: T, y: T): Boolean
}

Eq 타입 클래스는 두 값이 같은지 비교하는 함수 equal과 다른지 비교하는 함수 notEqual을 가지고 있다.

이제 행위의 구현부도 포함해보자.

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))
  println(Red.notEqual(Red, Yellow))
}

좋은 웹페이지 즐겨찾기