F# 값의 동등성(Equality)

F# 값의 동등성(Equality)

F#에서는 값의 동등성(equality)과 비교(comparison)가 언어 차원에서 제대로 다뤄져요. 튜플, 리스트, 옵션, 배열, 그리고 직접 만든 레코드·판별 공용체 같은 구조적 타입은 그 구조를 기준으로 '내용이 같은지'를 판단합니다. 이 글에서는 F#의 동등성·비교 연산이 어떻게 동작하는지, 타입 파라미터에 붙는 equality/comparison 제약이 무엇을 의미하는지, 그리고 직접 커스텀 동등성을 정의하는 방법까지 차근차근 짚어 볼게요.

출처

원문: Equality and Comparison Constraints in F# (Microsoft Learn, F# 언어 설계자 Don Syme 블로그)

참고: 원래 대상이었던 https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/values/equality 페이지는 현재 404로 사라져서, 동일 주제를 다루는 최신 공식 아카이브 글(F# 1.9.7 기준, 동등성·비교 제약)을 원문으로 사용했어요.

본문

튜플, 리스트, 그리고 구조적 타입

F#에서 함수형 프로그래밍을 하다 보면 구조적 동등성(structural equality), 해싱(hashing), 비교(comparison)를 아주 자주 만나요. 예를 들어:

(1,1+1) = (1,2)

이 식은 true를 돌려주는데, 튜플 타입이 '구조적' 동등성을 지원하기 때문이에요. 마찬가지로 아래 두 함수 호출도 같은 값을 돌려줍니다.

hash (1,1+1)
hash (1,2)

튜플의 각 성분에 순서가 정의되어 있으면 튜플 전체에도 순서가 생겨요. 그래서 아래 식들은 전부 true로 평가돼요.

(1,2) < (1,3)
(1,2) < (2,3)
(1,2) < (2,1)
(1,2) > (1,0)

이런 규칙은 리스트, 옵션, 배열, 그리고 구성 요소의 필드 타입이 구조적 동등성·해싱·비교를 허용하는 한, 사용자 정의 레코드·공용체·구조체에도 동일하게 적용돼요.

동등성과 비교는 '함수형 데이터로 프로그래밍하기'에서 놀라울 만큼 중요한 부분이에요. 튜플끼리, 리스트끼리, 다른 구조적 타입끼리 동등성을 비교할 수 없다면 F#이 F#이 아니라고 느껴질 정도죠. 그리고 이 모든 연산의 동작은 결국 구성 요소들의 동등성 속성에 달려 있어요.

이는 동등성·해싱·비교의 '개념'(구조적 버전까지 포함해서)이 F# 언어와 핵심 라이브러리에 깊이 내장되어 있음을 뜻해요. 실제로 F# 명세에는 사용자 정의 구조적 타입에 동등성을 적용할 때 무슨 일이 일어나는지 정의하는 데만 여러 페이지를 할애하고 있어요.

F#의 기본 동등성·비교 연산

F# 라이브러리에 있는 동등성·비교·해싱 연산의 시그니처를 먼저 볼게요.

(=) : 'T -> 'T -> bool when 'T : equality
(<>) : 'T -> 'T -> bool when 'T : equality
hash : 'T -> int when 'T : equality
(<) : 'T -> 'T -> bool when 'T : comparison
(<=) : 'T -> 'T -> bool when 'T : comparison
(>) : 'T -> 'T -> bool when 'T : comparison
(>=) : 'T -> 'T -> bool when 'T : comparison
compare : 'T -> 'T -> int when 'T : comparison
min : 'T -> 'T -> 'T when 'T : comparison
max : 'T -> 'T -> 'T when 'T : comparison

먼저 주목할 점은 이들이 제네릭(일반화) 연산이라는 거예요. 시그니처의 'T가 이를 보여주는데, 서로 다른 여러 타입의 객체에 쓸 수 있어요. 이 연산들은 같은 타입의 파라미터를 하나 또는 두 개 받아요. 예를 들어 = 연산자를 두 개의 Form 객체나 System.DateTime 객체, 또는 System.Type 객체에 적용해도 그럴듯한 결과가 나옵니다. F# 라이브러리의 불변(영속) Set<_>Map<_,_> 같은 파생 제네릭 타입들도 키 타입에 제네릭 비교를 사용해요.

type Set<'T when 'T : comparison> = ...
type Map<'Key, 'Value when 'Key : comparison> = ...

위 연산들은 F# 라이브러리의 다른 기본 제네릭 연산들과 마찬가지로 **제약(constrained)**을 받아요. 여기서는 'T : equality'T : comparison 제약이죠. 타입 파라미터에 제약을 두는 이유는 연산을 '말이 되는 타입'에만 쓰도록 보장하기 위해서예요. 예를 들어 System.Windows.Forms.Form 객체에 동등성과 비교를 적용해 볼게요. 동등성은 허용되는데, 거의 모든 .NET 객체 타입의 기본이 참조 동등성이기 때문이에요.

let form1 = new System.Windows.Forms.Form()
let form2 = new System.Windows.Forms.Form()
form1 = form1 // true
form1 = form2 // false

하지만 비교는 허용되지 않아요.

let form1 = new System.Windows.Forms.Form()
let form2 = new System.Windows.Forms.Form()
form1 <= form2
// Error: The type 'System.Windows.Forms.Form' does not support the 'comparison' constraint.
// For example, it does not support the 'System.IComparable' interface

이건 오히려 반가운 일이에요. 이런 객체에는 자연스러운 순서가 없으니까요. 파생 타입에서 IComparable을 구현하면 순서를 직접 정의할 수는 있지만, .NET 라이브러리가 그런 순서를 제공하지는 않아요.

핵심 라이브러리의 다른 연산과 달리, 동등성과 비교는 타입의 구조에 조건부로 걸려요. 즉 튜플의 구성 요소들이 각각 동등성과 비교를 지원할 때에만 튜플에 위 연산들을 쓸 수 있어요. 예를 들어 폼 튜플에 동등성을 적용하는 것은 허용돼요.

let form1 = new System.Windows.Forms.Form()
let form2 = new System.Windows.Forms.Form()
(form1, form2) = (form1, form2) // true
(form1, form2) = (form2, form1) // false

하지만 폼이 포함된 튜플을 정렬하려 하면 안 돼요.

(form1, "Data for Form1") <= (form2, " Data for Form2")
// Error: The type 'System.Windows.Forms.Form' does not support the 'comparison' constraint.

이것도 역시 좋은 동작이에요. 이런 순서는 코드의 버그가 될 테니까요.

.NET 라이브러리의 많은 타입은 커스텀 동등성·비교 구현을 갖고 있어요. 예를 들어 System.DateTime은 동등성과 비교를 모두 커스텀으로 구현해요. F#에서는 새 타입 정의에도 커스텀 비교·동등성을 직접 정의할 수 있는데, 이건 뒤에서 자세히 볼게요.

equality와 comparison 제약은 무엇을 의미하나?

지금까지 본 내용을 정리하면 이래요.

  • 어떤 타입은 동등성이 구조적이에요. 리스트, 옵션, 튜플, 사용자 정의 구조적 타입이 그 예죠.
  • 어떤 타입은 동등성이 참조 기반이에요. 이런 타입은 비교를 지원하지 않는 경향이 있고, System.Windows.Forms.Form이 그 예예요.
  • 어떤 타입은 동등성이 커스텀이에요. System.DateTime 같은 경우죠.
  • 어떤 타입은 동등성/비교에 대한 말이 되는 개념 자체가 없어요. 이런 타입은 '동등성 없음(no equality)', '비교 없음(no comparison)'으로 표시된다고 생각하면 돼요. 이 경우는 코드의 버그를 늦게가 아니라 더 일찍 찾아내는 데만 의미가 있어요.

이 점들이 조합되어 F# 언어에 동등성·비교 제약을 일급의 새 기본(primary) 제약으로 넣기로 결정했어요. 이제 F#에서 equality/comparison 제약이 언제 만족되는지 자세히 볼게요. 규칙의 핵심은 아주 단순해요.

제약 type : equality는 다음 조건이 성립하면 충족돼요.

  • type에 대한 타입 정의에 NoEquality 특성이 없고,
  • 타입의 모든 'equality 의존성(동등성 의존 요소)'도 type : equality를 만족할 때

제약 type : comparison은 다음 조건이 성립하면 충족돼요.

  • 이름 있는 타입이라면 그 타입 정의에 NoComparison 특성이 없고,
  • 타입 정의가 System.IComparable을 구현하며,
  • 타입의 모든 'comparison 의존성'도 type : comparison을 만족할 때

이 규칙들 덕에 equality 제약은 상대적으로 약한 제약이에요. 거의 모든 CLI 타입이 이 제약을 만족하거든요. 참조 동등성은 C# 같은 다른 CLI 언어에서 흔하기 때문에 CLI 라이브러리에도 그대로 드러나요. 참고로 equality 제약을 만족하면 그 객체에 F# hash 함수를 쓸 수 있다는 뜻도 돼요.

반면 comparison 제약은 더 강한 제약이에요. 보통 타입이 System.IComparable을 구현해야 한다는 뜻이니까요.

아까 '의존성(dependencies)' 개념을 이야기했는데, 예를 들어 (int * string)intstring이 모두 비교 가능하므로 비교 가능해요. 여기서 intstring이 튜플 타입의 의존성인 거예요. 타입에 새 의존성을 선언하는 방법은 아래에서 볼게요.

새 구조적 타입 정의하기

F#에서 구조적 타입을 정의하는 것은 아주 쉽습니다.

type MyBox<'T> = MyBox of 'T

이 경우 타입에 구조적 동등성·비교 구현이 자동으로 부여되고, F#이 'T에 대한 동등성·비교 의존성이 있다고 추론해요. 끝!

때로는 구조적 타입이 정말로 구조적 동등성을 반드시 지원해야 한다고 명시하고 싶을 때가 있어요. 그럴 땐 타입에 StructuralEquality 또는 StructuralComparison 특성을 붙이면, 타입 정의 시점에 그 조건을 만족하지 못하면 오류가 나요.

[<StructuralEquality;StructuralComparison>]
type MyIntBox = MyIntBox of int

이건 단지 검사를 추가해 주는 거예요. 아래 예시는 요소 타입 중 하나가 구조적 비교를 지원하지 않아서, 타입이 논리적으로 자동 구조적 비교를 지원할 수 없기 때문에 컴파일 시점에 오류가 나요.

[<StructuralEquality;StructuralComparison>]
type MyData = MyBox of int * string * string * System.Windows.Forms.Form

또한 F# 함수 타입은 동등성을 지원하지 않는다는 점을 자주 마주치게 돼요.

[<StructuralEquality;StructuralComparison>]
type MyData = MyBox of int * string * string * (int -> int)

이것도 오류가 나는데, 함수 값은 '동등성 없음(no equality)'으로 간주되기 때문이에요.

반대로 구조적 타입이 참조 동등성을 쓰도록 선언할 수도 있어요.

[<ReferenceEquality>]
type MyFormWrapper = MyFormWrapper of System.Windows.Forms.Form * (int -> int)

정리하면 다음 특성들이 타입의 비교·동등성 시맨틱스를 제어해요.

  • StructuralEquality, StructuralComparison — 구조적 타입이 반드시 동등성·비교를 지원해야 한다는 뜻
  • ReferenceEquality — 구조적 타입이 참조 동등성만 지원한다는 뜻
  • NoComparison, NoEquality — 타입이 동등성·비교를 전혀 지원하지 않는다는 뜻
  • CustomEquality, CustomComparison — 구조적 타입이 커스텀 동등성·비교를 지원한다는 뜻

참고로 '참조 비교(reference comparison)'라는 개념은 없어요(.NET에서 쓰는 객체 포인터는 움직이기 때문에 순서가 계속 바뀌니까요). 참조 기반 정렬이 필요하면 고유한 태그(tag)를 쓰고 커스텀 비교를 구현하면 돼요.

동등성·비교 커스터마이즈하기

어떤 타입은 직접 자신만의 비교·동등성·해싱 시맨틱스를 정의해야 하는 경우가 자주 있어요. 예를 들어 타입의 값마다 고유한 정수 태그가 붙어 있고, 그 태그를 비교에 쓸 수 있는 경우가 그렇죠.

이럴 때 권장하는 방법은 비교·동등성 연산을 타입에 직접 정의해 완전히 제어하는 거예요. 예를 들어 '도장(stamp)' 정수 값 기준으로 비교하는 코드는 이렇게 돼요.

/// A type abbreviation indicating we're using integers for unique stamps on objects
type stamp = int
/// A type containing a function that can't be compared for equality
[<CustomEquality; CustomComparison>]
type MyThing =
  { Stamp: stamp;
    Behaviour: (int -> int) }
  override x.Equals(yobj) =
    match yobj with
    | :? MyThing as y -> (x.Stamp = y.Stamp)
    | _ -> false
  override x.GetHashCode() = hash x.Stamp
  interface System.IComparable with
    member x.CompareTo yobj =
      match yobj with
      | :? MyThing as y -> compare x.Stamp y.Stamp
      | _ -> invalidArg "yobj" "cannot compare values of different types"

F# 헬퍼 모음에 넣어 두면 유용한 도우미 함수들도 있어요.

let equalsOn f x (yobj:obj) =
  match yobj with
  | :? 'T as y -> (f x = f y)
  | _ -> false
let hashOn f x = hash (f x)
let compareOn f x (yobj: obj) =
  match yobj with
  | :? 'T as y -> compare (f x) (f y)
  | _ -> invalidArg "yobj" "cannot compare values of different types"

이 도우미들을 쓰면 커스텀 구현이 훨씬 일관돼요. 예를 들어 공용체 타입의 커스텀 구현은 이렇게 만들 수 있어요.

type stamp = int
[<CustomEquality; CustomComparison>]
type MyUnionType =
  | MyUnionType of stamp * (int -> int)
  static member Stamp (MyUnionType (s,_)) = s
  override x.Equals y = equalsOn MyUnionType.Stamp x y
  override x.GetHashCode() = hashOn MyUnionType.Stamp x
  interface System.IComparable with
    member x.CompareTo y = compareOn MyUnionType.Stamp x y

다른 타입에도 커스텀 동등성·비교 구현을 정의할 수 있어요.

참고: F# 1.9.7의 오류 메시지에 "A type with CustomEquality must override Equals or implement IEquatable or IStructuralEquatable"라고 나오는 건 버그예요. 실제로는 CustomEquality 타입은 반드시 Equals를 오버라이드해야 해요.

더 안전한 코드: 타입의 동등성·비교 끄기

F# 타입 정의에 [<NoEquality>] 특성을 붙이면 그 타입의 동등성을 끌 수 있어요. 그냥 그 타입이 equality 제약을 만족하는 것으로 간주되지 않게 되는 거예요. 마찬가지로 [<NoComparison>] 특성을 붙이면 comparison 제약을 만족하지 않게 됩니다.

[<NoEquality; NoComparison>]
type MyProjections =
  | MyProjections of (int * string) * (string -> int)

라이브러리 타입에 이런 특성을 붙여 두면 클라이언트 코드가 더 안전해져요. 동등성·비교가 말이 안 되는 타입에 실수로 의존하는 일이 줄어들기 때문이에요.

더 안전한 코드: 더 안전한 Set과 Map

이전의 모든 F# 버전은 equality·comparison 제약을 검사하지 않았어요. 항상 이 문제를 해결하려고 계획은 하고 있었지만, 세부 사항이 확정된 건 1.9.7에서였죠.

이 제약을 검사하게 된 주된 동기는 F#의 SetMap 타입(그리고 앞으로 나올 F# 불변 컬렉션)이 오용되지 않도록 하기 위해서예요. 예를 들어 다음 코드를 봐요.

let x1 = set [ (fun x -> x + 1); id ; (fun x -> x + 2) ] // static error
let x2 = set [ obj(); obj(); obj() ] // static error
let x3 = set [ typeof<int>; typeof<string> ] // static error

초기 F# 버전에서는 이 코드들이 런타임 오류를 냈어요. 또 함수의 동등성을 비교하려는 시도를 생각해 볼게요.

id = id // static error

초기 F#에서는 이 식이 false를 돌려줬어요. 등호 양쪽의 id에 서로 다른 클로저가 할당됐기 때문이죠. 이는 F# 명세상 함수 값에 대한 동등성 비교 결과가 정의되어 있지 않다고 되어 있어서 타당한 동작이었어요. 이제는 정적 오류(static error)가 나요. 이런 사례들만 봐도 이 문제를 다뤄야 하는 중요성이 드러나요.

더 안전한 코드: 컨테이너 타입의 동등성 조건 선언하기

프로그래머들은 새 제네릭 컨테이너 타입을 정의하는 걸 좋아해요. .NET·F# 프로그래밍에서는 다른 언어들보다 덜 하지만, 여전히 중요하답니다!

여기서 동등성·비교가 역할을 해요. 예를 들어 값의 일부를 해싱으로 인덱싱하거나, 검색할 때 동등성으로 비교하거나, 순서로 비교할 수 있는 컬렉션이 흔하죠. 라이브러리 타입의 시그니처에 이런 제약이 보이는 건 전혀 놀랍지 않아요.

type Graph<'Node when 'Node : equality>() = ...

실제로 여기서 제약이 있다는 건 위안이 되는 측면이 있어요. 노드 타입에 대한 요구사항이 더 명확해지니까요.

때로는 '컨테이너 전체'를 비교할 수 있으면 좋을 때도 있어요. 즉 한 Set를 다른 Set와, 한 Map을 다른 Map과, 한 Graph를 다른 Graph와 비교하는 거죠. 이건 튜플이나 리스트를 비교하는 것과 아주 비슷해요. 일차적으로는 컨테이너의 동등성/비교를 커스터마이즈해서 구현해요.

type Graph<'Node when 'Node : equality>() =
  override x.Equals(yobj) = ...
  override x.GetHashCode() = ...

이 경우 노드 타입은 이미 동등성·해싱을 지원하므로(컬렉션 내부에서 인덱싱할 때 쓰니까) Equals의 구현은 앞서 '동등성·비교 커스터마이즈하기'에서 본 패턴을 따라 하면 충분히 간단해요.

그런데 때로는 제네릭 컨테이너 값 자체에 대한 동등성(과 비교)을 컨테이너 타입의 타입 파라미터에 의존한다고 선언해야 할 때가 있어요. 마치 F# 리스트·튜플 타입처럼요. 이건 제네릭 타입의 타입 파라미터에 EqualityConditionalOn, ComparisonConditionalOn 특성을 추가해서 처리해요.

예시로 정말 단순한 녀석을 쓸게요. 컨테이너에 대한 동등성·비교도 지원하는 단일 셀 컨테이너예요. F#에서는 이렇게 쉽게 정의할 수 있어요.

type MyBox<'T> = MyBox of 'T

이 경우 이것은 구조적 타입 정의라서, F#이 'T에 동등성·비교 의존성이 있다고 추론해요. 끝! MyBox<_>는 어떤 타입의 값이든 쓸 수 있고, MyBox 값에 대한 동등성·비교 연산은 요소 타입도 동등성·비교를 지원할 때에만 가능해요. 완벽하죠!

하지만 어떤 이유로 MyBox가 클래스 타입으로 구현되거나 타입에 대한 커스텀 비교·동등성 로직이 필요하다면 어떻게 될까요? 그럴 때는 타입 파라미터에 EqualityConditionalOnComparisonConditionalOn 특성을 붙여 더 명시적으로 선언해야 해요.

type MyBox<[<EqualityConditionalOn; ComparisonConditionalOn >]'T> = ...

이 특성들이 있으면 MyBox<A>A가 해당 제약을 만족할 때에만 equality·comparison 제약을 만족해요. 하지만 MyBox<_> 자체는 여전히 어떤 타입이든 쓸 수 있어요.

이제 Equals, GetHashCode, System.IComparable의 커스텀 구현을 볼게요. 컨테이너 자체에 대한 조건부 동등성·비교도 지원하는 컨테이너 타입의 전체 예제예요.

type MyBox<[<EqualityConditionalOn; ComparisonConditionalOn >]'T>(value : 'T) =
  member x.Value = value
  override x.Equals(yobj) =
    match yobj with
    | :? MyBox<'T> as y -> Unchecked.equals x.Value y.Value
    | _ -> false
  override x.GetHashCode() = Unchecked.hash x.Value
  interface System.IComparable with
    member x.CompareTo yobj =
      match yobj with
      | :? MyBox<'T> as y -> Unchecked.compare x.Value y.Value
      | _ -> invalidArg "yobj" "cannot compare values of different types"

여기서 인터페이스·오버라이드 구현이 '확인되지 않은(unchecked) 동등성·비교'를 쓰는 걸 볼 수 있어요. 이건 아래에서 다룰 거예요. 여기서 unchecked 동등성이 필요한 이유는 두 가지 사실이 맞물리기 때문이에요.

  • (a) F#은 .NET 오버라이드·인터페이스 구현 차원에서 커스텀 동등성을 지정하도록 요구하고,
  • (b) .NET은 조건부 인터페이스·오버라이드 구현을 지원하지 않아요(아래 논의 참고).

(a)에는 큰 장점이 있어요. F#을 단순하게 만들고, 객체를 C#에 넘겼을 때 어떤 동작을 보일지 F# 매뉴얼을 찾아볼 필요가 없게 해주죠.

다행히 unchecked 동등성 사용은 국소적이고 한정되어 있으며, 실수로 틀리기도 놀라울 만큼 어려워요. 어쨌든 타입이 동등성·비교를 제대로 지원하는지 단위 테스트로 확인하길 권해요.

F# 제네릭 동등성을 쓰는 IEqualityComparer<'T>/IComparer<'T> 구하기

.NET 라이브러리를 쓰다 보면 IEqualityComparer<'T>IComparer<'T>의 구현이 필요할 때가 아주 흔해요. F# 동등성·비교와 일관되게 이 인터페이스들을 구현하는 객체를 얻는 데 아주 유용한 F# 핵심 라이브러리 함수 두 개가 있어요.

HashIdentity.Structural<'T> : IEqualityComparer<'T> when 'T : equality
ComparisonIdentity.Structural<'T> : IComparer<'T> when 'T : comparable

이 둘은 .NET 해시 테이블·비교 연산을 초기화할 때 가장 유용해요. 예를 들어:

open System.Collections.Generic
let hashTable = new Dictionary<string,int>(HashIdentity.Structural<string>)
let hashSet = new HashSet<string>(HashIdentity.Structural<string>)

여기서 타입 주석 일부를 생략하고 타입 추론에 맡겨도 돼요.

open System.Collections.Generic
let hashTable = Dictionary(HashIdentity.Structural)
let hashSet = HashSet(HashIdentity.Structural)
hashTable.Add("three", 3)
hashSet.Add("three")

명시적으로 HashIdentity.*ComparisonIdentity.* 인자를 주는 큰 장점은 코드가 더 엄격하게 검사된다는 거예요. F# 컴파일러가 추론된 키 타입이 실제로 F# 동등성을 허용하는지 확인하니까요. 예를 들어 F# 함수 타입은 '동등성 없음'으로 간주돼요(즉 F# 함수 값에 해싱을 쓰는 건 좋은 생각이 아니에요).

open System.Collections.Generic
let concatOne (s:string) = s + "1"
let hashTable = new Dictionary<_,_>(HashIdentity.Structural)
hashTable.Add(concatOne, 10) // error: the type "string -> string" doesn't support equality

여기서 의도한 코드는 대략 이랬을 거예요.

hashTable.Add(concatOne "ten", 10) // ok: the type "string" supports equality

확인되지 않은(unchecked) 동등성·비교 사용하기

앞서 봤듯이 값에 'unchecked' 동등성·비교를 써야 하는 경우가 가끔 있어요. 이것은 정적 검사를 전혀 하지 않아요. 초기 F# 버전처럼요.

Unchecked.equals: 'T -> 'T -> bool
Unchecked.hash: 'T -> int
Unchecked.compare: 'T -> 'T -> int

이것들은 제약이 없는 연산으로, 제약이 있는 연산과 똑같이 동작해요. 예를 들어 기본 정수 타입에 쓰면 F# 컴파일러가 최적화해 주기도 해요. Unchecked.equalsUnchecked.hash는 시맨틱상 타입 obj로 변환한 뒤 그 정적 타입에 동등성을 적용하는 것과 비슷해요.

설계 대안: 인터페이스 제약만으로 충분할까? 타입 클래스는?

C#에 익숙한 독자라면 "F#에 정말 새 종류의 제약이 필요한가? 인터페이스 제약을 쓰면 안 되나?"라고 물을 거예요. 예를 들어 비교 시그니처를 이렇게 쓸 수 있지 않을까요?

compare : 'T -> 'T -> bool when 'T : System.IComparable

아니면 이렇게?

compare : 'T -> 'T -> bool when 'T : System.IComparable<'T>

두 방식 모두 너무 허용적이라는 게 문제예요. 예를 들어 F# 리스트가 IComparable이나 IComparable<'T>을 구현한다면, 위 시그니처들은 요소 타입이 비교를 지원하는지와 무관하게 어떤 두 F# 리스트든 비교할 수 있게 해 버려요.

여기 깔려 있는(그리고 잘 알려진) 문제는 .NET의 인터페이스 구현이 **무조건적(unconditional)**이라는 거예요. 어떤 타입은 인터페이스를 지원하거나 지원하지 않거나 둘 중 하나죠. 이건 .NET 제네릭의 근본적인 한계예요. F# 설계 방법론에서는 이런 한계를 우회하기 위해 추가적인 소거형(erased) 제약을 더해요.

마찬가지로 F# 리스트 타입에 파라미터 제약을 쓰는 것도 불가능해요. 예를 들어 이런 선택지를 생각해 볼게요.

type FSharpList<'T when 'T : IComparable<'T>>() = ... // this leads the type to be over-constrained.

이 시그니처를 쓰면 리스트가 훨씬 못 쓰게 돼요. 비교를 지원하지 않는 객체의 리스트를 만들 수 없게 되니까요!

결과적으로 .NET 인터페이스 제약만으로는 F#에서 equality·comparison 제약을 다루기에 충분하지 않아요. 그래서 F# 언어에 equality·comparison 제약을 새롭고 소거(erased)되는 제약으로 넣기로 한 거예요.

Haskell이나 다른 함수형 언어에 익숙한 독자는 동등성·비교 관련 이슈를 잘 알고 있을 거예요. 그 문제들은 어떤 정적 타입 언어에서든 동등성·비교에 근본적인 것이지만, 특히 함수형 언어에서 그러하니까요. F#에서 이 문제를 해결하는 메커니즘은 약한 형태의 타입 클래스(type classes)를 연상시켜요. 특히 동등성·비교가 타입의 구조에 의존한다는 점과, 어떤 타입에 대해서도 그 의존성을 선언할 수 있다는 점이 그렇죠. Haskell은 사용자가 매우 풍부한(때로는 꽤 복잡한!) 방식으로 자신의 제약을 정의할 수도 있어요.

한편 equality와 comparison은 F# 핵심 라이브러리에서 타입의 구조에 대해 정적으로 조건부인 유일한 연산이에요. 그렇다면 F#이 인터페이스 제약 너머의 사용자 정의 제약을 허용하지 않는 이유, 그리고 다른 제약이 조건부로 선언될 수 없는 이유가 궁금해져요. 무엇보다 프린팅이나 직렬화처럼 다른 연산도 구조에 조건부로 만드는 게 유용할 수 있다는 건 잘 알려져 있으니까요.

답은 두 가지로 나뉘어요. 첫째, 향후 F# 버전에서는 equality·comparison 제약을 구현하는 데 쓴 메커니즘을 사용자 정의 제약까지 확장하는 방안을 검토할 수 있어요.

둘째, 하지만 포맷팅·직렬화 같은 흔히 쓰이는 제약은 F#에서 제약을 쓰기에 그리 적합하지 않은 다른 방법으로 같은 일을 이룰 수 있어요. 또한 각 경우마다 .NET 라이브러리 및 기존 설계 관행과의 심각한 상호작용을 고려해야 해요. 마지막으로 일부 조건부 제약은 'dictionary passing' 구현을 필요로 하는데, 이는 추가적인 어려움을 가져와요. equality·comparison 제약은 dictionary passing이 아니고 궁극적으로 인터페이스·오버라이드를 통해 구현돼요. 그래서 Visual Studio 2010용 F#에서는 완전히 일반적인 메커니즘을 추가하는 대신, equality·comparison이라는 결정적인 사례에 대해 .NET과의 상호작용 문제를 해결하는 데 집중했어요.

요약

F#의 equality·comparison 제약은 F# 언어의 핵심 부분을 더 견고하게 만들고, 사용자 코드를 더 안전하고 단순하게 만들어 줘요.

전체적으로 보면 F# 코드는 이 제약들에 놀랄 만큼 큰 영향을 받지 않아요. 테스트 트리에는 아주 방대한 샘플 F# 코드가 있는데, 그중 극소수만이 equality·comparison 제약을 추가해야 했어요. 이 제약들은 문제를 잡아내지만, 방해하지는 않아요. 예를 들어 F# 컴파일러 코드베이스 전반에 구조적·동등성 제약을 사용하는데, 몇몇 경우에는 잠복해 있던 잠재적 버그를 찾아내는 데 도움이 됐어요.

1.9.7에서 동등성·비교를 제어하는 특성 일부도 단순화했어요. 특히 StructuralEquality 등의 'true'/'false' 인자를 없애고, 사용자가 정확히 무엇을 의도하는지 선언할 수 있게 CustomEquality를 추가했죠. 이런 변경에 맞춰 코드를 수정하는 데 추가 도움이 필요하면 알려 주세요.

더 안전하고 생산적인 코딩의 날들이 많아지길 바랍니다!

더 알아보기