이 글에서는 .NET 4에 추가된 대표적인 immutable 타입인 Tuple 클래스를 직접 만들면서 immutable 타입의 장점에 대해 살펴보겠습니다.
Tuple
클래스는 여러가지로 유용한데, 대표적으로 C#은 리턴 값이 1개밖에 없기 때문에 2개 이상의 리턴 값을 돌려줄 때 사용합니다. 또한 IDictionary
나 ISet
에 2개 이상의 값을 키로 데이터를 저장할 때도 유용하게 사용할 수도 있습니다.
튜플은 2개, 3개, 4개, … n개의 원소로 만들 수 있지만, 설명을 쉽게 하기 위해 2개의 원소를 가지는 튜플만 작성하는 것으로 하겠습니다. 일단 immutability를 고려하지 않고 가장 간단한 방법으로 Tuple
클래스를 만들면 다음과 같습니다.
class Tuple<T1, T2>
{
public T1 Item1 { get; set; }
public T2 Item2 { get; set; }
public Tuple(T1 item1, T2 item2)
{
this.Item1 = item1;
this.Item2 = item2;
}
}
이 Tuple
클래스는 다음과 같이 사용할 수 있습니다.
var tuple = new Tuple<string, string>("Hello", "World");
Console.WriteLine("Item1: " + tuple.Item1);
Console.WriteLine("Item2: " + tuple.Item2);
튜플의 용법 중 하나가 IDictionary
나 ISet
에 넣을 때 키로 사용하는 것이기 때문에 GetHashCode
와Equals
메소드도 오버라이드하겠습니다.
public override int GetHashCode()
{
return Item1.GetHashCode() + Item2.GetHashCode();
}
public override bool Equals(object obj)
{
if (obj == null)
return false;
var tuple = obj as Tuple<T1, T2>;
if (tuple == null)
return false;
return Item1.Equals(tuple.Item1) && Item2.Equals(tuple.Item2);
}
이제 다음처럼 set
안에 튜플을 넣으면 해당 튜플이 있는지 Contains
메소드를 통해 쿼리하는 것이 가능해졌습니다.
var set = new HashSet<Tuple<string, string>>();
var tuple = new Tuple<string, string>("Hello", "World");
set.Add(tuple);
var result = set.Contains(new Tuple<string, string>("Hello", "World"));
Console.WriteLine("result: " + result); // Returns true
하지만 문제는 여기서부터 복잡해집니다. 우리가 구현은 Tuple
은 mutable한 타입이기 때문에 set
에 집어 넣은 후에 Item1
이나 Item2
의 값을 바꾸는 것이 가능합니다.
var set = new HashSet<Tuple<string, string>>();
var tuple = new Tuple<string, string>("Hello", "World");
set.Add(tuple);
tuple.Item1 = "Goodbye";
위 코드에서 Item1
의 값을 "Goodbye"
로 바꿨기 때문에 set
에 들어 있는 값도 ("Goodbye", "World")
가 됩니다. 하지만 실제로 Contains
를 이용해 쿼리를 해보면 예상과 달리 false
가 리턴됩니다.
var set = new HashSet<Tuple<string, string>>();
var tuple = new Tuple<string, string>("Hello", "World");
set.Add(tuple);
tuple.Item1 = "Goodbye";
var result = set.Contains(new Tuple<string, string>("Goodbye", "World"));
Console.WriteLine("result: " + result); // Now returns false
이런 문제가 생기는 이유는 tuple
생성 후 set
에 집어 넣을 때는 "Hello"
와 "World"
의 해시코드를 사용하고, Contains
를 이용해 쿼리를 할 때는 바뀐 값인 "Goodbye"
와 "World"
를 해시코드로 사용하기 때문입니다. HashSet
은 키의 해시코드를 이용해 해당 버킷을 찾는 방식이기 때문에 일단 HashSet
에 추가된 후에 키 값이 바뀌면 이 키에 해당하는 값은 영원히 찾지 못하게 되는 문제가 발생하게 됩니다.
서두에 mutable 타입을 사용하면 코드를 이해하기 어렵다고 이야기한 이유는 이런 이슈들을 깊이 있게 이해하고 문제가 발생하지 않게 코드를 작성해야 하기 때문입니다. 반대로 Tuple
을 immutable 클래스로 작성했다면 애시당초 키 값이 바뀌는 일 자체가 발생할 수 없기 때문에 올바른 코드를 작성하기 위해 신경 써야 할 일이 줄어듭니다.
자, 그럼 Tuple
을 immutable하게 만들려면 어떻게 해야 할까요? C#은 C, C++, Java 계열의 명령형 혹은 절차형 언어에서 출발하였기 때문에 immutable한 타입을 작성하는 것이 자연스럽지는 않습니다. 예를 들어, 필드의 디폴트 속성은 mutable이고, immutable하게 만들려면 반드시 readonly
나 const
를 붙여줘야 합니다. 또한 C# 6가 나오기 전에는 Auto-implemented 프로퍼티를 readonly
로 만드는 방법이 존재하지도 않았습니다.
여기서는 item1
, item2
백킹 필드(backing field)를 만들고 Item1
, Item2
프로퍼티에서 이 백킹 필드들의 값을 리턴하는 방식으로 구현해 보겠습니다.
class Tuple<T1, T2>
{
private readonly T1 item1;
private readonly T2 item2;
public T1 Item1 { get { return item1; } }
public T2 Item2 { get { return item2; } }
public Tuple(T1 item1, T2 item2)
{
this.item1 = item1;
this.item2 = item2;
}
public override int GetHashCode() { ... }
public override bool Equals(object obj) { ... }
}
이제 Item1
의 세터(setter)가 정의되지 않았으므로 문제를 일으켰던 다음 코드는 아에 컴파일이 안 되게 됩니다.
var set = new HashSet<Tuple<string, string>>();
var tuple = new Tuple<string, string>("Hello", "World");
set.Add(tuple);
tuple.Item1 = "Goodbye"; // Compile error
이것만으로 문제가 해결되었다고 할 수는 없습니다. 사이드 이펙트(side effect)가 문제가 되니 아에 사이드 이펙트를 원천 봉쇄하면 모든 문제는 해결되고 코드 이해도 쉬워지는 건 당연합니다. 하지만 원래 요구사항을 만족시키기 위해서는 어떤 튜플이 주어졌을 때 Item1
이나 Item2
의 값만 선택적으로 업데이트할 수 있어야 합니다. 다시 mutable 타입으로 돌아가지 않고 immutable한 타입에서 선택적인 업데이트를 어떻게 지원할 수 있을까요?
우리는 여기서 .NET의 대표적인 immutable 타입인 string
클래스의 디자인을 참고할 수 있습니다. 예를 들어, String.Replace 메소드는 인스턴의 지정된 문자열을 다른 문자열로 치환해 줍니다. 하지만 string
도 대표적으로 IDictionary
나 ISet
에 키로 쓰이는 타입이므로 mutable하게 문자열을 업데이트하면 앞서 살펴본 튜플과 똑같은 문제가 발생하게 됩니다. .NET이 선택한 해결책 문자열을 업데이트하는 대신에 해당 문자열이 치환된 새로운 string
의 인스턴스를 리턴하는 것입니다.
public string Replace(
string oldValue,
string newValue
)
우리가 작성한 Tuple
클래스도 같은 방법으로 문제를 해결할 수 있습니다. 다음과 같이 Item1
이나 Item2
가 바뀐 새로운 Tuple
을 리턴하는 메소드 WithItem1
과 WithItem2
를 정의하면 됩니다.
class Tuple<T1, T2>
{
...
public Tuple<T1, T2> WithItem1(T1 newItem1)
{
return new Tuple<T1, T2>(newItem1, item2);
}
public Tuple<T1, T2> WithItem2(T2 newItem2)
{
return new Tuple<T1, T2>(item1, newItem2);
}
...
}
WithItem1
메소드는 다음과 같이 사용할 수 있습니다. WithItem1
은 기존 튜플을 그대로 두고 Item1
의 값만 바뀐 새로운 튜플을 리턴하므로 전과 같이 이미 set
에 저장된 튜플의 키 값을 바꿀 우려가 없습니다. 또한 매번 새로운 튜플으로 직접 생성하는 대신 기존 튜플에서 원하는 필드 값만 바꿔서 새로운 튜플을 생성할 수 있게 되었습니다.
var set = new HashSet<Tuple<string, string>>();
var tuple = new Tuple<string, string>("Hello", "World");
set.Add(tuple.WithItem1("Goodbye"));
예제로 사용한 2개짜리 튜플은 무척 간단한 타입이므로 코드량이 늘어나는 것에 비해 immutability가 주는 장점이 상대적으로 작게 느껴질 수 있습니다. 하지만 조금 더 복잡한 타입을 다루면 immutability가 주는 장점을 좀 더 체감할 수 있을 것으로 기대합니다.
.NET 4.5에서는 아에 immutable 콜렉션을 제공합니다. ImmutableArray
, ImmutableDictionary
,ImmutableSortedDictionary
, ImmutableHashSet
, ImmutableList
, ImmutableQueue
,ImmutableSortedSet
, ImmutableStack
등 우리가 자주 사용하는 거의 모든 데이터 구조의 immutable 버전을 제공하고 있으니 확인해 보시기 바랍니다.
Pingback: 변경 불가능 타입 Tuple 만들기 | 서광열의 C# 스쿨