값 비교
값 비교 (Comparison)
F#에서 값을 서로 비교할 수 있다는 건 생각보다 아주 중요한 특징이에요. 튜플이나 리스트를 =로 비교하는 일은 너무 자연스러워서 오히려 그 의미를 곱씹는 경우가 드물죠. 이 글에서는 F#이 동등성(equality)과 비교(comparison)를 어떤 제약(constraint)으로 다루는지, 그 원리부터 실전에서 쓸 수 있는 커스터마이징 방법까지 차근차근 설명해 볼게요.
출처: https://learn.microsoft.com/en-us/archive/blogs/dsyme/equality-and-comparison-constraints-in-f
참고: 본래 지정된 공식 URL
https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/values/comparison은 현재 F# 공식 문서에서 삭제되어 404를 반환합니다. 문서 세트에서 제거된 주제라 원본이 보존된, 동일 주제를 다루는 공식 자료(위 출처)를 바탕으로 번역했습니다.
본문
F# 1.9.7에서 동등성·비교 연산자를 쓸 때 코드의 문제를 조기에 잡아주는 새로운 제약 두 가지가 F# 언어에 도입됐어요. 이 글에서 이 제약들을 조금 더 자세히 들여다볼게요. 다루는 주제는 이러합니다.
- 튜플, 리스트와 그 밖의 구조적 타입 (Tuples, Lists and other Structural Types)
- F#의 기본 동등성·비교 연산 (The Basic Equality and Comparison Operations in F#)
- 동등성·비교 제약이 의미하는 것
- 새 구조적 타입 정의하기
- 동등성·비교 커스터마이징하기
- 더 안전한 코드: 타입에서 동등성·비교 끄기
- 더 안전한 코드: 더 안전한 Set과 Map
- 더 안전한 코드: 컨테이너 타입의 동등성 조건 선언
- F# 제네릭 동등성을 사용하는
IEqualityComparer<'T>·IComparer<'T>구현 얻기 - 비검사(unchecked) 동등성·비교 사용하기
- 설계 대안 (Design Alternatives)
- 요약 (Summary)
자세한 내용은 F# 언어 명세 초안에서도 확인할 수 있어요. 먼저 평소처럼 배경부터 짚어볼게요.
튜플, 리스트와 그 밖의 구조적 타입 (Tuples, Lists and other Structural Types)
F# 함수형 프로그래밍에서는 구조적 동등성(equality)·해싱(hashing)·비교를 자주 써요. 예를 들면 이런 코드죠.
(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)
리스트, 옵션, 배열은 물론이고, 필드 타입이 구조적 동등성·해싱·비교를 허용하는 사용자 정의 record·union·struct 타입에도 똑같은 원리가 적용돼요.
동등성과 비교는 "함수적 데이터로 프로그래밍하기"에서 놀랍도록 중요한 부분이에요. 튜플을 비교할 수 없고, 리스트를 비교할 수 없다면 F#이 F#이 아니라고 말해도 과장이 아니죠. 사용자 정의 구조적 타입도 마찬가지고요. 이 연산들이 어떻게 동작하는지는 결국 구성 요소의 동등성 속성에 달려 있어요.
그래서 동등성·해싱·비교(그 구조적 버전까지)라는 "아이디어"는 F# 언어와 핵심 라이브러리에 꽤 깊이 뿌리박혀 있어요. 실제로 F# 명세 여러 페이지가 사용자 정의 구조적 타입에서 동등성이 어떻게 동작하는지 정의하는 데 할애돼 있죠.
F#의 기본 동등성·비교 연산 (The Basic Equality and Comparison Operations in 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# 라이브러리의 다른 기본 제네릭 연산들과 마찬가지로, 위 연산들은 제약을 받아요. 여기서는 'T : equality와 'T : comparison 제약이지요. 타입 매개변수에 제약을 두는 이유는, 그 연산들을 합리적인 타입 집합에만 쓸 수 있게 하기 위해서예요. 예를 들어 System.Windows.Forms.Form 객체에 동등성과 비교를 써 보는 경우를 생각해 볼게요. 대부분의 .NET 객체 타입의 기본값이 **참조 동등성(reference equality)**이므로 동등성은 허용돼요.
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 라이브러리가 기본으로 주는 자연 순서는 존재하지 않아요.
이제 F# 코어 라이브러리의 다른 연산들과 달리, 동등성과 비교는 타입의 구조에 조건부로 달려 있어요. 예를 들어 튜플의 구성 요소가 동등성·비교를 지원해야만 그 튜플에서 위 연산자를 쓸 수 있어요. Form들의 튜플에 동등성을 쓰는 것은 허용되죠.
let form1 = new System.Windows.Forms.Form()
let form2 = new System.Windows.Forms.Form()
(form1, form2) = (form1, form2) // true
(form1, form2) = (form2, form1) // false
하지만 Form이 포함된 튜플에 순서를 매기려 하면 안 돼요.
(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#에서도 새 타입 정의에 커스텀 비교·동등성을 정의할 수 있어요. 커스터마이징하는 방법은 이 글의 뒤에서 살펴볼게요.
동등성·비교 제약이 의미하는 것 (What do the equality and comparison constraints mean?)
지금까지의 내용을 정리해 볼게요.
- 어떤 타입은 동등성이 구조적이에요. 예: 리스트, 옵션, 튜플, 사용자 정의 구조적 타입.
- 어떤 타입은 동등성이 참조 기반이에요. 이런 타입은 보통 비교를 지원하지 않아요. 예:
System.Windows.Forms.Form. - 어떤 타입은 동등성이 커스터마이즈돼 있어요. 예:
System.DateTime. - 어떤 타입은 동등성이나 비교의 합리적인 개념 자체가 없어요. 이런 타입은 "동등성 없음, 비교 없음"으로 표시된다고 생각하면 돼요. 이 경우는 코드의 버그를 더 일찍 찾는 데만 중요해요.
이 점들이 모여서, 동등성·비교 제약을 F# 언어의 일급(first-class) 기본 제약으로 도입하기로 한 결정의 근거가 됐어요. 이 글 뒷부분에서 이 설계 공간의 다른 선택지도 검토해 볼게요. 지금은 F#에서 동등성·비교 제약이 언제 충족되는지 더 자세히 볼게요.
규칙의 핵심은 아주 단순해요.
제약 type : equality 는 다음 조건을 만족하면 성립한다:
- 해당 타입의 타입 정의가 NoEquality 특성을 갖지 않고,
- 타입의 "동등성 의존성(equality dependencies)"도 모두 ty : equality 를 만족한다.
제약 type : comparison 은 다음 조건을 만족하면 성립한다:
- 타입이 이름 있는(named) 타입이라면, 그 타입 정의가 NoComparison 특성을 갖지 않고,
- 타입 정의가 System.IComparable 을 구현하고,
- 타입의 "비교 의존성(comparison dependencies)"도 모두 tyi : comparison 을 만족한다.
이 규칙들은 동등성 제약이 상대적으로 약한 제약임을 뜻해요. 거의 모든 CLI 타입이 이 제약을 만족하거든요. 참조 동등성은 C# 같은 다른 CLI 언어에서 널리 퍼져 있고, 그것이 CLI 라이브러리에 그대로 드러나요. 동등성 제약은 그 객체에 F#의 hash 함수를 쓸 수 있다는 뜻도 함께 내포해요.
비교 제약은 더 강한 제약이에요. 보통 타입이 System.IComparable을 구현해야 하기 때문이죠.
위에서 "의존성(dependencies)"이라는 개념을 언급했는데, 예를 들어 (int * string)은 int와 string이 둘 다 비교 가능하므로 비교 가능해요. 여기서 int와 string은 튜플 타입의 의존성인 셈이에요. 타입에 새 의존성을 선언하는 방법은 아래에서 더 살펴볼게요.
새 구조적 타입 정의하기 (Defining New Structural Types)
구조적 타입 정의는 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)
이것도 오류를 내요. 함수 값은 "동등성 없음"으로 간주되기 때문이에요.
구조적 타입이 참조 동등성을 사용하도록 선언할 수도 있어요.
[<ReferenceEquality>]
type MyFormWrapper = MyFormWrapper of System.Windows.Forms.Form * (int -> int)
정리하면, 앞서 살펴본 각 경우에 대해 타입의 비교·동등성 의미를 제어하는 특성은 이렇게 나뉘어요.
StructuralEquality,StructuralComparison– 구조적 타입이 동등성과 비교를 반드시 지원해야 함을 나타냄ReferenceEquality– 구조적 타입이 참조 동등성만 지원함을 나타냄NoComparison, NoEquality– 타입이 동등성·비교를 전혀 지원하지 않음을 나타냄CustomEquality, CustomComparison– 구조적 타입이 커스텀 동등성·비교를 지원함을 나타냄
참고로 "참조 비교(reference comparison)"라는 것은 존재하지 않아요. .NET이 쓰는 객체 포인터는 이리저리 움직여서 순서가 바뀔 수 있기 때문이에요. 그런 의미가 필요하다면 고유 태그와 커스텀 비교로 구현하면 돼요.
동등성·비교 커스터마이징하기 (Customizing Equality and Comparison)
어떤 타입은 자체 비교·동등성·해싱 의미를 직접 정의해야 할 때가 자주 있어요. 예를 들어 타입의 값이 고유 정수 태그를 들고 있어서 바로 그 태그를 이런 용도로 쓸 수 있는 경우가 그렇죠.
이 경우 우리의 권장은 주도권을 완전히 쥐고 타입에 커스텀 비교·동등성 연산을 직접 정의하는 거예요. 예를 들어 "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"
이렇게 하면 커스텀 구현을 좀 더 일관성 있게 만들 수 있어요. 예를 들어 union 타입을 위한 커스텀 구현은 이렇습니다.
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를 반드시 override해야 합니다.
더 안전한 코드: 타입에서 동등성·비교 끄기 (Safer Code: Suppressing Equality and Comparison on a Type)
F# 정의 타입의 정의에 [<NoEquality>] 특성을 붙이면 그 타입의 동등성을 끌 수 있어요. 그 타입이 더 이상 동등성 제약을 만족하지 않는다는 뜻이에요. 마찬가지로 [<NoComparison>] 특성을 붙이면 비교를 끌 수 있고, 그 타입은 비교 제약을 만족하지 않게 돼요.
[<NoEquality; NoComparison>]
type MyProjections =
| MyProjections of (int * string) * (string -> int)
이런 특성을 라이브러리 타입에 붙여 두면 클라이언트 코드가 더 안전해져요. 동등성·비교가 말이 안 되는 타입에 그것들을 우연히 의지할 가능성이 줄어들기 때문이에요.
더 안전한 코드: 더 안전한 Set과 Map (Safer Code: Safer Sets and Maps)
이전 버전의 F#은 모두 동등성·비교 제약을 확인하지 않았어요. 항상 이 문제를 해결할 계획이었지만, 세부 사항이 확정된 건 1.9.7에서였죠.
이 제약들을 확인하는 주요 동기 중 하나는 F#의 Set·Map 타입과 다른 미래의 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"에 서로 다른 클로저(closure)가 할당됐기 때문이에요. F# 명세에 따르면 함수 값에 대한 동등성 비교 결과는 정의되지 않은(undefined) 것이므로, 이는 그 스펙상 유효한 동작이었어요. 이제는 정적 오류가 돼요. 이런 경우들만 봐도 이 문제를 처리하는 게 얼마나 중요한지 알 수 있어요.
더 안전한 코드: 컨테이너 타입의 동등성 조건 선언하기 (Safer Code: Declaring Conditions for Equality over Container Types)
프로그래머들은 새 제네릭 컨테이너 타입을 정의하는 걸 좋아해요. .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를 어떤 이유로 클래스 타입으로 구현하거나, 타입에 커스텀 비교·동등성 로직을 붙인다면 어떻게 될까요? 그럴 때는 더 명시적으로 해야 해요. 타입 매개변수에 EqualityConditionalOn과 ComparisonConditionalOn 특성을 붙이는 것으로 시작하면 돼요.
type MyBox<[<EqualityConditionalOn; ComparisonConditionalOn >]'T> = ...
이 특성들을 붙이면, A가 이 제약들을 만족할 때에만 MyBox<A>가 동등성·비교 제약을 만족해요. 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"
인터페이스와 override의 구현이 "비검사(unchecked) 동등성·비교"를 사용한다는 점을 눈여겨봐 주세요. 이 내용은 아래에서 다룰게요. 여기서 비검사 동등성이 필요한 이유는 결국 두 가지 사실에서 나와요.
(a) F#은 커스텀 동등성을 .NET override와 인터페이스 구현 수준에서 지정하라고 요구하고, (b) .NET은 조건부 인터페이스·override 구현을 지원하지 않기 때문이에요.
(a)에는 큰 장점이 있어요. F#을 단순하게 만들고, 객체를 C#에 넘길 때 그 객체가 어떤 동작을 보일지 F# 매뉴얼을 찾아볼 필요도 없게 해 주죠.
다행히 비검사 동등성의 사용은 고립되고 국소적이어서, 실제로 틀리기가 놀라울 정도로 어려워요. 어쨌든 단위 테스트로 타입이 정말 동등성·비교를 올바르게 지원하는지 확인하는 게 좋아요.
F# 제네릭 동등성을 사용하는 IEqualityComparer<'T>·IComparer<'T> 구현 얻기 (Getting IEqualityComparer<'T> and IComparer<'T> Implementations which use F# Generic Equality)
.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
비검사 동등성·비교 사용하기 (Using unchecked equality and comparison)
앞에서 봤듯이, 값에 "비검사(unchecked)" 동등성·비교를 사용해야 하는 경우가 가끔 있어요. 이건 이전 버전의 F#처럼 어떤 정적 검사도 하지 않아요.
Unchecked.equals: 'T -> 'T -> bool
Unchecked.hash: 'T -> int
Unchecked.compare: 'T -> 'T -> int
이것들은 제약이 없는 연산이에요. 제약이 있는 연산과 똑같이 동작하는데, 예를 들어 기본 정수 타입에 쓰면 F# 컴파일러가 최적화하기도 해요. Unchecked.equals와 Unchecked.hash의 경우, 타입 "obj"로 변환해서 그 정적 타입에 대해 동등성을 수행하는 것과 의미상 비슷해요.
설계 대안 (Design Alternatives)
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#의 동등성·비교 제약을 다루기에 충분하지 않아요. 이 점이 동등성·비교 제약을 F# 언어의 새롭고 소거되는 제약으로 도입하기로 한 결정의 근거예요.
Haskell과 다른 함수형 언어에 익숙한 독자라면 동등성·비교와 관련된 모든 문제를 잘 알고 있을 거예요. 이 문제들은 어떤 정적 타입 언어에서든 동등성·비교의 근본이라, 특히 함수형 언어에서 두드러지거든요. F#에서 이 점들을 다루는 메커니즘은 **약한 형태의 타입 클래스(type class)**를 연상시켜요. 특히 동등성·비교가 타입의 구조에 의존한다는 점, 그리고 그 의존성을 어떤 타입에 대해서든 선언할 수 있다는 점에서요. Haskell은 사용자가 자신만의 제약을 매우 풍부한(때로는 꽤 복잡한!) 방식으로 정의하는 것도 허용해요.
어쨌든 동등성과 비교는 F# 코어 라이브러리에서 타입 구조에 정적으로 조건부인 유일한 연산이에요. 그러면 이런 질문이 생겨요. F#은 왜 (인터페이스 제약 너머의) 사용자 정의 제약을 허용하지 않고, 다른 제약들은 왜 조건부로 선언할 수 없을까? 결국 출력(printing)이나 직렬화(serialization)처럼 다른 연산도 구조에 조건부로 만드는 게 유용하다는 건 널리 알려져 있으니까요.
그 대답은 두 가지예요. 첫째, 미래의 F# 버전에서는 동등성·비교 제약을 구현하는 데 쓴 메커니즘을 확장해 사용자 정의 제약을 포함하는 방안을 고려할 수 있어요.
둘째, 하지만 포매팅·직렬화 가능성 같은 흔히 쓰이는 많은 제약에는, 제약을 쓰기에 별로 적합하지 않은 다른 방법으로도 F#에서 같은 일을 이룰 수 있어요. 또 각 경우마다 .NET 라이브러리와 기존 설계 관행과의 심각한 상호작용을 고려해야 해요. 마지막으로 일부 조건부 제약은 "dictionary passing" 구현을 요구할 텐데, 이는 추가적인 어려움을 가져와요. 동등성·비교 제약은 dictionary passing이 아니에요. 궁극적으로 인터페이스와 override로 구현되죠. 그 결과 **Visual Studio 2010의 F#**에서는 완전히 일반적인 메커니즘을 추가하는 대신, 동등성과 비교의 중요한 경우에 .NET과의 상호작용을 해결하는 데 집중했어요.
요약 (Summary)
F#의 동등성·비교 제약은 F# 언어의 핵심 부분을 더 단단하게 다져 줘요. 사용자 코드를 더 안전하고 더 단순하게 만들어 주죠.
전반적으로 F# 코드는 이 제약들의 영향을 놀랍도록 적게 받아요. 우리의 테스트 트리에는 아주 방대한 F# 예제 코드가 있는데, 그중 아주 일부만 동등성·비교 제약을 추가할 필요가 있었어요. 이 제약들은 문제를 잡아 주지만, 방해하지는 않는다는 뜻이에요. 예를 들어 우리는 F# 컴파일러 코드베이스 전반에 구조적·동등성 제약을 사용하고 있고, 몇몇 경우에는 그 제약이 잠재적 버그를 찾는 데 도움이 됐어요.
1.9.7에서는 동등성·비교를 제어하는 특성 중 일부도 단순화했어요. 특히 StructuralEquality 등에서 "true"·"false" 인자를 제거하고, 사용자가 정확히 의도한 바를 선언할 수 있도록 CustomEquality를 추가했죠. 이런 변경에 맞춰 코드를 조정하는 데 추가 도움이 필요하면 알려 주세요.
더 안전하고 더 생산적인 코딩의 많은 행복한 날들이 함께하길 바랍니다!