레코드 생성자

박준규 @curry@hackers.pub

이 글은 하스켈 생태계에서 레코드와 관련된 다양한 관용구를 정리한 짧은 문서입니다. 패키지를 처음 사용하는 사람들은 이 글을 통해 실제에서 접하게 되는 레코드 API 관용구를 더 잘 이해할 수 있습니다.

패키지 작성자를 위해, 글의 끝부분에는 제가 개인적으로 선호하는 관용구가 무엇인지 설명하는 간단한 권고도 포함했습니다.

예제

이 글에서 계속 사용할 예제로 다음 레코드 타입을 사용하겠습니다.

module Example where

data Person = Person{ name :: String , admin :: Bool }

패키지 작성자가 레코드 생성자를 내보내는 경우, Person 레코드를 생성하는 몇 가지 방법이 있습니다.

가장 간단한 방법은 확장 기능이 필요 없습니다. 모든 필드의 값을 하나의 표현식에서 초기화할 수 있습니다. 예를 들어 다음과 같습니다.

example :: Person
example = Person{ name = "John Doe", admin = True }

일부 레코드 리터럴은 꽤 커질 수 있기 때문에, 하스켈은 레코드 조립을 돕는 두 가지 확장 기능을 제공합니다.

먼저, NamedFieldPuns 확장 기능을 사용하여 다음과 같이 레코드를 작성할 수 있습니다.

{-# LANGUAGE NamedFieldPuns #-}

example :: Person
example = Person{ name, admin }
  where
    name = "John Doe"

    admin = True

이 방식이 가능한 이유는 NamedFieldPuns 확장이 Person { name, admin }Person { name = name, admin = admin }으로 변환해주기 때문입니다.

RecordWildCards 확장은 한 단계 더 나아가, 모든 필드를 다시 명시하지 않고도 레코드 리터럴을 초기화할 수 있게 해줍니다. 예를 들어 다음과 같습니다.

{-# LANGUAGE RecordWildCards #-}

example :: Person
example = Person{..}
  where
    name = "John Doe"

    admin = True

반대로, 레코드 리터럴을 여러 방법으로 구조 분해할 수도 있습니다. 예를 들어, 접근자 함수를 사용하여 레코드 필드에 접근할 수 있습니다.

render :: Person -> String
render person = name person ++ suffix
  where
    suffix = if admin person then " - Admin" else ""

... 또는 레코드 리터럴에 대해 패턴 매칭을 사용할 수도 있습니다.

render :: Person -> String
render Person{ name = name, admin = admin } = name ++ suffix
  where
    suffix = if admin then " - Admin" else ""

... 또는 NamedFieldPuns 확장을 사용하여(역방향으로도 작동) 다음과 같이 할 수 있습니다.

render :: Person -> String
render Person{ name, admin } = name ++ suffix
  where
    suffix = if admin then " - Admin" else ""

... 또는 RecordWildCards 확장을 사용하여(역방향으로도 작동) 다음과 같이 할 수 있습니다.

render :: Person -> String
render Person{..} = name ++ suffix
  where
    suffix = if admin then " - Admin" else ""

또한, RecordDotSyntax[1][2] 확장이 사용 가능해지면 일반 점 문법을 사용하여 레코드 필드에 접근할 수 있습니다.

render :: Person -> String
render person = person.name ++ suffix
  where
    suffix = if person.admin then " - Admin" else ""

불투명(opaque) 레코드 타입

일부 하스켈 패키지는 레코드 생성자를 내보내지 않기로 선택합니다. 이러한 경우, 대신 모든 필수 필드를 초기화하고 나머지 필드는 기본값으로 설정하는 함수를 제공합니다.

예를 들어, Person 타입에서 name 필드는 필수이고, admin 필드는 선택적이며 기본값이 False라고 가정해봅시다. 이 경우 API는 다음과 같이 생겼을 수 있습니다.

module Example (
      Person(name, admin)
    , makePerson
    ) where

data Person = Person{ name :: String, admin :: Bool }

makePerson :: String -> Person
makePerson name = Person{ name = name, admin = False }

주의할 점은, 이 모듈이 Person 타입과 모든 필드를 내보내지만 Person 생성자는 내보내지 않는다는 것입니다. 따라서 사용자가 Person 레코드를 생성할 수 있는 유일한 방법은 makePerson “스마트 생성자”를 사용하는 것입니다. 일반적인 관용구는 다음과 같습니다.

example :: Person
example = (makePerson "John Doe"){ admin = True }

다시 말해, 사용자는 필수 필드를 “스마트 생성자”를 통해 초기화하고, 나머지 선택적 필드는 레코드 문법을 사용하여 설정해야 합니다. 이는 생성자가 내보내지지 않았더라도, 내보낸 필드를 사용하여 레코드 타입을 업데이트할 수 있기 때문에 가능합니다.

wai 패키지는 이 관용구를 따르는 대표적인 패키지 중 하나입니다. 예를 들어, Request 레코드는 불투명하지만 접근자는 여전히 내보내지므로, defaultRequest를 생성한 뒤 레코드 문법을 사용하여 Request를 업데이트할 수 있습니다.

example :: Request
example = defaultRequest{ requestMethod = "GET", isSecure = True }

... 그리고 내보낸 접근자 함수를 사용하여 필드에 여전히 접근할 수 있습니다.

requestMethod example

이 방법은 레코드 조립 시 NamedFieldPuns와 함께 사용할 수도 있습니다.(구조 분해에는 적용되지 않음) 따라서 다음과 같이 작성할 수 있습니다.

example :: Request
example = defaultRequest{ requestMethod, isSecure }
  where
    requestMethod = "GET"

    isSecure = True

하지만, 이 방법은 RecordWildCards 언어 확장과는 함께 사용할 수 없습니다.

일부 다른 패키지는 한 걸음 더 나아가, 접근자를 내보내는 대신 접근자 필드에 대한 렌즈를 내보냅니다. 예를 들어, amazonka-* 계열 패키지가 이러한 방식을 사용하며, 이에 따른 레코드 생성 코드는 다음과 같이 작성됩니다.

example :: PutObject
example =
    putObject "my-example-bucket" "some-key" "some-body"
    & poContentLength .~ Just 9
    & poStorageClass  .~ ReducedRedundancy

... 그리고 필드에는 렌즈를 사용하여 접근할 수 있습니다.

view poContentLength example

제 권고

저는 패키지 작성자가 스마트 생성자 대신 레코드 생성자를 내보내는 것을 선호해야 한다고 생각합니다. 특히, 스마트 생성자 관용구는 레코드를 생성하기 위해 지나치게 많은 특화된 언어 지식을 요구하는데, 레코드 생성은 함수형 프로그래밍 언어에서 입문자가 수행해야 하는 기본적인 작업이어야 합니다.

패키지 작성자들은 일반적으로 스마트 생성자를 사용하면 새로 추가된 기본값 필드를 하위 호환 방식으로 제공할 수 있기 때문에 API 안정성을 높일 수 있다고 주장합니다. 하지만 저는 개인적으로 이러한 안정성을 크게 중요하게 여기지 않습니다.(패키지 작성자이자 사용자 모두로서) 그 이유는 하스켈이 정적 타입 언어이기 때문에, 타입 체커의 도움으로 이러한 변경을 역의존에서도 쉽게 처리할 수 있기 때문입니다.

저는 새로운 기여자들의 경험을 개선하는 데 더 큰 가치를 둡니다. 그래야 하스켈 프로젝트가 다양한 언어가 공존하는 조직 내에서도 더 쉽게 자리 잡을 수 있습니다. 다른 팀이 하스켈 코드에 자신 있게 기여할 수 있다고 느낄 때, 경영진은 조직 내에서 하스켈 프로젝트를 수용하는 데 덜 주저하게 됩니다.

미래 방향

장기적인 해결책으로, 언어가 기본값을 가지는 필드를 일급으로 지원한다면 두 가지 장점을 모두 누릴 수 있을 것입니다. 즉, 다음과 같이 레코드 타입을 정의할 수 있는 방법입니다.

data Person = Person{ name :: String , admin :: Bool = False }

... 그러면 레코드를 초기화할 때 기본값 필드는 안전하게 생략할 수 있게 됩니다. 물론, 이러한 변경이 가져올 영향에 대해서는 아직 충분히 검토하지 않았습니다.


  1. 역자주: RecordDotSyntax라는 이름의 확장은 없고 GHC 9.2.0부터 OverloadedRecordDot이라는 이름의 확장이 있다. ↩︎

  2. 역자주: OverloadedRecordDot 확장이 나오기 전에는 record-dot-preprocessor라는 프로그램을 설치하고 package.yaml에 다음과 같은 옵션을 넣어서 사용했다. ghc-options: - -F -pgmF=record-dot-preprocessor. 이 시절에는 이 기능을 RecordDotSyntax라고 불렀던 것 같다. ↩︎

5

1 comment

If you have a fediverse account, you can comment on this article from your own instance. Search https://hackers.pub/ap/articles/01997918-d421-7c4c-a2c9-bae5babbf478 on your instance and reply to it.

언어가 성장하면서, 점점 편의 기능이나 슈퍼 울트라 멋진 이펙티브 패턴들이 나오는 건 양날의 검 같아요. 이들이 많을 수록 입문자들은 더 제대로가 아닌 코딩을 할 확률이 올라가는 것 같습니다. 레코드도 마지막에 언급한 디폴트 기능을 언어 차원에서 지원하면, 모든 게 깔끔한데 말입니다. 물론 대의를 위해 지원하지 못하는 동작들을 위한 패턴들은 어쩔 수 없지만요. @curry박준규

0