C# 6.0 완벽 가이드/ 컬렉션

Contents

  • 컬렉션에 관련된 .NET Framework의 형식들은 크게 다음 세 범주로 나뉜다.
    • 표준 컬렉션 프로토콜을 정의하는 인터페이스
    • 바로 사용할 수 있는 컬렉션 클래스
    • 응용 프로그램에 특화된 커스텀 컬렉션을 작성하는데 사용하는 기반 클래스
  • 컬렉션 이름 공간들은 다음과 같다.
이름공간 내용
System.Collections 비제네릭 컬렉션 클래스들과 인터페이스들
System.Collections.Specialized 강한 형식의 비제네릭 컬렉션 클래스들
System.Collections.Generic 제네릭 컬렉션 클래스들과 인터페이스들
System.Collections.ObjectModel 커스텀 컬렉션을 위한 프록시들과 기반 클래스들
System.Collections.Concurrent 스레드에 안전한 컬렉션들

 

열거

  • 컴퓨팅에는 배열이나 연결 목록 같은 간단한 자료구조에서부터 적흑 트리(red/black tree)나 해시테이블 같은 복잡한 것에 이르기까지 다양한 종류의 컬렉션이 쓰인다.
    • 이런 자료구조들의 내부 구현과 외부 특징은 아주 다양하지만, 컬렉션의 내용을 운행하는(traverse) 능력, 다시 말해 컬렉션에 담긴 요소들에 차례로 접근할 수 있는 기능을 제공해야 한다는 점은 거의 보편적이다.
    • .NET Framework는 이를 위해 한 쌍의 인터페이스(IEnumerable과 IEnumerator, 그리고 해당 제네릭 인터페이스들)를 제공한다.
    • 이들을 구현함으로써 내부 구현과 외부 특징이 서로 다른 자료구조들이라도 공통의 운행 API를 소비자에게 노출할 수 있다.

IEnumerable과 IEnumerator

  • 열거자를 대표하는 IEnumerator 인터페이스는 컬렉션의 요소들을 운행하는, 즉 열거하는 기본적인 방법을 규정하는 저수준 프로토콜들을 정의한다.
    • 이 인터페이스는 전진(forward) 운행만 지원한다.
public interface IEnumerator
{
  bool MoveNext();
  object Current { get; }
  void Reset();
}
  • MoveNext 메서드는 현재 요소를 가리키는 ‘커서’를 다음 위치로 옮긴다. 만일 컬렉션에 더 이상의 요소가 없으면 false를 돌려준다.
  • Current는 현재 위치의 요소를 돌려준다. (흔히 object에서 좀 더 구체적인 형식으로 캐스팅한다)
    • 첫 요소를 조회하기 전에 반드시 MoveNext를 호출해야 한다. 빈(empty) 컬렉션을 허용하려면 이러한 제약이 필요하다.
  • Reset 메서드는 커서를 다시 컬렉션의 시작 위치로 되돌린다. 그러면 컬렉션을 다시 연결할 수 있다.
    • Reset은 주로 COM과의 상호운용을 위해 존재하는 것이다. 모든 컬렉션이 이 메서드를 구현하지는 않으므로 이를 직접 호출하는 것을 피해야 한다.
  • 대체로 컬렉션들은 열거자를 구현하는 것이 아니라, IEnumerable 인터페이스를 통해서 열거자를 제공한다.
public interface IEnumerable
{
  IEnumerator GetEnumerator();
}
  • 열거자를 돌려주는 메서드 하나를 정의하는 IEnumerable 인터페이스 덕분에 코드의 열거 논리를 다른 클래스에 맡길 수 있다는 유연성이 생긴다.
    • 더 나아가서 여러 소비자가 하나의 컬렉션을 서로 간섭하지 않고 열거할 수 있는 능력도 생긴다.
    • IEnumeratorProvider 라고 불러도 좋을 이 IEnumerable은 컬렉션 클래스들이 구현하는 가장 기본적인 인터페이스이다.
  • 다음은 IEnumerable과 IEnumerator의 저수준 용법을 보여주는 예이다.
string s = "Hello";

// string은 IEnumerable을 구현하므로 GetEnumerator를 호출할 수 있다.
IEnumerator rator = s.GetEnumerator();

while (rator.MoveNext())
{
  char c = (char) rator.Current;
  Console.Write(c + ".");
}

// output: H.e.l.l.o.

// 보통은 열거자의 메서드를 직접 호출하지 않고 foreach문을 사용한다.
foreach(char c in s)
  Console.Write(c + ".");

IEnumerable<T>와 IEumerator<T>

  • IEnumerator와  IEnumerable은 거의 항상 그에 해당하는 확장된 제네릭 버전들과 연계해서 구현된다.
    • 이 인터페이스들은 Current와  GetEnumerator의 형식 있는 버전들을 정의한다.
    • 이 덕분에 정적 형식 안정성이 강해지고, 값 형식 요소의 박싱 비용을 피할 수 있으며, 소비자가 사용하기에도 편하다. 배열은 자동으로 IEnumerable<T>를 구현한다.
public interface IEnumerator<T> : IEnumerator, IDisposable
{
  T Current { get; }
}

public interface IEnumerable<T> : IEnumerable
{
  IEnumerator<T> GetEnumerator();
}
  • 컬렉션 클래스들에서는 IEnumerable<T>는 공용으로 노출하되 비제네릭 IEnumerable은 명시적 인터페이스 구현을 통해서 ‘숨기는’ 것이 표준적인 관행이다.
    • 이 관행에 따라 작성된 컬렉션에 대해 GetEnumerator를 직접 호출하면 형식에 안전한 IEnumerator<T>가 반환된다.

IEnumerable<T>와 IDisposable

  • IEnumerator<T>는 IDisposable을 상속한다. 데이터베이스 연결 같은 자원에 대한 참조들을 열거자를 이용해서 열거할 때, 열거가 완료되면 그 자원들을 자동으로 해제된다.
    • foreach 문도 이러한 의미론을 지원한다. 컴파일러는 아래의 코드를 다음과 같은 코드로 바꾸어서 컴파일한다.
    • using 블록은 삭제를 보장한다.
// 컴파일러는 이 코드를
foreach (var element in somethingEnumerable) { ... }

// 이렇게 바꾸어서 컴파일한다.
using (var rator = somthingEnumerable.GetEnumerator())
{
  whie (rator.MoveNext())
  {
    var element = rator.Current;
    ...
  }
}

열거 인터페이스들의 구현

  • 커스텀 형식을 작성할 때, 다음 이유 중 하나 이상에 해당한다면 IEnumerable이나 IEnumerable<T>를 구현하는 것이 바람직하다.
    • foreach 문을 지원한다.
    • 표준 컬렉션을 지원하는 임의의 코드 요소와 연동한다.
    • 좀 더 정교한 컬렉션 인터페이스의 요구조건들을 만족한다.
    • 컬렉션 초기치 구문을 지원한다.
  • IEnumerable/ IEnumerable<T>를 구현하려면 반드시 열거자를 제공해야 한다. 그 방법은 다음 3가지 이다.
    • 다른 컬렉션을 감싸는 래퍼(wrapper) 클래스라면, 감싼 컬렉션의 열거자를 돌려준다.
    • yield return을 이용하는 반복자를 통해서 열거자를 제공한다.
    • 직접 구현한 IEnumerator/IEnumerator<T>의 인스턴스를 돌려준다.
  • 커스텀 형식이 감싸고 있는 내부 컬렉션의 열거자를 돌려주는 것은 간단하다. 내부 컬렉션의 GetEnumerator 메서드를 호출하면 된다.
    • 그러나 이런 접근방식은 내부 컬렉션의 항목들을 그대로 열거하는 아주 간단한 시나리오에만 적합하다.
    • 좀 더 유연한 접근 방식은 C#의 yield return 문을 사용해서 반복자를 작성하는 것이다.
    • 반복자(iterator)는 C# 언어의 한 기능으로 foreach 문이 컬렉션을 소비할 때 유용한 것과 마찬가지 방식으로, 반복자는 컬렉션을 작성할 때 유용하다.
    • 반복자는 IEnumerable과 IEnumerator 구현을 자동으로 처리해 준다.
public class MyCollection : IEnumerable
{
  int[] data = { 1, 2, 3 };

  public IEnumerator GetEnumerator()
  {
    foreach (int i in data)
      yield return i;
  }
}
  • GetEnumerator가 열거자를 돌려주지 않는다는 점에 주목하기 바란다. 여기에는 반복자의 마법이 숨어있다.
    • C# 컴파일러는 yield return 문을 만나면 숨겨진 내부 열거자 클래스를 즉석에서 작성하고, GetEnumerator가 그 클래스의 인스턴스를 생성해서 돌려주는 코드르르 삽입한다.
    • 반복자는 강력하고도 간단하다. 또한 LINQ to Object의 표준 질의 연산자들을 구현하는데 많이 쓰인다.
  • 제네릭 인터페이스 IEnumerable<T>도 이런 방식으로 구현할 수 있다.
public class MyCollection : IEnumerable<int>
{
  int[] data = { 1, 2, 3 };

  public IEnumerator<int> GetEnumerator()
  {
    foreach (int i in data)
      yield return i;
  }

  IEnumerator IEnumerable.GetEnumerator()
  {
    return GetEnumerator();
  }
}
  • IEnumerable<T>는 IEnumerable을 상속하므로 GetEnumerator의 제네릭 버전과 비제네릭 버전을 모두 구현해야 한다.
    • 이 예제는 표준 관행에 따라 비제네릭 버전을 명시적으로 구현했다. IEnumerator<T>가 IEnumerator를 상속하므로 이처럼 그냥 GetEnumerator를 호출하기만 하면 된다.
  • 방금작성한 클래스는 좀 더 정교한 컬렉션 클래스를 작성하는데 사용할 기반 클래스로 적합할 것이다.
    • 그러나 그냥 간단한 IEnumerable<T> 구현만으로 충분하다면 yield return 문을 조금 변형하는 것으로도 원하는 결과를 얻을 수 있다.
    • 컬렉션 클래스를 따로 작성하는 대신, 반복 논리를 제네릭 IEnumerable<T>를 돌려주는 메서드로 옮기고 나머지는 컴파일러에 맡기면 된다.
public class Test
{
  public static IEnumerable<int> GetSomeIntegers()
  {
    yield return 1;
    yield return 2;
    yield return 3;
  }
}

foreach (int i in Test.GetSomeIntegers())
{
  Console.WriteLine(i);
}

// output: 1 2 3
  • GetEnumerator를 작성하는 마지막 접근방식은 IEnumerator를 직접 구현하는 클래스를 작성하는 것이다. 반복자를 사용하는 코드를 컴파일할 때 컴파일러가 내부적으로 사용하는 방식이 바로 이것이다. (그러나 독자가 직접 이런 방식을 사용해야 하는 경우는 드물다)
    • (이하 코드 생략)

ICollection 인터페이스와 IList 인터페이스

  • 열거 인터페이스들은 컬렉션의 전진 전용 반복(운행)을 위한 프로토콜을 제공할 뿐, 컬렉션 크기 조회나 색인을 이용한 요소 접근, 컬렉션 검색이나 수정 같은 작업을 위한 메커니즘은 제공하지 않는다.
    • 그런 기능성을 위해 .NET Framework에는 ICollection과 IList, IDictionary라는 인터페이스들이 정의되어 있다.
    • 인터페이스마다 제네릭 버전과 비제네릭 버전이 있는데, 대부분의 경우 비제네릭 버전은 구식 코드를 지원하기 위해 존재하는 것일 뿐이다.
    • 이 인터페이스 중 어떤 것이라도 독자가 직접 구현해야 하는 경우는 드물다. 거의 모든 겨웅에서 독자가 컬렉션 클래스를 작성할 때는 그냥 Collection<T>를 상속하면 된다.
  • 제네릭 버전과 비제네릭 버전에는 독자가 예상한 것 이상의 차이가 있다. 특히 ICollection의 경우가 그렇다. 이런 차이가 생긴 이유는 대부분 역사적이다.
    • C#에 제네릭이 나중에 추가되었기 때문에 .NET 개발팀은 기존의 경험에 기초해서 제네릭 인터페이스들을 좀 더 잘 개발할 수 있었다. 특히 이전과는 다른 멤버들을 선택했다. ICollection<T>가 ICollection을 확장(상속)하지 않는 것은 이 때문이다.
    • 마찬가지 이유로 IList<T>는 IList를 확장하지 않으며, IDictionary<TKey, TValue>는 IDictionary를 확장하지 않는다.
    • 물론 컬렉션 클래스 자체는 한 인터페이스의 두 버전을 모두 구현해도 된다.

ICollection<T> 인터페이스와 ICollection 인터페이스

  • ICollection<T>는 가산(countable) 컬렉션을 위한 표준 인터페이스이다.
    • 이 인터페이스는 컬렉션의 크기를 파악하는 기능(Count 속성)과 주어진 항목이 컬렉션이 존재하는지 판단하는 기능(Contains), 컬렉션을 배열로 복사하는 기능(ToArray), 읽기 전용 여부를 판단하는 기능 (IsReadOnly) 등을 제공한다.
    • 쓰기 가능 컬렉션의 경우에는 컬렉션에 항목을 추가하거나(Add), 컬렉션에서 항목을 제거하거나(Remove), 컬렉션을 아예 비우는(Clear) 기능도 제공한다.
    • 그리고 이 인터페이스는 IEnumerable<T>를 확장하므로 foreach 문을 이용해서 요소들을 훑는 기능도 제공한다.
public interface ICollection<T> : IEnumerable<T>, IEnumerable
{
  int Count { get; }

  bool Contains (T item);
  void CopyTo (T[] array, int arrayIndex);
  bool IsReadOnly { get; }
  
  void Add(T item);
  bool Remove (T item);
  void Clear();
}
  • 이와 비슷하게 비제네릭 ICollection도 가산 컬렉션 기능을 제공하지만 목록을 변경하거나 요소의 존재 여부를 판정하는 등의 추가 기능은 제공하지 않는다.
    • 비제네릭 인터페이스의 특징이라면 동기화를 돕는 속성들이 정의되어 있다는 것이다. 제네릭 버전들에서는 이 속성들이 폐기되었는데, 스레드 안전성이라는 것이 컬렉션 자체가 제공해야 할 무엇은 아니라는 깨달음 때문이다.
public interface ICollection : IEnumerable
{
  int Count { get; }
  bool IsSynchronized { get; }
  object SyncRoot { get; }
  void CopyTo (T[] array, int arrayIndex);
}
  • 보통의 경우 이 인터페이스들은 IList나 IDictionary 인터페이스와 함께 구현한다.

IList<T> 인터페이스와 IList 인터페이스

  • IList<T>는 특정 위치에 있는 요소에 접근하는 기능을 제공하는 컬렉션을 위한 표준 인터페이스이다. ICollection<T>와 IEnumerable<T>에서 물려받은 기능성 외에, 이 인터페이스는 특정 위치에 있는 요소를 읽고 쓰는 기능과 특정 위치에 요소를 삽입하거나 제거하는 기능을 제공한다.
    • IndexOf 메서드는 선형 검색(linear search)을 이용해서 목록의 특정 요소에 접근한다. 만일 지정된 요소를 찾지 못하면 -1을 돌려준다.
public interface IList<T> : ICollectoin<T>, IEnumerable<T>, IEnumerable
{
  T this [int index] { get; set; }
  int IndexOf (T item);
  void Insert (int index, T item);
  void RemoveAt (int index);
}
  • 비제네릭 IList는 더 많은 멤버를 정의하는데, 이는 ICollection에서 물려 받은 멤버가 적기 때문이다.
    • 비제네릭 IList 인터페이스의 Add 메서드는 정수를 돌려준다. 그 정수는 새로 추가된 항목의 색인이다. 반면 ICollection<T>의 Add 메서드는 반환 형식이 void 이다.
public interface IList : ICollectoin, IEnumerable
{
  object this [int index] { get; set; }
  bool IsFixedSize { get; }
  bool IsReadOnly { get; }
  int Add (object value);
  void Clear();
  bool Contains (object value);
  int IndexOf (object value);
  void Insert (int index, object value);
  void Remove (object value);
  void RemoveAt (int index);
}
  • 범용 List<T> 클래스는 IList<T>와 IList의 최소한의 구현에 해당한다. C# 배열도 IList의 제네릭 버전과 비제네릭 버전을 모두 구현한다.

IReadOnlyList<T> 인터페이스

  • 읽기 전용 WinRT 컬렉션들과의 상호운용성을 위해 .NET Framework 4.5에는 IReadOnlyList<T>라는 새로운 컬렉션 인터페이스가 도입되었다.
    • 이 인터페이스는 그런 컬렉션을 지원하는데 유용할 뿐만 아니라, 그 자체로도 유용하다. 목록에 대한 읽기 전용 연산들에 필요한 멤버들만 노출하는 IList<T>의 축소 버전이라 생각하면 될 것이다.
public interface IReadOnlyList<out T> : IEnumerable<T>, IEnumerable
{
  int Count { get; }
  T this[int index] { get; }
}
  • 형식 매개변수들이 출력 위치에만 쓰이므로, 이 인터페이스에는 공변이다. 그래서 이를테면 고양이들의 목록을 동물들의 읽기 전용 목록으로 취급할 수 있다.
    • 반면 IList<T>의 T는 출력 위치 뿐만 아니라 입력 위치에도 쓰이므로 공변이 아니다.
  • 논리적으로는 IList<T>가 IReadOnlyList<T>를 상속해야 마땅하다. 그러나 실제로 그렇게 하려면 IList<T>의 멤버들을 IReadOnlyList<T>로 옮겨야 하는데, 그러면 CLR 4.5에 대한 하위호환성이 깨진다.
    • 그래서 Microsoft는 그런 상속을 포기하고 IList<T>를 구현하는 클래스를 작성할 때 반드시 IReadOnlyList<T>를 클래스가 구현하는 인터페이스 목록에 포함해야 한다는 제약을 추가했다.
  • IReadOnlyList<T>에 대응되는 WinRT의 인터페잇느느IVectorView<T>이다.

Array 클래스

  • Array 클래스는 모든 배열의 암묵적인 기반 클래스이며, 표준 컬렉션 인터페이스를 구현하는 가장 근본적인 형식 중 하나이다.
    • Array 클래스는 형식 통합을 제공한다. 이 덕분에 배여려 선언 방식이나 원소 형식과 무관하게 모든 배열에는 공통의 메서드들이 존재한다.
  • 배열이 아주 근본적인 자료구조이기 때문에, C#에는 배열의 선언과 초기화를 위한 특별한 구문이 존재한다.
    • C#의 구문으로 선언된 배열에 대해 CLR은 내부적으로 Array 클래스의 파생 클래스를 만들어 낸다. 즉, CLR은 배열의 차원들과 원소 형식에 적합한 유사 형식(pseudotype)을 합성해 낸다. 이 유사 형식은 IList<string> 같은 형식 있는 제네릭 컬렉션 인터페이스를 구현한다.
  • 배열은 생성 시점에서도 특별한 취급을 받는다. CLR은 배열에 대해 메모리의 연속된 공간을 할당한다.
    • 이 덕분에 색인으로 배열에 접근하는 속도가 아주 빠르다. 대신 일단 생성된 배열의 크기는 변경하지 못한다.
  • Array는 IList<T>까지의 컬렉션 인터페이스들을 구현한다.
    • 단 IList<T> 자체는 명시적으로 구현한다. 이는 Array의 공용 인터페이스들에 Add나 Remove 같은 메서드가 포함되지 않게 하기 위한 것이다. 그런 메서드들은 배열 같은 고정 길이 컬렉션에 대해 호출되었을 때 예외를 던지도록 구현되어 있다.
  • 사실 Array는 정적 Resize 메서드를 제공하지만, 이 메서드는 기존 배열의 크기를 변경하는 것이 아니라 새 배열을 생성한 후 기존 원소들을 새 배열에 복사한다.
    • 이는 비효율적이다. 게다가 프로그램의 어딘가에서 배열을 가리키던 참조들은 여전히 기존 배열을 가리키므로 문제가 발생한다.
    • 크기를 변경할 수 있는 컬렉션을 원한다면 List<T> 클래스를 사용하는 것이 더 나은 방법이다.
  • 배열은 값 형식 원소들을 담을 수도 있고, 참조 형식 원소들을 담을 수도 있다.
    • 값 형식 원소들은 배열 자체에 저장된다. 따라서 긴 정수(8바이트 정수) 세 개를 담은 배열은 연속된 메모리 24바이트를 차지한다.
    • 반면 참조 형식의 원소는 배열 안에서 참조 하나 크기 (32비트 환경에서는 4바이트, 64비트 환경에서는 8바이트)의 공간만 차지한다.
    • 다음 예는 배열들이 메모리 안에 저장되는 방식을 나타낸 것이다.
StringBuilder[] builders = new StringBuilder[5];
builders[0] = new StringBuilder("builder1");
builders[1] = new StringBuilder("builder2");
builders[2] = new StringBuilder("builder3");

long[] numbers = new long[3];
numbers[0] = 12345;
numbers[1] = 54321;

  • Array는 클래스이므로, 배열 원소의 형식이 어떻든 배열 자체는 항상 참조 형식이다.
    • 따라서 arrayB = arrayA 같은 배정문이 실행되면 두 변수가 같은 배열을 참조하게 된다.
    • 또한 서로 다른 두 배열의 상등 판정은 항상 거짓으로 평가 된다.
  • 물론 커스텀 상등 비교자를 사용한다면 다른 결과가 나올 수 있다.
    • .NET Framework 4.0에서는 배열이나 튜플의 상등을 그 원소들을 비교해서 판정하는 커스텀 상등 비교자 하나가 도입되었다.
    • 아래 예에서 보듯이 StructuralComparisons 형식을 통해서 그 상등 비교자에 접근할 수 있다.
object[] a1 = { "string", 123, true };
object[] a2 = { "string", 123, true };

Console.WriteLine(a1 == a2);  // false
Console.WriteLine(a1.Equals(a2));  // false

IStructuralEquatable se1 = a1;
Console.WriteLine(se1.Equals(a2, StructuralComparisons.StructuralEqualityComparer));  // true
  • 배열을 복제하는 한 가지 방법은 arrayB = arrayA.Clone() 처럼 Clone 메서드를 사용하는 것이다.
    • 그러나 이 메서드는 얕은 복제(shallow clone) 또는 얕은 복사(shallow copy)를 수행한다. 즉 배열에 할당된 메모리만 복사한다.
    • 값 형식 원소들을 담은 배열이면 그 원소들도 복사되므로 배열 전체를 복제하는 결과가 나오지만, 참조 형식 원소들을 담은 배열은 그냥 참조들만 복사될 뿐 그 참조가 가리키는 객체들은 복제되지 않는다.
    • 아래 그림은 다음과 같은 코드가 추가로 실행되었을 때의 메모리 구성을 나타낸 것이다.
StringBuilder[] builders2 = builders;
StringBuilder[] shallowClone = (StringBuilder[])builders.Clone();

  • 깊은 복사본 (deep copy)을 얻으려면, 즉 참조들이 가리키는 객체들도 복제하려면 직접 배열을 훑으면서 각 원소를 실제로 복제해야 한다.
    • 이는 다른 .NET 컬렉션 형식들에서도 마찬가지이다.
  • Array는 기본적으로 32비트 인덱서와 함께 사용되도록 설계된 것이지만, 몇몇 메서드는 Int32와 Int64 매개변수를 받는 중복적재 버전들을 제공한다. 즉, Array는 64비트 인덱서도 제한적이나마 지원한다.
    • 그러나 이러한 중복적재 버전들은 현실적으로 쓸모가 없다. 어차피 CLR은 2GB를 넘는 객체(배열도 포함된다)를 허용하지 않기 때문이다. 이는 32비트 환경 뿐만 아니라 64비트 환경에서도 동일하다.

배열의 생성과 색인 접근

  • 배열을 생성하고 색인으로 특정 원소에 접근하는 가장 간단한 방법은 C# 언어 자체의 구문을 이용하는 것이다.
  • 물론 Array.CreateInstance를 호출해서 동적으로 배열 인스턴스를 생성할 수도 있다.
    • 이렇게 하면 배열의 원소 형식과 차수(rank; 차원 수)를 실행시점에서 지정할 수 있다.
    • 또한 배열의 하계(lower bound)를 지정함으로써 첫 색인이 0이 아닌 정수에서 시작하는 배열을 만들 수도 있다.
    • 단 첫 색인이 0이 아닌 배열은 CLS(Common Language Specification: 공용 언어 사양)를 만족하지 않는다.
  • 동적으로 생성된 배열의 원소에 접근하는 수단으로는 GetValue 메서드와 SetValue 메서드가 있다.
// 길이가 2인 문자열 배열을 생성한다.
Array a = Array.CreateInstance(typeof(string), 2);
a.SetValue("hi", 0);
a.SetValue("threre", 1);
string s = (string) a.GetValue(0);

// 동적으로 생성한 배열을 C# 배열로 캐스팅하는 것도 가능하다.
string[] cSharpArray = (string[])a;
string s2 = cSharpArray[0];
  • 동적으로 생성한 0-색인 배열(zero-indexed array; 첫 색인이 0인 배열)은 그와 부합 또는 호환되는 형식의 C# 배열로 캐스팅할 수 있다. (호환 여부는 표준적인 배열 가변성(array-variance) 규칙들을 따른다.)
    • 예컨대 Apple이 Fruit의 파생 클래스이면 Apple[] 을 Fruit[]로 캐스팅할 수 있다.
  • 그렇다면 Array 클래스가 아니라 object[]를 배열 형식들의 통합을 위한 기반 형식으로 사용하지 않는 이유가 무엇인지 궁금할 것이다.
    • 그 답은 object[]는 다차원 배열이나 값 형식 배열과 (그리고 물론 첫 색인이 0이 아닌 배열과도) 호환되지 않는다는 것이다.
    • int[]를 object[]로 캐스팅할 수는 없다. 이때문에 완전한 형식 통합을 위해서 Array라는 클래스가 필요하다.
  • GetValue 와 SetValue는 컴파일러가 생성한 배열에도 사용할 수 있다.
    • 이 메서드들은 임의의 형식과 차수의 배열을 다루는 메서드를 작성할 때 유용하다.
    • 다차원 배열의 경우에는 인덱서들의 배열을 받는 버전을 사용하면 된다.
public object GetValue (params int[] indices)
public void SetValue (object value, params int[] indices)
  • 다음 메서드는 임의의 (차수와 무관하게) 배열의 첫 원소를 출력한다.
    • SetValue는 배열의 원소 형식과 호환되지 않는 형식의 입력이 주어지면 예외를 던진다.
    • 형식은 모르지만 차수는 알고 있는 배열을 다루어야 한다면 제네릭을 이용하면 더 간단하고 효율적이다.
void WriteFirstValue (Array a)
{
  Console.Write(a.Rank + "차원 배열; ");

  // indexers 배열의 모든 원소는 자동으로 0으로 초기화된다.
  // 따라서 이 배열을 GetValue나 SetValue에 넘겨주면 배열의 0번 원소가 조회 또는 설정 된다.
  int[] indexer = new int[a.Rank];
  Console.WirteLine("첫 값은 " + a.GetValue(indexers));
}

void Demo()
{
  int[] oneD = { 1, 2, 3 };
  int[,] twoD = { {5, 6}, {8, 9} };

  WriteFirstValue(oneD);  // 1차원 배열; 첫 값은 1
  WriteFirstValue(twoD);  // 2차원 배열; 첫 값은 5
}
  • C# 구문을 사용하든 아니면 Array.CreateInstance를 사용하든, 배열을 인스턴스화 하면 그 원소들이 자동으로 초기화된다.
    • 원소가 참조 형식이면 모든 원소는 null로 설정되고, 값 형식이면 원소마다 해당 값 형식의 기본 생성자가 호출된다.
    • Array 클래스는 이미 생성된 배열의 모든 원소를 그런 식으로 초기화하는 메서드를 제공하는데, 바로 Clear이다.
    • 이 메서드는 배열의 크기에는 영향을 미치지 않는다. 이는 컬렉션의 요소 개수를 0으로 줄이는 통상적인 Clear 메서드들과는 다른 면이다.

열거

  • foreach 문을 이용하면 간단하게 배열의 모든 원소를 열거할 수 있다.

길이와 차수

  • 다음은 길이와 차수의 조회를 위해 Array가 제공하는 메서드들과 속성들이다.
    • GetLength와 GetLongLength는 주어진 차원의 길이를 돌려주고, Length와 LongLength는 배열의 모든 원소의 개수를 돌려준다.
    • GetLowerBound와 GetUpperBound는 첫 색인이 0이 아닌 배열에 유용하다. GetUpperBound는 주어진 차원의 GetLowerBound와 GetLength를 더한 결과를 돌려준다.
public int GetLength (int dimension);
public long GetLongLength (int dimension);

public int Length { get; }
public long LongLength { get; }

public int GetLowerBound (int dimension);
public int GetUpperBound (int dimension);

public int Rank { get; }  // 배열의 차원 개수를 돌려준다.

검색

  • Array 클래스는 1차원 배열 안에서 특정 원소들을 찾기 위한 여러 메서드들을 제공하는데, 크게 다음 3 부류로 나뉜다.
    • BinarySearch 메서드들
      • 정렬된 배열에서 특정 원소 하나를 빠르게 찾는다.
    • IndexOf/ LastIndex 메서드들
      • 정렬되지 않은 배열에서 특정 원소 하나를 찾는다.
    • Find/ FindLast/ FindIndex/ FindLastIndex/ FindAll/ Exists/ TrueForAll
      • 정렬되지 않은 배열에서 주어진 Predicate<T>를 만족하는 원소들을 찾는다.
  • 이러한 배여려 검색 메서드들 중 요청된 값을 찾지 못했을 때 예외를 던지는 것은 하나도 없다.
    • 그런 경우 반환 형식이 정수인 메서드들은 -1을 돌려주고(첫 색인이 0이라고 가정할 때), 반환 형식이 제네릭인 메서드들은 해당 형식의 기본값(int인 경우 0, string인 경우 null)을 돌려준다.
  • 이진 검색 메서드(여러 BinarySearch들)는 빠르긴 하지만 정렬된 배열에만 작동하며, 배열 원소들에 대해 단순한 상등 비교가 아니라 순서 비교가 가능해야 한다는 제약이 있다.
    • 다행히 이진 검색 메서드들 중에는 원소들의 순서 비교를 행해주는 비교자(ICompare나 ICompare<T> 객체)를 받는 버전이 있으므로, 원소 형식 자체가 순서 비교를 지원하지 않아도 이진 검색이 가능하다. 단, 그 비교자는 애초에 배열을 정렬할 때 사용했던 것과 같은 방식으로 순서를 비교하는 것이어야 한다.
    • 비교자를 제공하지 않으면 이진 검색 메서드는 원소 형식의 기본 순서 비교 알고리즘을 적용한다.
  • IndexOf 메서드와 LastIndexOf 메서드는 그냥 배열의 원소들을 차례로 훑으면서 주어진 값과 부합하는 (match) 첫 (또는 마지막) 원소의 색인을 돌려준다.
  • Predicate<T>를 받는 검색 메서드들은 주어진 원소가 원하는 값과 부합하는지의 여부를 주어진 술어(predicate)를 이용해서 판정한다.
    • 술어는 그냥 객체 하나를 받고 true나 false를 돌려주는 대리자이다.
  • FindAll 메서드는 주어진 술어를 만족하는 모든 원소를 담은 배열을 돌려준다.
    • 사실 이 메서드는 System.Linq 이름공간의 Enumerable.Where와 같은 일을 한다.
    • 단 FindAll은 부합하는 항목들의 배열을 돌려주지만, Enumerable.Where는 그런 항목들의 IEnumerable<T>를 돌려준다.
  • Exist는 주어진 술어를 만족하는 원소가 하나라도 있으면 true를 돌려준다.
    • System.Linq.Enumerable의 Any에 해당한다.
  • TrueForAll은 주어진 술어를 배열의 모든 원소가 만족하면 true를 돌려준다.
    • System.Linq.Enumerable의 All에 해당한다.

정렬

  • 다음은 Array의 정렬(sorting) 메서드들이다.
// 배열 하나를 정렬하는 메서드들
public static void Sort<T> (T[] array);
public static void Sort (Array array);

// 한 쌍의 배열들을 정렬하는 메서드들
public static void Sort<TKey, TValue> (TKey[] keys, TValue[] items);
public static void Sort (Array keys, Array items);
  • 각 메서드마다 다음과 같은 매개변수를 받는 중복적재 버전들이 있다.
int index  // 정렬을 시작할 색인
int length  // 정렬할 원소 개수
IComparer<T> comparer  // 순서를 결정하는 객체
Comparison<T> comparison  // 순서를 결정하는 대리자
  • 다음 예제는 Sort의 가장 간단한 용법을 보여준다.
int[] numbers = { 3, 2, 1 };
Array.Sort(numbers);  // numbers 배열은 이제 { 1, 2, 3 }
  • 배열 쌍을 받는 메서드들은 첫 배열을 정렬하면서 원소들의 위치 변경을 둘째 배열의 원소들에 그대로 적용한다.
    • 다음 예는 수사들의 배열을 실체 수치들의 순서대로 정렬하는 예이다.
int[] numbers = { 3, 2, 1 };
string[] words = { "셋", "둘", "하나" };

Array.Sort(numbers, words);  

// numbers 배열은 이제 { 1, 2, 3 }
// words 배열은 이제 { "하나", "둘", "셋" }
  • 이상의 Array.Sort 메서드들에는 배열 원소 형식이 IComparable 인터페이스를 구현해야 한다는 제약이 있다. 따라서 대부분의 C# 형식을 이 메서드로 정렬할 수 있다.
  • 그렇지 않은 형식의 원소들이라면, 또는 기본 순서 비교 방식 이외의 방식으로 배열을 정렬하고 싶다면, Sort를 호출할 때 두 원소의 상대적 위치 관계를 알려주는 커스텀 Comparison 공급자도 지정해야 한다. 그러한 공급자를 지정하는 방법은 2가지이다.
    • IComparer/ IComparer<T>를 구현하는 보조 객체를 사용한다.
    • 다음과 같은 서명의 Comparison 대리자를 사용한다.
public delegate int Comparison<T> (T x, T y);
  • Comparison 대리자의 작동 방식은 IComparer<T>.CompareTo와 같다. 즉, 만일 x가 y보다 앞이어야 하면 음의 정수를 돌려주고, x가 y보다 뒤여야 하면 양의 정수를, x와 y가 같은 위치여야 하면 0을 돌려준다.
    • 다음은 홀수들이 앞에 오도록 정수들을 정렬하는 예이다.
int[] numbers = { 1, 2, 3, 4, 5 };
Array.Sort(numbers, (x, y) => x % 2 == y % 2 ? 0 : x % 2 == 1 ? -1 : 1);

// 이제 numbers 배열은 { 1, 3, 5, 2, 4 }
  • Sort를 호출하는 대신 LINQ의 OrderBy나 ThenBy 연산자를 이용해서 배열을 정렬할 수도 있다. Array.Sort와는 달리 LINQ의 연산자들은 원래의 배열을 변경하지 않는다. 대신 정렬된 결과를 담은 새로운 IEnumerable<T> 순차열을 돌려준다.

원소 순서 뒤집기

  • 다음의 Array 메서드들은 배열의 모든 원소 또는 일부분의 순서를 뒤집는다.
public static void Reverse (Array array);
public static void Reverse (Array array, int index, int length);

복사

  • Array에는 얕은 복사(shallow copy)를 수행하는 메서드들이 있다. 바로 Clone, CopyTo, Copy, ConstrainedCopy이다. 앞의 둘은 인스턴스 메서드이고 나머지 둘은 정적 메서드이다.
    • Clone 메서드는 얕게 복사된, 완전히 새로운 배열을 돌려준다.
    • CopyTo와 Copy 메서드는 배열의 연속된 일부분을 복사한다.
      • 다차원 사각형 배열을 복사하려면 다차원 색인을 선형 색인으로 사상(mapping) 해야 한다.
      • 예를들어 3×3 배열의 정중앙 원소 (1, 1)의 선형 색인은 4(= 1 x 3 + 1)이다. 복사시 원본 영역과 대상 영역이 겹쳐도 문제가 되지 않는다.
    • ConstrainedCopy 메서드는 원자저(atomic) 연산을 수행한다. 즉, 요청된 원소들을 모두 성공적으로 복사한 경우에만 복사가 완료되며, 중간에 문제가 생기면 복사 연산 전체가 철회(roll back) 된다.
  • Array는 또한 AsReadOnly라는 메서드도 제공하는데, 이 메서드는 원소 재배정이 금지된 래퍼(wrapper)를 돌려준다.

형식 변환과 크기 변경

  • Array.ConvertAll 메서드는 모든 원소를 다른 형식으로 변환해서 복사해서 만든 새로운 배열을 돌려준다.
    • 형식 변환은 둘째 인수로 주어진 Converter 대리자가 담당한다.
    • Converter 대리자의 서명은 다음과 같은데, TOutput이 새 원소 형식이다.
public delegate TOutput Converter<TInput, TOutput> (TInput input)
  • 다음은 float 배열을 int 배열로 변환하는 예이다.
float[] reals = { 1.3f, 1.5f, 1.8f };
int[] wholes = Array.ConvertAll (reals, r => Convert.ToInt32(r));
// wholes 배열은 { 1, 2, 2 }
  • 배열의 크기를 변경하는 Resize 메서드는 주어진 크기의 배열을 새로 생성해서 기존 원소들을 새 배열에 복사하고 새 배열을 참조 매개변수를 통해서 돌려준다.
    • 단 다른 객체에 있던 기존 배열 원소에 대한 참조는 변경되지 않으므로 주의가 필요하다.
  • System.Linq 이름공간에는 배열 형식 변환에 적합한 다양한 확장 메서드들이 정의되어 있다. 이 메서드들은 IEnumerable<T>를 돌려주는데, 필요하다면 Enumerable의 ToArray 메서드를 이용해서 다시 배열로 변환할 수 있다.

목록, 대기열, 스택, 집합

List<T> 클래스와  ArrayList 클래스

  • 컬렉션 클래스 중 가장 자주 쓰이는 제네릭 List 클래스와 비제네릭 ArrayList 클래스는 크기를 동적으로 변경할 수 있는 배열을 나타낸다.
    • ArrayList는 IList를 구현하는 반면, List<T>는 IList와 IList<T>를 모두 구현한다. 또한 새로운 읽기 전용 버전인 IReadOnlyList<T>도 구현한다.
    • 보통의 배열과 다른 점은 모든 인터페이스를 공용으로 구현한다는 것과 Add나 Remove 같은 메서드들도 제공한다는 것이다.
  • List<T>와 ArrayList는 내부적으로 객체들의 배열을 관리한다. 만일 용량을 늘려야 하면 그 배열을 더 큰 배열로 대체한다.
    • 요소를 추가하는 것은 효율적이지만 (대부분의 경우 끝에 빈칸이 남아 있으므로) 중간에 요소를 삽입하는 것은 느릴 수 있다 (삽입할 칸을 마련하려면 그 위치 이후의 모든 요소를 이동해야 하므로).
    • 정렬 성능 특성은 배열과 비슷하다. 정렬된 목록에 대해 BinarySearch 메서드를 이요하는 것은 효율적이지만, 그렇지 않은 방법은 개별 항목을 일일이 점검해야 하므로 느리다.
    • T가 값 형식이면 List<T>가 ArrayList보다 몇 배 빠르다. 그런 경우 List<T>는 요소들의 박싱과 언박싱을 생략할 수 있기 때문이다.
  • List<T>와 ArrayList는 기존 컬렉션들을 받는 생성자들을 제공한다. 이 생성자들은 기존 컬렉션의 요소들을 새 List<T>나 ArrayList에 복사한다.
    • (이하 List<T> 내부 모습과 사용 예제 생략)

LinkedList<T> 클래스

  • LinkedList<T>는 이중 연결 목록(doubly linked list) 자료구조를 나타내는 제네릭 클래스이다.
    • 이중 연결 목록의 장점은 새 요소를 목록의 어디에나 빠르게 삽입할 수 있다는 것이다. 삽입 시 그냥 새 노드를 생성하고 참조 몇 개만 갱신하면 되기 때문이다.
    • 그러나 애초에 노드를 삽입할 위치를 찾는 것은 느릴 수 있다. 색인을 이용해서 특정 노드에 직접 접근할 방법이 없기 때문이 노드들을 일일이 훑어야 한다. 마찬가지 이유로 효율적인 이진 검색도 불가능하다.
  • LinkedList<T>는 IEnumerable<T>와 ICollection<T>를 구현하나, 색인 접근이 불가능하므로 IList<T>는 구현하지 않는다.
    • 목록 노드는 다음과 같은 클래스로 구현된다.
public sealed class LinkedListNode<T>
{
  public LinkedList<T> List { get; }
  public LinkedListNode<T> Next { get; }
  public LinkedListNode<T> Previous { get; }
  public T Value { get; set; }
}

  • (이하 LinkedList<T> 클래스 내부 모습과 설명 생략)

Queue<T> 클래스와 Queue 클래스

  • Queue<T>와  Queue는 선입선출 방식의 대기열 자료구조를 나타내는 클래스들로 새 항목을 대기열의 꼬리에 추가하는 Enqueue 메서드와 대기열의 머리에 있는 요소를 제거하고 그 요소를 돌려주는 Dequeue 메서드를 제공한다.
    • 또한 대기열의 머리에 있는 요소를 제거하지 않고 돌려주는 Peek 메서드와 대기열의 길이를 돌려주는 Count 속성도 제공한다.
  • 대기열은 열거 가능 컬렉션이지만 IList<T>와 IList를 구현하지는 않는다. 색인으로 특정 요소에 직접 접근할 수 없기 때문이다.
    • 대신 ToArray 메서드를 제공하므로 임의 접근이 필요하다면 이 메서드로 요소들을 배열에 복사하면 된다.
  • (이하 Queue<T> 클래스의 내부 모습 생략)
  • 대기열은 내부적으로 배열로 구현된다. 제네릭 List 클래스와 비슷하게, 요소들을 담을 공간이 부족해지면 대기열은 내부 배열을 더 큰 배열로 대체한다.
    • 대기열은 머리 요소와 꼬리 요소를 직접 가리키는 색인들을 유지한다. 따라서 대기열에 대한 요소 삽입, 삭제는 아주 효율적이다.

Stack<T> 클래스와 Stack 클래스

  • Stack<T>와 Stack은 후입선출 방식의 스택 자료구조를 나타내는 클래스로 스택 최상위에 항목을 추가하는 Push 메서드와 스택 최상위의 행목을 제거해서 돌려주는 Pop 메서드를 제공한다.
    • 또한 스택 최상위 항목을 제거하지 않고 돌려주는 Peek 메서드와 요소 개수를 돌려주는 Count 속성, 그리고 임의 접근을 위해 요소들을 배열로 복사하는 ToArray 메서드도 제공한다.
  • (이하 Stack<T> 클래스의 내부 모습 생략)
  • Queue<T>나 List<T>처럼 스택은 내부적으로 요소들을 배열에 저장하며, 필요에 따라 배열의 크기를 변경한다.

BitArray 클래스

  • BitArray는 bool 값들을 빽빽하게 저장하는 컬렉션으로 컬렉션 크기의 동적 변경을 지원한다.
    • bool 값 하나에 비트 하나만 사용하므로, 각 값을 1바이트에 저장하는 보통의 bool 배열이나 bool 형식의 제네릭 List보다 메모리를 효율적으로 사용한다.
  • BitArray의 인덱서는 개별 비트를 읽거나 쓴다.
var bits = new BitArray(2);
bits[1] = true;
  • 이 클래스는 비트별 연산자 메서드 And, Or, Xor, Not를 제공한다.
    • 처음 세 메서드는 다른 BitArray 객체를 받는다.
    • 다음 예처럼 자신과의 비트별 연산도 가능하다.
bits.Xor(bits);  // 자신과의 비트별 xor 연산을 수행한다.
Console.WriteLine(bits[1]);  // false

HashSet<T> 클래스와 SortedSet<T> 클래스

  • HashSet<T>와 SortedSet<T>는 각각 .NET Framework 3.5와 4.0에 새로 추가된 제네릭 컬렉션이다. 둘 다 다음과 같은 특징을 갖고 있다.
    • Contains 메서드는 해시 기반 조회(hash-based lookup)를 이용해서 빠르게 실행된다.
    • 중복된 원소를 저장하지 않으며, –이는 수학의 집합이 가진 특성이다. 이 때문에 Set이라는 이름이 붙었다– 중복된 원소를 추가하는 요청은 소리 없이 무시한다.
    • 색인으로 특정 원소에 접근할 수 없다.
    • 이 두 형식의 공통성은 ISet<T> 인터페이스에 기인한다. 역사적인 이유로 HashSet<T>은 System.Core.dll에 있는 반면 ISet<T>와 SortedSet<T>는 System.dll에 있다.
  • SortedSet<T>는 원소들을 정렬된 순서로 저장하는 반면 HashSet<T>는 그렇지 않다.
  • HashSet<T>는 키들만 저장하는 해시테이블로 구현되고, SortedSet<T>는 적흑 트리로 구현된다.
    • 두 컬렉션 모두 ICollection<T>를 구현하며, Contains와 Add, Remove 같이 통상적인 컬렉션 메서드들을 제공한다.
    • 또한 술어에 기초해서 특정 원소를 제거하는 RemoveWhere라는 메서드도 있다.
  • 다음 코드는 기존 컬렉션으로 HashSet<char>를 생성하고, 트정 값의 집합 소속 여부를 판정하고 컬렉션의 모든 원소를 나열한다.
// string을 HashSet<char>의 생성자에 전달할 수 있는 이유는 string이 IEnumerable<char>를 구현하기 때문이다.
var letters = new HashSet<char>("the quick brown fox");

Console.WriteLine(letters.Contains('t'));  // true
Console.WriteLine(letters.Contains('k'));  // false

foreach (char c in letters)
  Console.Write(c);  // the quickbrownfx
  • 이 클래스들에서 정말로 흥미로운 메서드들은 집합 연산을 수행하는 메서드들이다.
    • 다음은 순서대로 합집합, 교집합, 차집합, 대칭차집합을 계산하는 메서드들인데, 이들은 기존 집합을 수정한다는 의미에서 파괴적(destructive)이다.
public void UnionWith (IEnumerable<T> other);  // 원소 추가
public void IntersectWith (IEnumerable<T> other);  // 원소 제거
public void ExceptWith (IEnumerable<T> other);  // 원소 제거
public void SymmetricExceptWith (IEnumerable<T> other);  // 원소 제거
  • 반면 다음 메서드들은 그냥 집합에 대한 질의를 수행하므로 파괴적이지 않다.
public bool IsSubsetOf (Ienumerable<T> other);
public bool IsProperSubsetOf (Ienumerable<T> other);
public bool IsSupersetOf (Ienumerable<T> other);
public bool IsProperSupersetOf (Ienumerable<T> other);
public bool OverlapsOf (Ienumerable<T> other);
public bool SetEquals (Ienumerable<T> other);
  • UnionWith 메서드는 둘째 집합의 모든 원소를 첫 집합에 추가한다.
  • IntersectWith는 둘 중 한 집합에만 있는 원소들을 제거한다.
  • ExceptWith는 둘째 집합의 원소들을 첫 집합에서 제거한다.
  • SymmetricExceptWith는 두 집합에 모두 있는 원소들을 첫 집합에서 제거한다.
  • HashSet<T>와 SortedSet<T>는 IEnumerable<T>를 구현하므로 이 집합 연산 메서드들은 모두 다른 형식의 집합도 인수로 받는다는 점을 주목하기 바란다.
  • SortedSet<T>는 HashSet<T>의 모든 멤버를 제공하며, 다음과 같은 멤버들도 추가로 제공한다.
public virtual SortedSet<T> GetViewBetween (T lowerValue, T upperValue)
public IEnumerable<T> Reverse()
public T Min { get; }
public T Max { get; }
  • SortedSet<T>는 또한 추가로 IComparer<T>를 받는 생성자도 제공한다.
    • 다음은 앞의 예제들에서와 같은 영문자들을 SortedSet<char>에 추가하는 예이다.
var letters = new SortedSet<char> ("the quick brown fox");

foreach (char c in letters)
  Console.Write(c);  //bcefhiknoqrtuwx

// d이로부터 f 에서 j 까지의 범위에 있는 영문자들을 얻을 수 있다.
foreach (char c in letters.GetViewBetween('f', 'j'))
  Console.Write(c);  // fhi

사전

  • 사전(Dictionary)은 각 요소가 키-값 쌍인 컬렉션으로 주된 용도는 조회(lookup) 또는 정렬된 목록이다.
  • .NET Framework는 사전을 위한 하나의 표준 프로토콜을 정의한다. 그 프로토콜은 인터페이스 IDictionary<TKey, TValue> 및 일단의 범용 사전 클래스들로 구성된다.
  • 범용 사전 클래스들은 다음과 같은 기준에 따라 분류 할 수 있다.
    • 항목(요소)들을 정렬된 순서로 저장하는지의 여부
    • 키뿐만 아니라 위치(색인)로도 항목들에 접근할 수 있는지의 여부
    • 제네릭인지 아니면 비제네릭인지의 여부
    • 큰 사전에서 키로 항목을 추출하는 것이 빠른지 아니면 느린지의 여부
  • 아래 표는 이러한 기준들에 따라 사전 클래스들을 분류한 것이다.
    • ‘속도’ 열들의 수치는 1.5GHz PC에서 키와 값이 모두 정수인 사전에 대해 50,000회의 연산을 수행하는데 걸린 시간(밀리초)이다.
    • 같은 바탕 컬렉션 자료구조를 사용하는 제네릭 버전과 비제네릭 버전의 성능 차이는 박싱 때문에 생기는 것으로, 이러한 차이는 값 형식 요소들에서만 나타난다.
형식 내부 자료구조 색인으로 조회 가능? 메모리 추가부담 (요소 당 평균 바이트 수) 속도: 무작위 삽입 속도: 순차 삽입 속도: 키로 조회
정렬되지 않은 사전
Dictionary<K, V> 해시테이블 no 22 30 30 20
Hashtable 해시테이블 no 38 50 50 30
ListDictionary 연결목록 no 36 50,000 50,000 50,000
OrderedDictionary 해시테이블 + 배열 yes 59 70 70 40
정렬된 사전
SortedDictionary<K, V> 적흑 트리 no 20 130 100 120
SortedList <K, V> 배열 두 개 yes 2 3,300 30 40
SortedList 배열 두 개 yes 27 4,500 100 180

 

  • 키로 조회하는 시간을 대문자 O 표기법으로 표현하면 다음과 같다.
    • Hashtable과 Dictionary, OrderedDictionary는 O(1)
    • SortedDictionary와 SortedList는 O(log n)
    • ListDictionary는 (그리고 List<T> 같은 비사전 형식들은) O(n)

IDictionary<TKey, TValue> 인터페이스

  • IDictionary<TKey, TValue>는 모든 키-값 쌍 기반 컬렉션의 표준 프로토콜을 정의한다.
    • 이 인터페이스는 임의의 형식의 키에 기초해서 요소들에 접근하는 메서드들과 속성들을 추가해서 ICollection<T>를 확장한다.
    • .NET Framework 4.5에는 IReadOnlyDictionary<TKey, TValue>라는 인터페이스도 추가되었다. 이 인터페이스는 사전 멤버들의 읽기 전용 부분집합을 정의한다. 이 인터페이스는 WinRT의 IMapView<K, V> 형식에 대응되는데, 사실 그것이 이 인터페이스가 도입된 이유이다.
public interface IDictionary<TKey, TValue> : ICollection<KeyValuePair<TKey, TValue>>, IEnumerable
{
  bool ContainKey (TKey key);
  bool TryGetValue (TKey key, out TValue value);
  void Add (TKey, TValue value);
  bool Remove (TKey key);

  TValue this [TKey key]  { get; set; }  // 주 인덱서. 키로 접근
  ICollection<TKey> Keys { get; }  // 키들만 돌려준다.
  ICollection<TValue> Values { get; }  // 값들만 돌려준다.
}
  • 사전에 어떤 항목을 추가할 때는 Add 메서드를 호출할 수도 있고 인덱서의 설정 접근자를 사용할 수도 있다.
    • 후자는 주어진 키가 사전에 존재하지 않으면 해당 항목을 사전에 추가하고, 존재하면 기존 항목을 갱신한다.
    • 모든 사전 구현은 중복된 키들을 허용하지 않으므로 같은 키로 Add를 두 번 호출하면 예외가 발생한다.
  • 사전에서 어떤 항목을 조회하는 방버도 두 가지이다. 인덱서를 사용할 수도 있고, TryGetValue 메서드를 호출해도 된다.
    • 지정된 키가 존재하지 않으면 인덱서는 예외를 던지지만 TryGetValue는 false를 돌려준다.
    • ContainsKey를 호출해서 키의 존재 여부를 파악할 수도 있다. 그러나 그 다음에 해당 항목을 조회할 것이라면, ContainsKey를 먼저 호출하는 것은 조회를 두 번 수행하는 셈이 된다.
  • IDictionary<TKey, TValue>를 직접 열거하면 KeyValuePair 구조체들의 순차열을 얻게 된다.
public struct KeyValuePair <TKey, TValue>
{
  public TKey Key { get; }
  public TValue Value { get; }
}

IDictionary 인터페이스

  • 비제네릭 IDictionary 인터페이스는 기본적으로 IDictionary<TKey, TValue>와 같되, 기능상으로는 다음과 같은 차이점이 2개 있다. IDictionary를 구식 코드에서 종종 볼 수 있다는 점에서 이 차이점들을 잘 이해하는 것이 중요하다.
    • 존재하지 않는 키를 조회하려는 경우 인덱서는 예외를 던지지 않고 null을 돌려준다.
    • 키의 존재 여부를 판정하는 메서드가 ContainsKey가 아니라 Contains이다.
  • 비제네릭 IDictionary에 대해 열거를 수행하면 DictionaryEntry 구조체들의 순차열이 반환된다.
public struct DictionaryEntry
{
  public object Key { get; set; }
  public object Value { get; set; }
}

Dictionary<TKey, TValue> 클래스와 Hashtable 클래스

  • 제네릭 Dictionary 클래스는 아주 흔히 쓰이는 컬렉션이다. 이 클래스는 해시테이블 자료구조에 키들과 값들을 저장하므로 빠르고 효율적이다.
    • Dictionary<TKey, TValue>의 비제네릭 버전의 이름은 Hashtable이다. .NET Framework에 Dictionary라는 이름의 비제네릭 클래스는 없다.
  • Dictionary는 IDictionary 인터페이스의 제네릭 버전과 비제네릭 버전을 모두 구현하되, 제네릭 IDictionary 버전만 공용으로 노출한다.
    • 사실 Dictionary는 제네릭 IDictionary의 교과서적 구현이다.
    • (이하 Dictionary<TKey, TValue> 클래스 사용예 생략)
  • 이 클래스의 바탕 해시테이블은 요소를 추가할 때 주어진 요소의 키를 정수 해시코드(일종의 유사고유(pseudounique) 값)으로 변환하고, 그 해시코드를 적당한 알고리즘을 이용해서 해시 키로 변환한다.
    • 그리고 내부적으로 이 해시 키를 이용해서 주어진 요소의 값을 집어 넣을 통(bucket)을 결정한다.
    • 요소를 조회할 때는 요소의 키를 이용해서 ‘통’을 결정한다. 만일 통에 여러 개의 값이 들어 있으면 선형 검색을 이용해서 해당 값을 찾는다.
  • 좋은 해시 함수는 진정으로 고유한 해시코드를 계산하는데 주력하는 대신 (일반적으로 그런 일은 불가능하다), 32비트 정수 공간에 고르게 분포되는 해시코드들을 생성하는데 주력한다.
    • 그러면 몇몇 통에 값들이 몰리는 현상을 피할 수 있다.
  • 사전의 키 형식으로는 키들 사이의 상등 비교가 가능하고 키로부터 해시 코드를 얻을 수 있는 형식이기만 하면 어떤 형식도 가능하다.
    • 기본적으로 상등 비교에는 키의 object.Equals 메서드가 쓰이며, 유사고유 해시코드를 얻는데는 키의 GetHashCode 메서드가 쓰인다.
    • 이 기본 방식 이외의 방식을 원한다면 두 메서드를 재정의하거나 사전을 생성할 때 적절한 IEqualityComparer 객체를 제공하면 된다.
    • 다음은 이러한 접근방식의 흔한 응용 사례로, 문자열 키들을 대소문자 구분 없이 비교하는 상등 비교자를 지정해서 사전을 생성한다.
var d = new Dictionary<string, int> (StringComparer.OrdinalIgnoreCase);
  • 다른 여러 컬렉션 형식들과 마찬가지로, 사저너 생서이 컬렉션의 예상 크기를 지정해서 내부적인 크기 변경 연산의 횟수를 줄이면 사전의 성능을 조금 향상할 수 있다.
  • 이 클래스의 비제네릭 버전은 Hashtable이다. 이 클래스는 Dictionary와 비슷한 기능을 제공하되, 앞에서 말한 IDictionary 인터페이스의 비제네릭 버전만 구현한다는 점에서 비롯된 차이점들이 있다.
  • Dictionary와  Hashtable의 단점은 항목들이 정렬되지 않는다는 거싱다. 또한 항목들이 저장된 순서가 애초에 항목들을 추가한 순서와는 다르다는 점도 단점이다.

OrderedDictionary 클래스

  • OrderedDictionary는 비제네릭 사전 클래스로 요소들의 저장 순서가 추가 순서와 동일하다는 특징이 있다. 이 덕분에 키 뿐만 아니라 색인으로도 요소에 접근할 수 있다.
    • OrderedDictionary가 정렬된(sorted) 사전은 아니다.
  • OrderedDictionary는 Hashtable과 ArrayList의 조합이다.
    • 그래서 이 클래스는 Hashtable의 모든 기능뿐만 아니라 RemoveAt 같은 추가적인 메서드와 정수 인덱서도 제공한다.
    • 또한 키들과 값들을 원래 순서대로 돌려주는 Keys 속성과 Values 속성도 제공한다.
  • 이 클래스는 .NET Framework 2.0에서 도입되었지만, 이상하게도 제네릭 버전은 없다.

ListDictionary 클래스와 HybridDictionary 클래스

  • ListDictionary는 단일하게 연결된 목록(singly linked list), 자료구조에 바탕 자료를 저장한다.
    • 이 클래스는 정렬 기능을 제공하지 않지만 애초에 항목들이 추가된 순서는 유지한다.
    • 목록이 클 경우 ListDictionary는 극도로 느리다.
    • 유일한 자랑거리는 아주 작은 목록(요소가 10개 미만)에서 효율적이라는 점이다.
  • HybridDictionary는 ListDictionary와 Hashtable의 조합이다.
    • 이 사전은 처음에는 ListDictionary지만, 일정한 크기가 되면 Hashtable 로 전환된다. (목록 크기에 따른 ListDictionary의 성능 문제를 피하기 위해)
    • 이 클래스의 요지는 사전이 작을 떄는 메모리를 적게 먹는 방식을 사용하고, 사전이 클 때는 성능이 좋은 방식을 사용한다는 것이다.
    • 그러나 이 두 방식의 전환에 따르는 추가부담 때문에, 그리고 대체로 Dictionary의 메모리 사용량이나 성능이 그리 나쁘지는 않다는 점 때문에, 비합리적일 정도로 극단적인 상황이 아니라면 그냥 Dictionary를 사용해도 큰 문제는 없다.
  • 두 클래스 모두 비제네릭 버전만 있다.

정렬된 사전

  • .NET Framework에는 요소들을 항상 키를 기준으로 정렬된 상태를 유지하는 사전 클래스가 2개 있다.
    • SortedDictionary<TKey, TValue>
    • SortedList<TKey, TValue>
  • SortedDictionary<,>는 적흑 트리를 사용한다.
    • 적흑 트리(red/black tree)는 모든 삽입 또는 조회 상황에서 일관된 성능을 내도록 설계된 자료구조이다.
  • SortedDictionary<,>는 내부적으로 배열 두 개에 키들과 값들을 담는다.
    • 정렬된 배열에 대해 이진 검색을 적용하므로 조회가 빠르지만, 새 항목을 추가할 때마다 기존 항목들을 옮겨서 자리를 만들어야 하기 때문에 삽입 성능은 나쁘다.
  • 무작위한 순차열에 요소들을 삽입하는 속도는 SortedDictionary<,>가 SortedList<,>보다 훨씬 빠르다.
    • 그러나 SorteList<,>는 키 뿐만 아니라 색인으로도 요소에 접근하는 추가적인 능력을 갖추고 있다. 정렬된 목록에서는 정렬된 순차열의 n번째 요소에 직접 접근하는 것이 가능하다.
    • SortedDictionary<,>에서는 최대 n개의 항목을 차례로 훑어야 한다.
  • 이 세 컬렉션 모두 중복 키는 허용하지 않는다.
  • (이하 사용 예 생략)

커스텀화 가능한 컬렉션과 프록시

  • 지금까지 설명한 컬렉션 클래스들은 바로 인스턴스화해서 써먹을 수 있다는 점에서 편리하지만, 컬렉션에 항목이 추가되거나 기존 항목이 제거될 때 일어나는 일을 프로그래머가 세밀하게 제어할 수 없다는 한계가 있다.
    • 강한 형식이 적용된 컬렉션을 응용 프로그램에서 사용할 떄는 종종 그러한 제어가 필요하다. 이를 테면 다음과 같은 용법을 생각해 볼 수 있다.
      • 항목이 추가되거나 제거되면 이벤트를 발동한다.
      • 추가 또는 제거된 항목에 맞게 속성들을 갱신한다.
      • 적법하지 않은 추가/제거 연산을 검출해서 예외를 던진다.
  • .NET Framework는 바로 이런 용도를 위한 일단의 컬렉션 클래스들을 제공한다.
    • System.Colllections.ObjectModel 이름공간에 있는 이 클래스들은 IList<T>나 IDictionary<,>를 구현하는 래퍼(wrapper) 또는 프록시(proxy)로 메서드들을 바탕 컬렉션에 전달하는 역할을 한다.
    • 이들은 Add, Remove, Clear 연산을 일종의 관문(gateway) 역할을 하는 가상 메서드들에 연결하며, 커스텀 컬렉션을 만들 때는 그 가상 메서드들을 적절히 재정의함으로써 원하는 기능성을 구현한다.
  • 이런 커스텀화 가능한 컬렉션 클래스들을 이용한 커스텀 컬렉션 작성 방법은 주로 공용으로 노출되는 컬렉션들을 만드는데 쓰인다.
    • 좋은 예가 System.Windows.Form 클래스에 공용으로 노출된 컨트롤들의 컬렉션이다.

Collection<T> 클래스와 CollectionBase 클래스

  • Collection<T> 클래스는 List<T>에 대한 커스텀화 가능 래퍼이다.
    • 이 클래스는 IList<T>와 IList를 구현하며, 다음과 같은 가상 메서드들과 보호된 속성도 정의한다.
public class Collection<T> : IList<T>, ICollection<T>, IEnumerable<T>, IList, ICollection, IEnumerable
{
  // ...
  protected virtual void ClearItems();
  protected virtual void InsertItem(int index, T item);
  protected virtual void RemoveItem(int index);
  protected virtual void SetItem(int index, T item);
  protected IList<T> Items { get; }
}
  • 이 가상 메서드들은 일종의 관문 역할을 한다. 커스텀 컬렉션 구현자는 이들을 후킹(hooking) 지점으로 사용해서 기본 행동 방식을 변경하거나 개선한다.
    • 보호된 Items 속성은 커스텀 컬렉션 클래스가 ‘바탕 목록’에 직접 접근하는 수단으로 쓰인다. 이를 통해서 가상 메서드를 호출하지 않고도 바탕 목록을 직접 변경할 수 있다.
  • 가상 메서드들을 반드시 재정의해야 하는 것은 아니다. 기본 행동 방식을 바꾸어야 하는 경우에만 재정의하면 된다.
    • 다음은 Collection<T>를 활용하는 전형적인 ‘뼈대’를 보여주는 예제이다.
public class Animal
{
  public string Name;
  public int Popularity;
  public Animal (string name, int popularity)
  {
    Name = name; Popularity = popularity;
  }
}

public class AnimalCollection : Collection <Animal>
{
  // AnimalCollection은 '동물들의 목록'으로 작동하는데 충분한 기능을 갖추고 있다. 더 이상의 코드는 필요하지 않다.
}

// AnimalCollection을 노출하는 클래스
// 이런 클래스는 흔히 바탕 컬렉션(AnimalCollection)에는 없는 멤버들을 추가한다.
public class Zoo
{
  public readonly AnimalCollection Animals = new Animalcollection();
}

class Program
{
  static void Main()
  {
    Zoo zoo = new Zoo();
    zoo.Animals.Add(new Animal("캥거루", 10));
    zoo.Animals.Add(new Animal("바다사자", 20));

    foreach(Animal in zoo.Animals)
      Console.WriteLine(a.Name);
  }
}
  • 이 예제의 AnimalCollection 클래스는 단순한 List<Animal>과 기능상 다를 바 없다. 이 클래스는 이후의 확장을 위한 기반으로 쓰인다.
    • 확장 방법을 보여주기 위해 다음 예제는 Animal 클래스에 Zoo 형식의 속성을 하난 추가한다. 그러면 각 동물은 자신이 살고 있는 동물원에 접근할 수 있다.
    • 또한 AnimalCollection 클래스는 Collection<Animal>의 각 가상 메서드를 그 속성을 자동으로 갱신하도록 재정의 한다.
public class Animal
{
  public string Name;
  public int Popularity;
  public Zoo Zoo { get; internal set; }
  public Animal (string name, int popularity)
  {
    Name = name; Popularity = popularity;
  }
}

public class AnimalCollection : Collection <Animal>
{
  Zoo zoo;
  public AnimalCollection (Zoo zoo) { this.zoo = zoo; }

  protected override void InsertItem (int index, Animal item)
  {
    base.InsertItem(index, item);
    item.Zoo = zoo;
  }

  protected override void SetItem(int index, Animal item)
  {
    base.SetItem(index, item);
    item.Zoo = zoo;
  }

  protected override void RemoveItem(int index)
  {
    this [index].Zoo = null;
    base.RemoveItem(index);
  }

  protected override void ClearItems()
  {
    foreach (Animal a in this)  a.Zoo = null;
    base.ClearItems();
  }
}

public class Zoo
{
  public readonly AnimalCollection Animals = new Animalcollection();
  public Zoo() { Animals = new AnimalCollection (this); }
}
  • Collection<T>에는 기존의  IList<T>를 받는 생성자도 있다.
    • 다른 컬렉션 클래스들과 달리, 그 생성자는 주어진 목록(IList<T>)를 복사하는 대신 그냥 자신을 그 목록의 프록시(proxy)로 삼기만 한다.
    • 다른 말로 하면, 그때부터 목록에 생긴 변화는 해당 Collection<T>에 반영되며(단, Collection<T>의 가상 메서드의 호출은 일어나지 않는다) 반대로 Collection<T>에 가해진 변경은 해당 목록에도 가해진다.

CollectionBase 클래스

  • .NET Framework 1.0부터 있던 CollectionBase는 Collection<T>의 비제네릭 버전이다.
    • 이 클래스는 Collection<T>의 기능들을 거의 다 제공하지만, 사용하기는 좀 거추장 스럽다.
    • 이 클래스에는 제네릭 메서드 InsertItem과 RemoveItem, SetItem, ClearItem 대신 OnInsert, OnInsertComplete, OnSet, OnSetComplete, OnRemove, OnRemoveComplete, OnClear, OnClearComplete라는 ‘후킹’ 메서드들이 있다. 구현해야 할 메서드가 제네릭 버전의 두 배인 셈이다.
    • CollectionBase는 비네제릭 클래스이므로 이 클래스를 상속할 때는 형식 있는 메서드들도 구현해야 한다. 적어도 형식 있는 인덱서와 Add 메서드는 반드시 구현해야 한다.

KeyedCollection<TKey, TItem> 클래스와  DictionaryBase 클래스

  • 키 기반 컬렉션을 대표하는 KeyedCollection<TKey, TItem>은 Collection<TItem>의 파생 클래스로, Collection<TItem>에 비해 추가된 기능도 있고 제거된 기능도 있다.
    • 추가된 기능은 마치 사전처럼 키로 항목에 접근하는 능력이고, 제거된 기능은 내부 목록의 프록시가 되는 능력이다.
  • 키 기반 컬렉션은 선형 목록과 해시테이블을 결합한다는 점에서 OrderedDictionary와 비슷한 면이 있다.
    • 그러나 OrderedDictionary와는 달리 키 기반 컬렉션은 IDictionary를 구현하지 않으며, 키-값 쌍이라는 개념을 지원하지 않는다.
    • 키 기반 컬렉션에서는 요소에 키가 따로 있는 것이 아니라, 요소 자체에서 키를 얻는다. (추상 메서드 GetKeyForItem을 통해서)
    • 따라서 키 기반 컬렉션을 열거하는 거은 보통의 목록을 결거하는 것과 같다.
  • KeyedCollection<TKey, TItem>은 Collection<TItem>의 기능에 빠른 키 기반 조회 능력을 추가한 것이라고 생각하는 것이 최선이다.
    • 이 클래스는 Collection<>의 파생 클래스이므로 Collection<>의 모든 기능을 물려 받는다.
    • 단 기존 목록을 받는 생성자는 제공하지 않는다. 또한 이 키 기반 컬렉션 클래스는 다음과 같은 추가적인 멤버들도 정의 한다.
public abstract class KeyedCollection<TKey, TItem> : Collection<TItem>
{
  // ...

  protected abstract TKey GetKeyForItem(TItem item);
  protected void ChangeItemKey(TItem item, TKey newKey);

  // 빠른 키 기반 조회 - 색인 뿐만 아니라 키로도 요소에 접근할 수 있다.
  public TItem this [TKey key] { get; }
  protected IDictionary<Tkey, TItem> Dictionary { get; }
}
  • GetKeyForItem 메서드는 바탕 객체로부터 항목의 키를 계산해서 돌려주는 역할을 한다. 커스텀 컬렉션 클래스 작성자는 이 추상 메서드를 반드시 구현해야 한다.
    • ChangeItemKey 메서드는 항목의 키를 변경하는데 쓰인다.
  • Dictionary 속성은 조회를 구현하는데 쓰이는 내부 사전을 돌려준다.
    • 기본적으로 내부 사전은 첫 항목이 추가될 때 생성되는데, 만일 그러한 기본 방식이 마음에 들지 않는다면 컬렉션 생성시 생성 문턱값(createion threshold)을 지정하면 된다. 그러면 요소 개수가 해당 문턱값에 도달해야 내부 사전이 생성된다. (그 이전에는 키 기반 조회를 그냥 선형 검색으로 해결한다)
    • 그러나 내부 사전이 만들어져 있으면 Dictionary의 Keys 속성을 통해서 키 컬렉션을 얻을 수 있다는 장점이 있으므로 특별한 이유가 없는 한 생성 문턱값은 지정하지 않는 것이 좋다.
  • KeyedCollection<,>의 가장 흔한 용도는 색인과 이름 모두로 접근할 수 있는 컬렉션을 만드는 것이다.
    • 다음은 이점을 보여주기 위해 동물원 예제를 수정한 것이다. 이번에는 AnimalCollection을 KeyedCollection<string, Animal>로 구현한다.
public class Animal
{
  string name
  public string Name
  {
    get { return name; }
    set {
      if (Zoo != null) Zoo.Animals.NotifyNameChange(this, value);
      name = value;
    }
  }
  public int Popularity;
  public Zoo Zoo { get; internal set; }
  public Animal (string name, int popularity)
  {
    Name = name; Popularity = popularity;
  }
}

public class AnimalCollection : KeyedCollection <string, Animal>
{
  Zoo zoo;
  public AnimalCollection (Zoo zoo) { this.zoo = zoo; }

  internal void NotifyNameChange(Animal a, string newName)
  {
    this.ChangeItemKey(a, newName);
  }

  protected override string GetKeyForItem(Animal item)
  {
    return item.Name;
  }

  protected override void InsertItem (int index, Animal item)
  {
    base.InsertItem(index, item);
    item.Zoo = zoo;
  }

  protected override void SetItem(int index, Animal item)
  {
    base.SetItem(index, item);
    item.Zoo = zoo;
  }

  protected override void RemoveItem(int index)
  {
    this [index].Zoo = null;
    base.RemoveItem(index);
  }

  protected override void ClearItems()
  {
    foreach (Animal a in this)  a.Zoo = null;
    base.ClearItems();
  }
}

public class Zoo
{
  public readonly AnimalCollection Animals = new Animalcollection();
  public Zoo() { Animals = new AnimalCollection (this); }
}

class Program
{
  static void Main()
  {
    Zoo zoo = new Zoo();
    zoo.Animals.Add(new Animal("Kangaroo", 10));
    zoo.Animals.Add(new Animal("Mr Sea Lion", 20));
    Console.WriteLine(zoo.Animals[0].Popularity);  // 10
    Console.WriteLine(zoo.Animals["Mr Sea Lion"].Popularity);  // 20
    zoo.Animals["Kangaroo"].Name = "Mr Roo";
    Console.WriteLine(zoo.Animals["Mr Roo"].Popularity);  // 10
  }
}

DictionaryBase 클래스

  • KeyedCollection의 비제네릭 버전은 DictionaryBase라는 클래스이다. 이 구식 클래스는 KeyedCollection과는 아주 다른 접근 방식을 취한다.
    • 이 클래스는 IDictionary를 구현하며, CollectionBase처럼 지저분한 후킹 메서드들, 즉 OnInsert, OnInsertComplete, OnSet, OnSetComplete, OnRemove, OnRemoveComplete, OnClear, OnClearComplete를 사용한다 (추가로 OnGet도 있다)
    • KeyedCollection의 접근방식을 취하는 대신 IDictionary를 구현하는 것의 주된 장점은 클래스 파생 없이도 키들을 얻을 수 있다는 것이다. 그러나 애초에 DictionaryBase는 기반 클래스 용도로 만들어진 것이므로, 이것은 사실 장점이 아니다.
    • KeyedCollection이 더 늦게 나왔으므로 기존 방식의 단점을 보완할 수 있었을 것이라고 충분히 짐작할 수 있다는 점에서, KeyedCollection의 모형이 더 낫다고 보는 것이 안전하다. 결론적으로 DictionaryBase는 하위 호환성을 위해서나 유용하다고 간주하는 것이 최선이다.

ReadOnlyCollection<T> 클래스

  • ReadOnlyCollection<T>는 기존 컬렉션의 읽기 전용 시각을 제공하는 하나의 래퍼 또는 프록시이다.
    • 이 클래스는 읽기 전용 컬렉션 클래스, 즉 소비자에게는 기존 컬렉션에 대해 읽기 전용 접근만 제공하되, 클래스 자체는 여전히 컬렉션을 내부적으로 수정할 수 있어야 하는 클래스를 만들 때 유용하다.
  • 읽기 전용 컬렉션의 생성자는 입력 컬렉션을 인수로 받아서 그 컬렉션에 대한 참조를 계속 유지한다.
    • 입력 컬렉션의 정적 복사본을 받는 것이 아니므로, 입력 컬렉션이 바뀌면 읽기 전용 컬렉션에도 그 변경이 반영된다.
  • 이 점을 보여주는 예로 Names라는 문자열 목록에 대한 읽기 전용 공용 접근을 제공하는 클래스를 작성한다고 하자.
public class Test
{
  public List<string> Names { get; private set; }
}
  • 이 구현에는 문제점이 있는데, 비록 외부에서 Names 속성에 다른 목록을 배정할 수는 없지만, 목록에 대해 Add, Remove, Clear를 호출하는 것이 가능하다는 점이다.
    • 다음처럼 ReadOnlyCollection<T> 클래스를 이용하면 이 문제가 해결된다.
public class Test
{
  List<string> names;
  public ReadOnlyCollection<string> Names { get; private set; }

  public Test()
  {
    names = new List<string>();
    Names = new ReadOnlyCollection<string> (names);
  }

  public void AddInternally() { names.Add("test"); }
}

// 이제는 Test 클래스의 멤버들만 이름 목록을 변경할 수 있다.
Test t = new Test();
Console.WriteLine(t.Names.Count);  // 0
t.AddInternally();
Console.WriteLine(t.Names.Count);  // 1
t.Names.Add("test");  // 컴파일 시점 오류
((IList<string>)t.Names).Add("test"); // NotSupportedException

상등 및 순서 비교 플러그인

  • 형식의 상등비교와 해시 계산, 순서 비교를 가능하게 하는 표준 .NET 프로토콜들을 구현하는 형식은 ‘자동으로’ 사전이나 정렬된 목록 안에서 잘 작동하게 된다. 좀 더 구체적으로 말하면 다음과 같다.
    • Equals와 GetHashCode가 의미 있는 결과를 돌려주는 형식은 Dictionary나 Hashtable에서 키로 쓰일 수 있다.
    • IComparable/IComparable<T>를 구현하는 형식은 임의의 정렬된 사전이나 목록에서 키로 쓰일 수 있다.
  • 대체로 형식의 기본적인 상등, 순서 비교 구현은 그 형식에 가장 ‘자연스러운’ 행동 방식을 반영한다.
    • 그러나 그러한 기본 행동방식이 적합하지 않은 경우가 종종 생긴다. 예컨대 키 형식이 string인 사전에서 어떤 단어를 대소문자 구분 없이 검색하고 싶을 수 있다. 또는 어떤 고객 목록을 고객의 이름이 아니라 고객의 우편번호순으로 정렬하고 싶을 수도 있다.
  • 이러한 요구를 만족하기 위해 .NET Framework는 일단의 플러그인(plug-in) 프로토콜들을 제공한다. 이 프로토콜은 다음 두 가지 역할을 한다.
    • 기본 방식과는 다른 방식의 상등 비교 또는 순서 비교를 적용할 수 있게 한다.
    • 상등 비교나 순서 비교 능력이 갖추어져 있지 않은 형식을 사전이나 정렬된 목록의 키 형식으로 사용할 수 있게 한다.
  • 다음은 플러그인 프로토콜을 구성하는 인터페이스들이다.
    • IEqualityComparer와 IEqualityComparer<T>
      • 플러그인 상등 비교와 해싱 기능을 담당한다.
      • Hashtable과 Dictionary가 지원한다.
    • IComparer와 IComparer<T>
      • 플러그인 순서 비교 기능을 담당한다.
      • 정렬된 사전과 컬렉션 그리고 Array.Sort가 지원한다.
  • 인터페이스마다 제네릭 버전과 비제네릭 버전이 있다. IEqualityComparer 인터페이스에는 EqualityComparer라는 이름의 기본 구현 클래스도 있다.
    • .NET Framework 4.0에는 IStructuralEquatable과 IStructuralComaparble이라는 새로운 두 인터페이스도 도입되었다. 이들은 클래스의 배열에 대한 구조적 비교 능력을 제공한다.

IEqualityComparer 인터페이스와 EqualityComparer 클래스

  • 이들은 기본 방식 이외의 상등 비교와 해시 계산 구현을 위한 상등 비교자(equality comparer)를 만드는데 쓰인다. 그러한 상등 비교자는 주로 Dictionary 클래스와 Hashtable 클래스에 유용하다.
  • 해시테이블 기반 사전은 임의의 키가 주어졌을 때 다음 두 질문의 답을 얻을 수 있어야 한다.
    • 그 키와 같은(상등) 키가 존재하는가?
    • 그 키의 정수 해시코드는 무엇인가?
  • 상등 비교자는 IEqualityComaparer 인터페이스를 구현함으로써 그러한 질문에 답한다.
public interface IEqualityComparer<T>
{
  bool Equals(T x, T y);
  int GetHashCode (T obj);
}

// 비제네릭 버전
public interface IEqualityComparer
{
  bool Equals(object x, object y);
  int GetHashCode(object obj);
}
  • 상등 비교자를 작성할 때는 이 두 인터페이스 중 하나 또는 둘 다를 구현한다 (둘 다 구현하면 상호운용성이 극대화 된다.) 그런데 그 두 인터페이스를 구현하는 것은 다소 지루한 일이다. 그 대신 다음과 같이 정의된 추상 클래스 EqualityComparer를 상속하는 방법도 있다.
public abstract class EqualityComparer<T> : IEqualityComparer, IEqualityComparer<T>
{
  public abstract bool Equals(T x, T y);
  public abstract int GetHashCode(T obj);

  bool IEqualityComparer.Equals(object x, object y);
  int IEqualityComparer.GetHashCode(object obj);

  public static EqualityComparer<T> Default { get; }
}
  • EqualityComparer 클래스는 두 인터페이스를 모두 구현하고 있으므로 상등 비교자 클래스 작성자는 그냥 추상 메서드 두 개만 재정의하면 된다.
  • Equals와 GetHashCode의 의미론은 앞선 object.Equals 및 object.GetHashCode와 동일한 규칙을 따른다.
    • 다음은 Customer 클래스와 두 고객ㄱ의 상댕을 비교하는 상등 비교자의 예이다. 상등 비교자는 성과 이름이 둘 다 같을 때만 두 고객이 상등이라고 판정한다.
public class Customer
{
  public string LastName;
  public string FirstName;
  public Customer(string last, string first)
  {
    LastName = last; FirstName = first;
  }
}

public class LastFirstEqComparer : EqualityComparer<Customer>
{
  public override bool Equals (Customer x, Customer y)
     => x.LastName == y.LastName && x.FirstName == y.FirstName;

  public override int GetHashCode(Customer obj)
     => (obj.LastName + ";" + obj.FirstName).GetHashCode();
}

// 위 클래스를 기준으로 객체 생성
Customer c1 = new Customer("Bloggs", "Joe");
Customer c2 = new Customer("Bloggs", "Joe");

// object.Equals를 재정의하지 않았기 때문에 이 둘에 대해 참조 상등 의미론이 적용된다.
Console.WriteLine(c1 == c2);  // false
Console.WriteLine(c1.Equals(c2));  // false

// 이 고객들을 상등 비교자를 지정하지 않고 Dictionary에 사용하면 역시 동일한 참조 상등 의미론이 적용된다.
var d = new Dictionary<Customer, string> ();
d[c1] = "Joe";
Console.WriteLine(d.ContainKey(c2));  // false

// 커스텀 상등 비교자를 지정하면 두 고객이 같다는 결과를 얻을 수 있다.
var eqComparer = new LastFirstEqComparer();
var d = new Dictionary<Customer, string>(eqComparer);
d[c1] = "Joe";
Console.WriteLine(d.ContainKey(c2));  // true

EqualityComparer<T>.Default 메서드

  • EqualityComparer<T>.Default를 호출하면 정적 object.Equals 메서드 대신 사용할 수 있는 범용 상등 비교자가 반환된다.
    • 그 상등 비교자의 장점은 먼저 T가 IEquatable<T>를 구혆나ㅡㄴ지 점건해서 만일 구현한다면 해당 구현을 사용하므로 박싱 부담이 없다는 것이다. 이는 제네릭 메서드에서 특히나 유용하다.

IComparer 인터페이스와 Comparer 클래스

  • 이들은 기본 방식 이외의 순서 비교 구현을 위한 순서 비교자를 만드는데 쓰인다. 그러한 순서 비교자는 주로 정렬된 사전과 컬렉션에 유용하다.
  • 순서 비교자는 Dictionary나 Hashtable 같은 정렬되지 않은 사전에는 쓸모가 없음을 주의하기 바란다. 그런 사전에 필요한 것은 해시코드를 얻기 위한 IEqualityComparer이다.
    • 마찬가지로 상등 비교자는 정렬된 사전과 컬렉션에는 쓸모가 없다.
  • 다음은 IComparer 인터페이스의 정의이다.
public interface IComparer
{
  int Compare(object x, object y);
}

public interface IComparer <in T>
{
  int Compare(T x, T y);
}
  • 상등 비교자처럼 인터페이스들을 직접 구현하는 대신 상속을 통해서 순서 비교자르 ㄹ만드는데 사용할 수 있는 추상 클래스가 있다.
public abstract class Comparer<T> : IComparer, IComparer<T>
{
  public static Comparer<T> Default { get; }
  public abstract int Compare(T x, T y);  // 작성자가 직접 구현
  int IComparer.Compare(object x, object y);  // 구현이 제공됨
}
  • 다음은 소원을 나타내는 클래스와 소원들을 그 우선순위대로 정렬하기 위한 순서 비교자의 예이다.
class Wish
{
  public string Name;
  public int Priority;
  public Wish(string name, int priority)
  {
    Name = name; Priority = priority;
  }
}

class PriorityComparer : Comparer<Wish>
{
  public override int Compare(Wish x, Wish y)
  {
    if (object.Equals(x, y)) return 0;
    return x.Priority.CompareTo(y.Priority);
  }
}

// PriorityComparer를 이용해서 List를 정렬한다.
var wishList = new List<Wish>();
wishList.Add(new Wish("평화", 2));
wishList.Add(new Wish("부자 되기", 3));
wishList.Add(new Wish("사랑", 2));
wishList.Add(new Wish("소원 세 개 더", 1));

wishList.Sort(new PriorityComparer());

foreach (Wish w in wishList) Console.Write(w.Name + " | ");  // output: 소원 세 개 더 | 사랑 | 평화 | 부자 되기 |
  • (또 다른 예제 생략)

StringComparer 클래스

  • StringComparer는 미리 정의된 플러그인 클래스이다. 이것을 이용하면 문자열의 상등 비교나 순서 비교시 언어나 대소문자 구별 여부를 임의로 지정할 수 있다.
    • StringComaparer 클래스는 IEqualityComparer와 IComparer를 모두 구현하므로, 그 어떤 형식의 사전이나 정렬된 컬렉션에도 사용할 수 있다.
    • (StringComaparer 내부 모습 생략)
  • StringComparer는 추상 클래스이므로 직접 인스턴스화 할 수는 없다. 대신 적절한 인스턴스를 돌려주는 정적 메서드들과 속성이 갖추어져 있는데, StringComparer.Ordinal은 기본 방식의 문자열 상등 비교 구현에 해당하고 StringComparer.CurrentCulture는 기본 방식의 문자열 순서 비교 구현에 해당한다.
// 아래 예는 대소문자를 구분하지 않는 순서 있는 사전을 생성한다. 
// 대소문자를 구분하지 않으므로 dict["Joe"]와 dict["JOE"]는 같은 항목으로 간주된다.
var dict = new Dictionary<string, int> (StringComparer.OrdinalIgnoreCase);

// 다음은 이름들의 목록을 호추식 영어를 이용해서 정렬하는 예이다.
string[] names = { "Tom", "HARRY", "sheila" };
CultureInfo ci = new CultureInfo("en-AU");
Array.Sort<string>(names, StringComparer.Create(ci, false));
  • (이하 예제 생략)

IStructuralEquatable 인터페이스와 IStructuralComparable 인터페이스

  • 구조체의 기본적인 상등 비교 방식은 구조적 비교(structural comparison)이다.
    • 이 상등 비교 방식에서 두 구조체는 그 필드들이 모두 상등이면 상등이다. 순서 비교 역시 모든 필드를 차례로 비교하는 방식으로 진행된다.
    • 그런데 이러한 구조적 상등 및 순서 비교 방식을 배열이나 튜플처럼 구조체가 아닌 형식에 적용하고 싶을 때가 있다.
    • .NET Framework 4.0에는 구조적 비교 의미론을 플러그인 형태로 적용하기 위한 다음과 같은 인터페이스들이 추가되었다.
public interface IStructuralEquatable
{
  bool Equals(object other, IEqualityComparer comparer);
  int GetHashCode(IEqualityComparer comparer);
}

public interface IStructuralComparable
{
  int CompareTo(object other, IComparer comparer);
}
  • 이 인터페이스의 메서드들은 인수로 주어진 IEqualityComparer 또는 IComparer 객체를 합성 객체의 각 요소에 적용한다.
    • 아래는 이 인터페이스들을 구현하는 배열과 튜플의 예이다.
int[] a1 = { 1, 2, 3 };
int[] a2 = { 1, 2, 3 };
IStructuralEquatable se1 = a1;
Console.Write(a1.Equals(a2));  // false
Console.Write(se1.Equals(a2, EqualityComparer<int>.Default));  // true

var t1 = Tuple.Create(1, "foo");
var t2 = Tuple.Create(1, "FOO");
IStructuralEquatable se1 = t1;
bool isTrue = se1.Equals(t2, StringComparer.InvariantCultureIgnoreCase);
ISturecturalComparable sc1 = t1;
int zero = sc.CompareTo(t2, StringComparer.InvariantCultureIgnoreCase);
  • 배열과 다른 점이라면 튜플은 기본 상등 및 비교 구현들이 이미 구저적 비교를 사용한다는 점이다.
[ssba]

The author

지성을 추구하는 디자이너/ suyeongpark@abyne.com

댓글 남기기

This site uses Akismet to reduce spam. Learn how your comment data is processed.