불변 객체와 값 동등성
이 장에서 배우는 것
앞 장에서 여러 작업을 함께 실행하고 취소와 시간 제한을 적용했다. 여러 작업이 같은 객체를 읽는다면 그 객체의 내용이 도중에 바뀌는지부터 확인해야 한다. 작은 동네 도서관에서도 예약을 등록하는 작업과 예약 목록을 조회하는 작업이 같은 회원 번호와 도서 코드를 사용할 수 있다. 생성이 끝난 뒤 상태를 바꿀 수 없는 불변 객체(immutable object)는 이러한 데이터를 이해하기 쉽게 만든다.
이번에는 예약을 식별하는 회원 번호와 도서 코드를 하나의 객체로 묶는다. 같은 내용을 가진 두 객체가 같은 예약 키로 취급되도록 값 동등성(value equality)을 정의하고, 집합과 사전에서 그 규칙이 어떻게 사용되는지 살펴본다. 불변성과 동등성은 서로 다른 개념이지만, 컬렉션의 키를 설계할 때 함께 고려하면 변경으로 생기는 문제를 줄일 수 있다.
- ReadOnly 속성과 생성자로 유효한 상태를 만들고 유지한다.
- Equals와 IEquatable(Of T)로 객체의 내용에 따른 비교 규칙을 정의한다.
- GetHashCode와 동등성 사이의 계약을 설명한다.
- =와 <> 연산자를 구현하고 Nothing을 일관되게 처리한다.
- HashSet과 Dictionary에서 안정적으로 사용할 예약 키를 만든다.
문제 상황
도서관은 한 회원이 같은 도서에 중복 예약을 넣는 것을 막으려 한다. 예약 화면에서는 회원 번호 17과 도서 코드 B-104를 받고, 대기 목록을 읽는 작업에서도 같은 두 값을 얻는다. 두 곳에서 각각 객체를 만들었다고 하자. 내용은 같지만 생성된 객체는 서로 다르다. 일반 클래스가 제공하는 기본 Equals 비교에만 의존하면 두 객체를 같은 예약 키로 판단하지 못할 수 있다.
반대로 예약 키의 속성을 자유롭게 바꿀 수 있도록 만들면 다른 문제가 생긴다. 사전에 키를 넣은 다음 도서 코드를 B-205로 바꾸면, 키의 동등성과 해시 계산에 쓰이는 값이 달라진다. 사전은 삽입 당시의 해시를 기준으로 항목을 관리하므로 이후 검색이 예상대로 동작하지 않을 수 있다. 화면에서 보이는 키 값만 살펴보면 원인을 찾기 어렵다.
여기서 예약 키는 예약 자체와 구분한다. 예약에는 접수 상태나 처리 결과처럼 변경되는 정보가 있을 수 있다. 이번에 만드는 객체는 “어느 회원이 어느 도서를 예약했는가”라는 식별 값만 담는다. 회원 번호와 도서 코드의 조합이 같으면 같은 키다. 예약 상태는 사전의 값으로 둔다.
도서 코드의 입력 규칙도 정해야 한다. 이 예제에서는 앞뒤 공백을 제거하고 대문자로 바꾼 코드를 저장한다. 따라서 b-104와 B-104는 같은 코드로 취급한다. 이는 도서관이 정한 코드 정책이며, 사람 이름이나 모든 자연어 문자열에 그대로 적용할 규칙은 아니다.
생성자로 완성하는 불변 객체
ReadOnly 속성은 출발점이다
자동 구현 ReadOnly 속성은 외부에서 값을 대입할 수 없으며, 해당 형식의 생성자에서 초기값을 지정할 수 있다. 회원 번호와 도서 코드를 생성자의 매개변수로 받으면 객체를 만든 직후부터 필요한 값이 준비된다. 생성자는 회원 번호가 양수인지, 도서 코드가 비어 있지 않은지도 검사한다. 잘못된 입력은 객체 생성 단계에서 거부한다.
Public ReadOnly Property MemberId As Integer
Public ReadOnly Property BookCode As String
Public Sub New(memberId As Integer, bookCode As String)
If memberId <= 0 Then
Throw New ArgumentOutOfRangeException(NameOf(memberId))
End If
If String.IsNullOrWhiteSpace(bookCode) Then
Throw New ArgumentException(
"도서 코드는 비어 있을 수 없다.", NameOf(bookCode))
End If
Me.MemberId = memberId
Me.BookCode = bookCode.Trim().ToUpperInvariant()
End Sub
검사와 정규화는 생성자에 모은다. 호출하는 곳마다 대문자 변환을 반복하게 만들면 한 경로에서 처리를 빠뜨릴 수 있다. 생성된 객체는 항상 같은 형식의 도서 코드를 갖게 하고, 동등성 비교에서는 이미 정리된 값을 비교한다.
ReadOnly가 붙었다고 모든 객체가 불변이 되는 것은 아니다. 속성이 List(Of String)을 반환한다면 속성에 다른 목록을 대입하지 못하더라도 반환받은 목록에 항목을 추가할 수 있다. 이번 예약 키의 Integer는 값 형식이고 String은 불변 형식이므로 두 속성을 통해 내부 내용을 변경할 경로가 없다.
| 속성 형식 | 외부 대입 | 내용 변경 |
|---|---|---|
| ReadOnly Integer | 불가능하다 | 속성을 통해 값을 바꿀 수 없다 |
| ReadOnly String | 불가능하다 | 문자열 자체를 수정할 수 없다 |
| ReadOnly List(Of String) | 불가능하다 | 반환된 목록의 항목은 바꿀 수 있다 |
수정 대신 새 객체를 만든다
사용자가 다른 도서로 예약을 변경한다면 기존 키의 도서 코드를 수정하지 않는다. 같은 회원 번호와 새 도서 코드로 새 키를 만든다. 완성 코드의 WithBookCode 메서드는 이 방식을 사용한다. 메서드 이름은 변경된 값을 가진 객체를 돌려준다는 뜻이며, 호출 대상의 상태는 유지된다.
클래스에는 NotInheritable도 붙인다. 하위 클래스가 새로운 상태를 추가하면서 기존 동등성 규칙과 다른 의미를 만들지 못하도록 형식의 경계를 닫는 선택이다. 모든 불변 클래스에 필요한 문법은 아니지만, 이번처럼 작은 식별 값을 표현하는 형식에서는 비교 규칙을 분명하게 유지하는 데 도움이 된다.
불변 객체가 있다고 주변 작업 전체가 동시 실행에 안전해지는 것은 아니다. 여러 작업이 같은 Dictionary에 항목을 추가하거나 제거한다면 컬렉션의 변경을 별도로 관리해야 한다. 불변 키는 키의 상태 변경 문제를 줄이며, 컬렉션 자체의 동시 변경 문제까지 해결하지는 않는다.
동등성 규칙을 한곳에 모으기
참조 동일성(reference identity)은 두 변수가 같은 객체를 가리키는지 묻는다. 값 동등성은 서로 다른 객체라도 비교 대상인 값이 같은지 묻는다. 예약 키에서는 회원 번호와 정규화된 도서 코드가 모두 같아야 같은 값이다. 객체가 만들어진 시점이나 변수 이름은 비교에 포함하지 않는다.
Object.ReferenceEquals는 참조 동일성을 확인한다. 예약 키의 Equals는 값 동등성을 확인한다. 두 확인 결과가 다를 수 있으며, 그것이 이 형식의 의도다. 다만 Equals가 같은 객체를 자기 자신과 비교할 때는 참이 되어야 한다.
Equals와 IEquatable(Of T)
IEquatable(Of ReservationKey)를 구현하면 ReservationKey를 직접 받는 Equals 메서드를 제공한다. 이 메서드에 핵심 비교 규칙을 둔다. Dictionary와 HashSet이 사용하는 기본 동등성 비교자는 이 인터페이스의 구현을 활용할 수 있다.
Object를 받는 Equals도 재정의한다. 다른 형식의 객체와 비교하는 호출에서는 이 진입점이 사용될 수 있기 때문이다. 여기서는 TryCast로 ReservationKey인지 확인하고, 형식이 맞으면 앞에서 만든 비교 메서드에 맡긴다. 다른 형식이거나 Nothing이면 거짓을 반환한다.
두 Equals에 비교식을 따로 작성하지 않는 것이 중요하다. 한쪽은 대소문자를 무시하고 다른 쪽은 구분한다면 호출 경로에 따라 결과가 달라진다. 완성 코드는 도서 코드를 생성자에서 정규화하고, 비교에서는 StringComparer.Ordinal을 사용한다. 실행 환경의 문화권에 따른 문자열 비교 규칙을 키의 의미에 끌어들이지 않기 위한 선택이다.
동등성은 자기 자신과 같아야 하고, 비교 순서를 바꾸어도 결과가 같아야 하며, 같은 값이라는 관계가 이어져야 한다. 두 객체의 비교 대상 값이 그대로라면 반복 비교 결과도 같아야 한다. 회원 번호와 도서 코드를 함께 비교하는 규칙은 이러한 요구를 만족하도록 설계할 수 있다.
GetHashCode의 약속
해시 코드(hash code)는 해시 기반 컬렉션이 검색할 후보를 줄이는 데 사용하는 정수다. 값이 같은 두 키는 같은 해시 코드를 반환해야 한다. 역방향은 성립하지 않는다. 서로 다른 키가 같은 해시 코드를 가질 수도 있으며, 컬렉션은 동등성 비교로 실제 일치 여부를 다시 확인한다.
따라서 Equals에서 회원 번호와 도서 코드를 비교한다면 GetHashCode도 그 값들을 기준으로 계산한다. 문자열 비교와 해시 계산에는 같은 비교 정책을 사용해야 한다. 완성 코드에서는 StringComparer.Ordinal로 문자열의 해시를 구하고, HashCode.Combine으로 회원 번호와 결합한다.
해시 코드는 저장용 식별자가 아니다. 특히 HashCode와 문자열 해시는 실행이 달라질 때 같은 정수를 보장하지 않는다. 파일에 해시를 저장해 예약 번호로 쓰거나 실행 결과 예시에 특정 해시 정수를 적어 두지 않는다. 이번 검사는 같은 실행 안에서 같은 키의 해시가 같은지만 확인한다.
| 기능 | 판단 대상 | 이번 구현의 기준 |
|---|---|---|
| Object.ReferenceEquals | 같은 객체인지 | 참조가 같은지 확인한다 |
| Equals | 같은 값인지 | 회원 번호와 도서 코드가 모두 같다 |
| GetHashCode | 검색 후보 분류 | 동등성에 쓰는 값을 함께 계산한다 |
| =와 <> | 연산자 형태의 값 비교 | Nothing 처리 후 Equals에 맡긴다 |
연산자는 비교 규칙의 다른 입구다
Visual Basic에서는 =를 오버로드할 때 <>도 함께 정의한다. 두 연산자가 서로 반대 결과를 내도록 <>에서 =의 결과를 뒤집는다. =의 구현은 두 참조가 같으면 참을 반환하고, 한쪽만 Nothing이면 거짓을 반환한다. 나머지는 Equals에 맡긴다.
연산자 내부에서 Nothing을 확인하려고 다시 left = Nothing을 사용하면 현재 작성 중인 = 연산자가 호출될 수 있다. 참조 확인에는 Object.ReferenceEquals를 쓰거나 Is를 사용해야 한다. 완성 코드에서는 참조 확인 방식을 통일하기 위해 Object.ReferenceEquals를 사용한다.
컬렉션 키의 값은 유지되어야 한다
HashSet(Of ReservationKey)은 같은 회원과 같은 도서의 조합을 한 번만 보관한다. Dictionary(Of ReservationKey, String)은 예약 키로 상태를 찾는다. 조회할 때 삽입에 사용한 객체를 그대로 보관할 필요는 없다. 같은 값을 가진 새 키를 만들어도 동등성과 해시 규칙이 일관되면 항목을 찾을 수 있다.
키가 컬렉션에 들어 있는 동안에는 동등성과 해시에 영향을 주는 상태를 바꾸지 않아야 한다. 예를 들어 생성 시각처럼 비교에 쓰지 않는 속성을 추가했다고 해서 모든 상태가 키의 일부가 되는 것은 아니다. 무엇이 키의 의미를 구성하는지 먼저 정하고, 그 값들을 안정적으로 유지해야 한다.
이번 예제는 구분하기 쉽게 식별 값만 가진 불변 클래스를 사용한다. 사전의 값인 예약 상태는 갱신할 수 있다. 같은 값을 가진 두 번째 키로 상태를 갱신하면 항목 수는 늘어나지 않고 기존 항목의 값이 바뀐다. 키의 불변성과 사전 항목 값의 변경 가능성은 함께 존재할 수 있다.
완성 코드
다음 전체 코드를 콘솔 앱의 Program.vb에 넣는다. 프로젝트에는 외부 패키지를 추가하지 않는다. 검사 함수는 조건이 맞지 않으면 예외를 발생시키고, 성공하면 통과 수를 누적한다. 이 예제의 검사는 콘솔 안에서 동등성 계약과 컬렉션 동작을 확인하는 용도다.
Option Strict On
Option Explicit On
Option Infer On
Imports System
Imports System.Collections.Generic
Imports System.Globalization
Public NotInheritable Class ReservationKey
Implements IEquatable(Of ReservationKey)
Public ReadOnly Property MemberId As Integer
Public ReadOnly Property BookCode As String
Public Sub New(memberId As Integer, bookCode As String)
If memberId <= 0 Then
Throw New ArgumentOutOfRangeException(NameOf(memberId))
End If
If String.IsNullOrWhiteSpace(bookCode) Then
Throw New ArgumentException(
"도서 코드는 비어 있을 수 없다.", NameOf(bookCode))
End If
Me.MemberId = memberId
Me.BookCode = bookCode.Trim().ToUpperInvariant()
End Sub
Public Function WithBookCode(bookCode As String) As ReservationKey
Return New ReservationKey(MemberId, bookCode)
End Function
Public Overloads Function Equals(
other As ReservationKey) As Boolean _
Implements IEquatable(Of ReservationKey).Equals
If Object.ReferenceEquals(other, Nothing) Then
Return False
End If
Return MemberId = other.MemberId AndAlso
StringComparer.Ordinal.Equals(BookCode, other.BookCode)
End Function
Public Overloads Overrides Function Equals(obj As Object) As Boolean
Return Equals(TryCast(obj, ReservationKey))
End Function
Public Overrides Function GetHashCode() As Integer
Return HashCode.Combine(
MemberId, StringComparer.Ordinal.GetHashCode(BookCode))
End Function
Public Shared Operator =(
left As ReservationKey, right As ReservationKey) As Boolean
If Object.ReferenceEquals(left, right) Then
Return True
End If
If Object.ReferenceEquals(left, Nothing) OrElse
Object.ReferenceEquals(right, Nothing) Then
Return False
End If
Return left.Equals(right)
End Operator
Public Shared Operator <>(
left As ReservationKey, right As ReservationKey) As Boolean
Return Not (left = right)
End Operator
Public Overrides Function ToString() As String
Return MemberId.ToString(CultureInfo.InvariantCulture) &
"/" & BookCode
End Function
End Class
Module Program
Private passed As Integer
Sub Main()
Dim first As New ReservationKey(17, " b-104 ")
Dim second As New ReservationKey(17, "B-104")
Dim otherMember As New ReservationKey(18, "B-104")
Dim emptyKey As ReservationKey = Nothing
Dim sameAsObject As Object = second
Check(Not Object.ReferenceEquals(first, second),
"別 객체")
Check(first.Equals(second), "형식 지정 Equals")
Check(first.Equals(sameAsObject), "Object Equals")
Check(first = second, "= 연산자")
Check(Not (first <> second), "<> 연산자")
Check(first <> emptyKey, "한쪽 Nothing")
Check(emptyKey = emptyKey, "양쪽 Nothing")
Check(first <> otherMember, "다른 회원")
Check(first.GetHashCode() = second.GetHashCode(),
"같은 값의 해시")
Dim keys As New HashSet(Of ReservationKey)()
keys.Add(first)
keys.Add(second)
Check(keys.Count = 1, "집합 중복 제거")
Check(keys.Contains(New ReservationKey(17, "b-104")),
"새 객체로 집합 검색")
Dim states As New Dictionary(Of ReservationKey, String)()
states.Add(first, "대기")
states(second) = "접수 완료"
Check(states.Count = 1 AndAlso states(first) = "접수 완료",
"같은 키로 상태 갱신")
Dim changed As ReservationKey = first.WithBookCode("B-205")
Check(first.BookCode = "B-104" AndAlso
changed.BookCode = "B-205" AndAlso first <> changed,
"원본 유지와 새 키 생성")
Console.WriteLine("정규화된 키: " & first.ToString())
Console.WriteLine("값 비교: " &
If(first = second, "같음", "다름"))
Console.WriteLine("집합의 키 수: " &
keys.Count.ToString(CultureInfo.InvariantCulture))
Console.WriteLine("사전의 항목 수: " &
states.Count.ToString(CultureInfo.InvariantCulture))
Console.WriteLine("예약 상태: " & states(second))
Console.WriteLine("변경 전 키: " & first.ToString())
Console.WriteLine("새 예약 키: " & changed.ToString())
Console.WriteLine("검사 통과: " &
passed.ToString(CultureInfo.InvariantCulture))
End Sub
Private Sub Check(condition As Boolean, name As String)
If Not condition Then
Throw New InvalidOperationException("검사 실패: " & name)
End If
passed += 1
End Sub
End Module
줄별 해설
Option Strict On은 암시적인 축소 변환과 늦은 바인딩을 제한한다. Option Explicit On은 변수를 선언하고 사용하도록 하며, Option Infer On은 초기값에서 지역 변수의 형식을 추론하도록 한다. 세 선언을 파일 위에 두어 예제의 형식 검사 조건을 분명하게 했다.
Imports System.Collections.Generic은 집합과 사전에, Imports System.Globalization은 출력할 정수를 일정한 형식으로 바꾸는 데 사용한다. 해시 정수와 실행 시각은 출력하지 않는다. 집합이나 사전의 열거 순서에도 출력 결과를 의존시키지 않는다.
ReservationKey 선언의 NotInheritable은 상속을 막는다. Implements 선언은 형식이 지정된 Equals를 제공한다는 계약을 표시한다. 이어지는 두 ReadOnly 속성은 예약 키의 전체 상태다. 클래스가 참조 형식이라는 사실은 유지되며, 값 형식으로 변하는 것은 아니다.
생성자의 첫 If는 0 이하의 회원 번호를 거부한다. 두 번째 If는 Nothing, 빈 문자열, 공백만 있는 문자열을 함께 거부한다. 이 검사를 통과한 뒤 Trim과 ToUpperInvariant를 호출하므로 저장되는 도서 코드는 비어 있지 않은 정규화된 문자열이다.
WithBookCode의 Return은 새 생성자를 호출한다. 새 코드에도 같은 검사가 적용된다. 잘못된 코드가 들어오면 새 객체 생성이 실패하고 기존 객체는 그대로 남는다. 반환값을 사용하지 않으면 기존 키에 아무 변경도 생기지 않는다.
형식이 지정된 Equals는 other가 Nothing인지 먼저 확인한다. 이어서 AndAlso로 회원 번호와 도서 코드를 비교한다. 회원 번호가 다르면 뒤의 문자열 비교는 수행하지 않는다. Object를 받는 Equals는 TryCast 결과를 이 메서드에 전달하므로 같은 비교 규칙을 사용한다.
GetHashCode는 Equals에서 확인한 두 값을 결합한다. HashCode.Combine은 두 입력을 받아 하나의 정수를 반환한다. 이 정수를 사람이 읽는 예약 번호로 해석하지 않는다. 같은 값을 가진 키가 같은 실행 안에서 같은 해시를 제공하는 것이 여기서 필요한 성질이다.
= 연산자는 두 참조가 같다는 빠른 확인부터 한다. 이 경우 두 값이 모두 Nothing인 상황도 참으로 처리된다. 이후 한쪽이 Nothing인 경우를 거르고, 두 실제 객체의 비교는 Equals에 맡긴다. <>는 이 결과를 뒤집으므로 두 연산자의 규칙이 갈라지지 않는다.
ToString은 화면에 보여 줄 키 형식을 만든다. MemberId에는 문화권을 명시하고, 도서 코드와 /로 연결한다. 이 문자열은 읽기 편한 표시이며, Equals와 GetHashCode의 구현을 대체하지 않는다.
Main의 first와 second는 서로 다른 객체다. 첫 번째 Check는 그 사실을 검사한다. 다음 검사들은 서로 다른 객체가 같은 값으로 비교되는지 확인한다. sameAsObject는 선언 형식이 Object이므로 Object를 받는 Equals 경로도 검사할 수 있다.
HashSet에 두 키를 추가한 뒤 개수가 1인지 확인한다. 이어서 세 번째 객체를 새로 만들어 Contains에 전달한다. Dictionary에서는 first로 추가한 항목을 second로 갱신하고, 항목 수가 1이며 first로 읽은 상태가 갱신되었는지 함께 확인한다.
마지막 Check는 기존 키의 코드가 유지되고, 새 키의 코드가 달라졌으며, 두 키가 다른 값인지 확인한다. Check 호출은 모두 13번이다. 실패하면 출력 단계까지 진행하지 않고 예외가 발생한다. 성공하면 지정된 여덟 줄을 출력하고 Main이 종료된다.
실행 결과
다음 명령으로 프로젝트를 만든 뒤 Program.vb를 완성 코드로 교체한다. .NET 10 SDK가 설치된 macOS 또는 Linux에서 실행한다.
dotnet new console -lang VB -f net10.0 -n NeighborhoodLibrary
cd NeighborhoodLibrary
dotnet run
프로그램의 예상 출력은 다음과 같다.
정규화된 키: 17/B-104
값 비교: 같음
집합의 키 수: 1
사전의 항목 수: 1
예약 상태: 접수 완료
변경 전 키: 17/B-104
새 예약 키: 17/B-205
검사 통과: 13
집합과 사전에 같은 값의 객체를 두 번 사용했지만 항목 수는 각각 1이다. 도서 코드를 바꾸는 메서드를 호출한 뒤에도 변경 전 키는 17/B-104다. 해시는 출력하지 않으므로 실행마다 달라질 수 있는 해시 정수가 예상 출력에 영향을 주지 않는다.
실무에서 자주 틀리는 것
ReadOnly 목록을 불변 목록으로 생각한다
다음 속성은 목록 참조의 교체만 막는다. 호출자가 Codes.Add를 실행하면 객체가 보유한 목록이 바뀐다.
Public ReadOnly Property Codes As New List(Of String)()
목록을 보관해야 한다면 입력을 복사하고 내부 목록을 직접 노출하지 않는 방법을 선택할 수 있다. 다음 코드는 생성자가 만든 배열을 비공개로 보관하고 읽기 전용 래퍼만 반환한다. 요소가 불변 문자열이고 내부 배열도 이후 수정하지 않으므로 이 예제에서는 외부에서 내용을 바꿀 경로가 없다.
Private ReadOnly storedCodes As String()
Public Sub New(source As IEnumerable(Of String))
If source Is Nothing Then
Throw New ArgumentNullException(NameOf(source))
End If
storedCodes = New List(Of String)(source).ToArray()
End Sub
Public ReadOnly Property Codes As IReadOnlyList(Of String)
Get
Return Array.AsReadOnly(storedCodes)
End Get
End Property
IReadOnlyList만 반환한다고 원래 데이터까지 불변이 되는 것은 아니다. 호출자가 여전히 원본 목록을 수정할 수 있다면 내용은 바뀔 수 있다. 위 코드에서 입력 복사와 내부 데이터 비노출이 함께 필요한 이유다. 요소가 변경 가능한 객체라면 요소 내부 상태도 별도로 검토해야 한다.
동등성은 바꾸고 해시 규칙은 그대로 둔다
다음 조각은 객체의 참조를 기준으로 한 기본 해시를 그대로 사용한다. 값이 같은 별개 객체의 해시가 같아야 한다는 요구를 만족시키는 구현이 아니다.
Public Overrides Function GetHashCode() As Integer
Return MyBase.GetHashCode()
End Function
동등성에 사용하는 값과 문자열 비교 정책에 맞추어 해시를 계산한다.
Public Overrides Function GetHashCode() As Integer
Return HashCode.Combine(
MemberId, StringComparer.Ordinal.GetHashCode(BookCode))
End Function
검사할 때는 같은 키의 해시가 같은지 확인한다. 다른 키의 해시가 서로 다르다는 조건은 검사에 넣지 않는다. 해시 충돌은 허용되므로 그러한 검사는 구현에 필요 이상의 요구를 붙인다.
오버로드한 연산자로 Nothing을 확인한다
다음 코드를 = 연산자의 본문에 넣으면 Nothing 확인 과정에서 같은 연산자를 다시 호출한다. 재귀 호출이 반복될 수 있다.
If left = Nothing Then
Return False
End If
참조 확인을 사용하고, 두 값이 모두 Nothing인 경우도 먼저 처리한다.
If Object.ReferenceEquals(left, right) Then
Return True
End If
If Object.ReferenceEquals(left, Nothing) OrElse
Object.ReferenceEquals(right, Nothing) Then
Return False
End If
Return left.Equals(right)
실제 객체와 Nothing은 다른 값이고, Nothing끼리는 연산자 비교에서 같은 것으로 정했다. 이 규칙은 Equals를 호출할 실제 객체가 없는 경우까지 연산자가 다루기 위해 필요하다.
사전에 넣은 키를 직접 수정한다
다음은 BookCode를 쓰기 가능한 속성으로 만든 설계에서 생기는 잘못된 사용이다. 완성 코드의 ReadOnly 속성에서는 대입문 자체가 컴파일되지 않는다.
states.Add(key, "대기")
key.BookCode = "B-205"
식별 값의 변경은 기존 항목을 제거하고 새 키로 다시 등록하는 작업으로 표현한다. 새 키가 이미 있는 경우에는 어떻게 처리할지도 먼저 정한다. 다음 조각은 목적지 키가 있으면 변경을 거부한다.
Dim replacement As ReservationKey = key.WithBookCode("B-205")
If replacement <> key Then
If states.ContainsKey(replacement) Then
Throw New InvalidOperationException("새 예약 키가 이미 있다.")
End If
Dim currentState As String = states(key)
states.Remove(key)
states.Add(replacement, currentState)
key = replacement
End If
마지막 대입은 지역 변수 key가 새 객체를 가리키게 한다. 이전 객체의 속성은 변경하지 않는다. 이 조각은 단일 실행 흐름을 전제로 한다. 여러 작업이 사전을 변경하는 상황에서는 확인과 제거와 추가를 하나의 일관된 작업으로 관리해야 한다.
한눈에 보기
| 설계 항목 | 적용 방법 | 확인할 결과 |
|---|---|---|
| 초기 상태 | 생성자에서 검사하고 정규화한다 | 유효한 키만 생성된다 |
| 상태 유지 | ReadOnly 속성과 불변 요소를 사용한다 | 외부에서 키 내용을 바꾸지 못한다 |
| 변경 표현 | 변경된 값을 가진 새 객체를 반환한다 | 기존 객체는 유지된다 |
| 값 비교 | 형식 지정 Equals에 비교를 모은다 | 객체 생성 경로와 무관하게 비교한다 |
| 해시 계약 | 동등성에 쓰는 값을 계산에 사용한다 | 같은 값은 같은 해시를 제공한다 |
| 연산자 | Nothing을 처리하고 Equals에 맡긴다 | =와 <>가 반대 결과를 낸다 |
| 컬렉션 사용 | 키를 유지하고 항목 값을 갱신한다 | 같은 키로 기존 항목을 찾는다 |
불변성은 상태 변경 경로에 대한 약속이고, 값 동등성은 비교의 의미에 대한 약속이다. 각각을 따로 설계한 뒤 두 약속이 컬렉션에서 함께 유지되는지 확인한다. 다음 장에서는 이러한 데이터 객체를 사용하는 서비스를 인터페이스 뒤에 배치하고, 필요한 기능을 외부에서 전달하는 구조를 다룬다.
언어와 라이브러리의 계약을 더 확인하려면 ReadOnly 문법, IEquatable(Of T), GetHashCode 계약을 참고할 수 있다.
연습 문제
- 완성 코드에 검사를 추가한다. first.Equals(first)는 참이고, first.Equals(CType("17/B-104", Object))는 거짓이어야 한다. 표시 문자열이 같아 보여도 두 번째 비교가 거짓인 이유를 설명한다.
- 예약 키의 동등성을 회원 번호만으로 정의하도록 바꿨다고 가정한다. GetHashCode도 어떻게 바꿔야 하는지 작성하고, 도서관 예약 키로는 이 규칙이 왜 부적절한지 설명한다.
- WithBookCode에 공백만 있는 문자열을 전달하는 검사를 추가한다. ArgumentException이 발생하는지 콘솔 검사 함수로 확인하고, 기존 객체의 BookCode가 유지되는지도 검사한다.
- 연체료 규칙을 표현하는 불변 클래스 LateFeeRule을 설계한다. 속성은 DailyAmount As Decimal과 GraceDays As Integer다. 음수 입력을 거부하고 두 속성으로 동등성과 해시를 정의하라. 같은 일일 금액이지만 유예 일수가 다른 두 객체는 다른 값이어야 한다.
정답과 해설
1. 같은 객체와 다른 형식의 비교
Check(first.Equals(first), "자기 자신 비교")
Check(Not first.Equals(CType("17/B-104", Object)),
"다른 형식 비교")
첫 비교는 같은 회원 번호와 도서 코드를 비교하므로 참이다. 두 번째 비교에서는 문자열을 ReservationKey로 변환할 수 없어 TryCast가 Nothing을 반환한다. 표시 형식이 같다는 사실은 두 형식의 값이 같다는 뜻이 아니다. 이 검사를 Main의 출력 전에 추가하면 검사 통과 수는 15가 된다.
2. 동등성에 맞춘 해시 변경
Public Overrides Function GetHashCode() As Integer
Return MemberId.GetHashCode()
End Function
회원 번호만으로 동등성을 판단한다면 해시도 도서 코드에 의존하지 않아야 한다. 그렇지 않으면 같은 회원의 서로 다른 도서 키가 Equals에서는 같으면서 다른 해시를 반환할 수 있다. 그러나 이 규칙은 한 회원이 서로 다른 도서를 예약한 경우까지 같은 키로 합친다. 이번 서비스에서는 도서 코드도 동등성에 포함해야 한다.
3. 잘못된 새 값과 원본 유지
Dim rejected As Boolean = False
Try
first.WithBookCode(" ")
Catch ex As ArgumentException
rejected = True
End Try
Check(rejected, "공백 코드 거부")
Check(first.BookCode = "B-104", "실패 후 원본 유지")
WithBookCode는 새 생성자에 입력을 전달한다. 생성자의 검사에서 예외가 발생하므로 새 객체는 반환되지 않는다. 기존 first의 속성에 대입하는 과정이 없으므로 원본 코드는 유지된다. 다른 종류의 예외까지 모두 잡지 않고 기대하는 예외만 잡아야 검사 의도가 분명해진다.
4. 연체료 규칙의 값 동등성
다음 클래스를 같은 Program.vb의 Module 밖에 추가할 수 있다. 이 문제에서는 연산자 구현을 요구하지 않으므로 두 Equals와 GetHashCode에 집중한다.
Public NotInheritable Class LateFeeRule
Implements IEquatable(Of LateFeeRule)
Public ReadOnly Property DailyAmount As Decimal
Public ReadOnly Property GraceDays As Integer
Public Sub New(dailyAmount As Decimal, graceDays As Integer)
If dailyAmount < 0D Then
Throw New ArgumentOutOfRangeException(NameOf(dailyAmount))
End If
If graceDays < 0 Then
Throw New ArgumentOutOfRangeException(NameOf(graceDays))
End If
Me.DailyAmount = dailyAmount
Me.GraceDays = graceDays
End Sub
Public Overloads Function Equals(
other As LateFeeRule) As Boolean _
Implements IEquatable(Of LateFeeRule).Equals
If Object.ReferenceEquals(other, Nothing) Then
Return False
End If
Return DailyAmount = other.DailyAmount AndAlso
GraceDays = other.GraceDays
End Function
Public Overloads Overrides Function Equals(obj As Object) As Boolean
Return Equals(TryCast(obj, LateFeeRule))
End Function
Public Overrides Function GetHashCode() As Integer
Return HashCode.Combine(DailyAmount, GraceDays)
End Function
End Class
생성자는 0을 허용하고 음수만 거부한다. 일일 금액이 0인 정책이나 유예 일수가 0인 정책도 표현할 수 있다. Decimal의 수치 동등성과 그 해시 계약을 사용하므로 표시 소수 자릿수를 별도 식별 값으로 취급하지 않는다.
Dim ruleA As New LateFeeRule(200D, 2)
Dim ruleB As New LateFeeRule(200D, 2)
Dim ruleC As New LateFeeRule(200D, 3)
Check(ruleA.Equals(ruleB), "같은 연체료 규칙")
Check(ruleA.GetHashCode() = ruleB.GetHashCode(),
"같은 규칙의 해시")
Check(Not ruleA.Equals(ruleC), "다른 유예 일수")
ruleA와 ruleB는 두 속성이 모두 같으므로 같은 값이다. ruleC는 유예 일수가 달라 다른 값이다. 다른 값의 해시가 다르다는 검사는 추가하지 않는다. 실제 서비스의 검사 항목이 늘어나면 별도 테스트 프로젝트로 옮길 수 있지만, 여기서는 콘솔의 Check만으로 필요한 동작을 확인한다.