C# 6.0 완벽 가이드/ C#의 사용자 정의 형식

클래스

메서드

메서드에 적용할 수 있는 수정자들

정적 수정자 static
접근 수정자 public, internal, private, protected
상속 수정자 new, virtual, abstract, override, sealed
부분 메서드 수정자 partial
비관리 코드 수정자 unsafe, extern
비동기 코드 수정자 async

식 본문 메서드 (C# 6)

int Foo (int x) { return x * 2; }

int Foo (int x) => x * 2; // 위 코드와 동일

void Foo (int x) => Console.WriteLine(x); // 반환형이 void인 함수도 이런 식으로 사용할 수 있다.
  • 위의 코드와 같이 표현식이 본문인 메서드(expression-bodied method)를 줄여서 식본문 메서드라고 부른다. 중괄호와 return 키워드 대신 ‘이중 화살표’가 쓰였다.

생성자의 중복 적재

  • 클래스나 구조체에서 생성자도 중복적재가 가능하다. 생성자를 중복적재한 경우 한 생성자에서 this 키워드를 이용해서 다른 생성자를 호출함으로써 중복을 피할 수 있다.
using System;

public class Wine
{
  public decimal Price;
  public int Year;
  public Wine (decimal price) { Price = price; }
  public Wine (decimal price, int year) : this (price) { Year = year; }
}

속성

  • 속성(property)은 겉으로 보기에는 필드 같지만 내부적으로는 메서드처럼 특정한 논리 코드를 가진 멤버이다.

식 본문 속성 (C# 6)

  • C#6 부터는 읽기 전용 속성을 본문이 하나의 표현식인 형태로 간결하게 정의할 수 있다.
public decimal Worth => currentPrice * sharesOwned;

속성 초기치 (C# 6)

  • C# 6 부터는 자동 속성에도 필드에 하는 것처럼 초기치를 지정할 수 있다. 이를 속성 초기치라 부른다.
public decimal CurrentPrice { get; set; } = 123;
public int Maximum { get; } = 999;

CLR 속성 구현

  • C# 속성 접근자들은 내부적으로 이름이 get_XXX와 set_XXX 형태인 메서드로 컴파일 된다.
  • 단순한 비가상(nonvirtual) 속성 접근자들은 JIT(just-in-time) 컴파일러가 인라인화(inlining)한다. 이 덕분에 속성 접근과 필드 접근의 성능상 차이가 사라진다.
    • 인라인화는 메서드 호출을 그 메서드의 본문으로 대체하는 최적화 기법이다.
    • WinRT 속성의 경우 컴파일러는 set_XXX 대신 put_XXX 형태의 이름을 사용한다.
public decimal get_CurrentPrice { ... }
public void set_CurrentPrice (decimal value) { ... }

인덱서

  • 인덱서는 값들의 목록이나 사전을 캡슐화하는 클래스나 구조체에서 특정값에 자연스러운 구문으로 접근하기 위한 기능을 제공하는 멤버이다.

인덱서의 구현

  • 인덱서(indexer)를 작성할 때는 this라는 이름으로 속성을 선언하되, 대괄호쌍 안에 색인 매개변수들을 지정한다.
class Sentence
{
  string[] words = "The quick brown fox".Split()";

  public string this [int wordNum]
  {
    get { return words[wordNum]; }
    set { words[wordNum]  = value; }
  }
}
  • set 접근자를 생략하면 읽기 전용 인덱서가 된다. C# 6 부터는 표현식을 본문으로 지정해서 인덱서를 좀 더 간결하게 정의할 수 있다.
public string this [int wordNum] => words[wordNum];

CLR 인덱서 구현

  • 컴파일러는 내부적으로 인덱서를 다음과 같은 형태의 get_Item 메서드와 set_Item 메서드로 바꾸어서 컴파일 한다.
public string get_Item(int wordNum) { ... }
public void set_Item(int wordNum, string value) { ... }

상수

  • 상수(constant) 필드는 값을 결코 바꿀 수 없는 정적 필드이다. 상수는 컴파일 시점에서 정적으로 평가되며 컴파일러는 상수가 쓰이는 곳마다 그 값을 직접 박아 넣는다. (C++의 매크로와 상당히 비슷하다)
  • 사용할 수 있는 형식 면에서나 필드 초기화 방식에서나 상수는 static readonly 필드보다 훨씬 제한적이다. 또한 컴파일 시점에서 평가된다는 점 역시 static readonly 필드와 다른 점이다.
  • PI(원주율) 처럼 결코 변하지 않는 값은 상수로 두는 것이 합당하지만, 응용 프로그램마다 다른 값일 수 있는 필드는 static readonly로 만드는 것이 낫다.
    • 이후 버전에서 달라질 수도 있는 값을 다른 어셈블리들에 노출할 때에도 static readonly 필드가 낫다. 예컨대 어떤 어셈블리 X가 프로그램 버전을 상수로 정의하고 다른 어셈블리 Y가 해당 상수를 참조한다면, 나중에 프로그램 버전을 수정해서 X를 다시 컴파일하더라도 Y는 여전히 예전 프로그램 버전을 사용한다. Y를 다시 컴파일해야 비로소 새로운 버전으로 변경이 된다. static readonly는 이런 문제가 없으므로 static readonly를 쓰는 편이 낫다.
    • 나중에 변할 수도 있는 값은 애초에 상수의 정의에 맞지 않으므로 상수로 두지 말아야 한다.

정적 생성자

  • 정정 생성자는 인스턴스를 생성할 때마다 실행되는 것이 아니라 형식 자체에 대해 한 번만 실행된다. 하나의 형식에 정적 생성자를 하나만 정의할 수 있다. 정적 생성자는 매개변수가 없어야 하며 반환 형식은 해당 형식 자체이다.
  • 런타임은 형식이 쓰이기 직전에 자동으로 정적 생성자를 호출한다. 구체적으로 아래 둘 중 하나가 발생하면 정정 생성자가 호출된다.
    • 형식을 인스턴스화 한다.
    • 형식의 정적 멤버에 접근한다.
  • 정정 생성자에 적용할 수 있는 수정자는 unsafe와 extern 뿐이다.
class Test
{
  static Test() { Console.WriteLine ("Type Initializer"); }
}

종료자

  • 종료자(finalizer)는 클래스에만 둘 수 있는 함수 멤버로, 더는 참조되지 않는 객체의 메모리를 쓰레기 수거기가 가져가기 전에 실행된다.
  • 종료자를 선언할 때는 클래스 이름 앞에 ~ 기호를 붙인다.
class Class1
{
  ~Class1() { ... }
}
  • 이는 사실 Object의 Finalize 메서드를 재정의하는 코드를 간결하게 표현하기 위한 C#의 구문이다. 컴파일러는 종료자 선언을 다음과 같은 메서드 선언으로 확장한다.
protected override void Finalize()
{
  ...
  base.Finalize();
}

nameof 연산자 (C# 6)

  • nameof 연산자는 임의의 기호 이름에 해당하는 문자열을 돌려준다.
  • 필드나 속성 같은 형식 멤버의 이름을 얻으려면 그것이 속한 형식의 이름도 지정해야 한다.
    • 이는 정적 멤버와 인스턴스 멤버 모두 마찬가지다.
int count = 123;
string name = nameof(count); // name은 "count"

상속

캐스팅과 참조 변환

  • 객체 참조에 대해 다음 2가지 캐스팅이 가능하다.
    • 기반 클래스 참조로의 암묵적 상향 캐스팅(upcasting)
    • 파생 클래스 참조로의 명시시적 하향 캐스팅(downcasting)
  • 호환되는 형식들 사이의 상향 캐스팅과 하향 캐스팅에 의해 참조 변환(reference conversion)이라는 연산이 수행된다. 이 연산에 의해 새로운 참조가 생성되는데 그 참조는 원래의 참조가 가리키는 것과 동일한 객체를 가리킨다.

as 연산자

  • as 연산자도 하향 캐스팅을 수행한다. ( )를 이용한 하향 캐스팅과 다른 것은 하향 캐스팅이 실패한 경우 null로 평가 된다는 점이다.
  • as 연산자는 커스텀 변환을 수행하지 못하며, 수치 변환도 수행하지 못한다.

is 연산자

  • is 연산자는 주어진 참조 변환의 성공 여부를 판정해 준다. 다른 말로 하면 만일 연산자 왼쪽에 있는 객체의 형식이 오른쪽에 있는 클래스에서 파생된 클래스이면 이 연산자는 참으로 평가된다.
    • 흔히 하향 캐스팅을 수행하기 전에 이러한 판정을 수행한다.
  • is 연산자는 언박싱 변환(unboxing conversion)의 성공 여부도 판정해 준다. 그러나 커스텀 변환이나 수치 변환은 고려하지 않는다.

상속된 멤버 숨기기

  • 기반 클래스와 파생 클래스에 동일한 멤버가 존재할 수 있는데, 대부분의 경우 이는 프로그래머의 실수이지만, 프로그래머가 의도적으로 멤버를 가리려는 경우 파생 클래스의 멤버에 new 수정자를 지정하면 된다.
    • new 수정자는 단지 컴파일러가 멤버 숨기기에 관한 경고를 내지 않게 만드는 역할만 한다.
  • C#에서 new 키워드는 문맥에 따라 여러 의미로 쓰인다. 이 new 멤버 수정자는 new 연산자와는 다른 것이다.
public class A { public int Count = 1; }
public class B : A { public new int Count = 2; }

생성자와 상속

  • 파생 클래스는 반드시 자신의 생성자를 선언해야 한다. 파생 클래스에서 기반 클래스의 생성자에 접근할 수는 있지만, 기반 클래스 생성자가 자동으로 상속되지는 않는다.

Object 형식

  • object 형식은 하나의 클래스이며, 따라서 참조 형식이다.
  • 그러나 int 같은 값 형식과 object 사이의 캐스팅도 가능하다. C#의 이러한 특징을 형식 통합(type unification)이라고 부른다.
    • C# 코드에서 값 형식과 object 사이의 캐스팅을 실행하면 CLR은 값 형식과 참조 형식의 의미론 차이를 메우기 위해 몇 가지 특별한 작업을 수행하는데, 박싱과 언박싱이 바로 그것이다.

박싱과 언박싱

  • 값 형식 인스턴스를 참조 형식 인스턴스로 변환하는 것을 박싱(boxing)이라 한다. 이때 참조 형식은 반드시 인터페이스나 object 클래스여야 한다.
  • 언박싱은 그 반대의 과정, 즉 object를 다시 원래의 값 형식으로 캐스팅하는 것이다.
  • 언박싱에는 명시적 캐스팅이 필수이다. 런타임은 주어진 값 형식이 실제 객체 형식과 일치하는지 점점해서 만일 부합하지 않으면 Invalid CastException 예외를 던진다. 아래 코드는 예외를 발생 시키는데 long이 int와 정확하게 부합하지 않기 때문이다.
object obj = 9;  // 9는 int 형식으로 추론 됨
long x = (long) obj;  // InvalidCastException

박싱과 언방식의 복사 의미론

  • 박싱은 값 형식 인스턴스를 새 객체로 복사하고, 언박싱은 객체의 내용을 다시 값 형식 인스턴스로 복사한다. 다음의 예에서 i의 값을 바꾸어도 그 전에 박싱된 복사본은 변하지 않는다.
int i = 3;
object boxed = i;
i = 5;
Console.WriteLine (boxed);  // 3

GetType 메서드와 typeof 연산자

  • C#의 모든 형식은 실행시점에서 System.Type의 인스턴스로 표현된다. System.Type 객체를 얻는 방법은 기본적으로 다음의 2가지이다.
    • 인스턴스에 대해 GetType을 호출한다.
    • 형식 이름에 대해 typeof 연산자를 적용한다.
  • GetType은 실행시점에서 평가되는 반면, typeof는 컴파일 시점에서 정적으로 평가된다.

ToString 메서드

  • ToString 메서드는 주어진 형식 인스턴스의 기본적인 텍스트 표현을 돌려준다. 이 메서드는 C#의 모든 내장 형식에 대해 재정의되어 있다.
    • 커스텀 형식에서는 프로그래머가 ToString 메서드를 직접 재정의할 수 있다. 커스텀 형식에서 ToString을 재정의하지 않으면 이 메서드는 해당 형식의 이름을 돌려준다.
public class Panda
{
  public string Name;
  pubic override string ToString() => Name;
}

Panda p = new Panda { Name = "Petey" };
Console.WriteLine(p);  // Petey

구조체

  • 구조체(struct)는 클래스와 비슷하나 다음과 같은 중요한 차이가 있다.
    • 구조체는 값 형식이고 클래스는 참조 형식이다.
    • 구조체는 상속을 지원하지 않는다.
  • 구조체는 클래스가 지원하는 요소 중 다음을 제외한 모든 요소를 지원한다.
    • 매개변수 없는 생성자
    • 필드 초기화
    • 종료자
    • 가상 멤버와 보호된 멤버
  • 구조체는 값 형식 의미론이 바람직한 경우에 적합하다.

접근 수정자

  • 캡슐화를 강화하는 목적으로 형식이나 형식의 멤버의 접근성을 설정할 수 있다. 하나의 선언에서 아래의 수정자 중 하나만 지정할 수 있다.
public 모든 형식과 어셈블리가 적용할 수 있다.
internal 형식이 속한 어셈블리나 그 어셈블리와 ‘친구(friend)’ 관계인 어셈블리에서만 접근할 수 있다.
private 멤버가 속한 형식 안에서만 접근할 수 있다.
protected 멤버가 속한 형식 또는 그 형식의 파생 형식들에서만 접근할 수 있다.
protected internal protected와 internal 접근성의 합집합

 

인터페이스

  • 인터페이스(interface)는 클래스와 비슷하되, 그 멤버들을 실제로 구현하는 대신 명세(specification)만 제공한다는 점이 다르다. 인터페이스는 다음과 같은 면에서 특별하다.
    • (인터페이스는 멤버를 상속 받는게 아니라, 해당 인터페이스의 멤버를 구현하겠다는 약속에 가깝다. 여러 개의 인터페이스를 상속 받을 수 있는 것도 같은 맥락.)
    • 모든 인터페이스 멤버들은 암묵적으로 추상이다. 반면 클래스는 추상 멤버들과 구체 멤버들을 모두 제공할 수 있다.
    • 하나의 클래스가 여러 개의 인터페이스를 구현할 수 있다. 그러나 하나의 클래스가 상속할 수 있는 클래스는 단 하나이다.
  • 인터페이스의 모든 멤버는 암묵적으로 추상이기 때문에 구현을 제공할 수 없다. 인터페이스의 멤버들은 그 인터페이스를 상속하는 클래스에서 구현해야 한다.
  • 인터페이스가 가질 수 있는 멤버의 종류는 메서드, 속성, 이벤트, 인덱서 뿐인데, 이는 클래스에서 추상 멤버가 될 수 있는 멤버들과 정확히 일치한다.

인터페이스의 확장

  • 한 인터페이스에서 다른 인터페이스를 파생하는 것도 가능하다. 파생 인터페이스는 부모 인터페이스의 모든 멤버를 상속 받기 때문에, 파생 인터페이스를 구현하는 형식은 반드시 부모 인터페이스의 멤버들도 구현해야 한다.

명시적 인터페이스 구현

  • 한 클래스가 여러 개의 인터페이스를 구현하다 보면 멤버 서명들이 충돌할 수 있는데, 그런 경우 특정 인터페이스의 멤버를 명시적으로 구현하면 문제를 해결할 수 있다.
interface I1 { void Foo(); }
interface I2 { int Foo(); }

public class Widget : I1, I2
{
  public void Foo()
  {
    Console.WriteLine("Widget의 I1.Foo 구현");
  }
  int I2.Foo()
  {
    Console.WriteLine("Widget의  I2.Foo 구현");
    return 42;
  }
}

Widget w = new Widget
w.Foo();  // widget의 I1.Foo
((I1)w).Foo();  // widget의 I1.Foo
((I2)w).Foo();  // widget의 I2.Foo

인터페이스 멤버의 가상 구현

  • 암묵적으로 구현된 인터페이스 멤버들은 기본적으로 봉인된(sealed) 멤버이다. 파생 클래스에서 그런 멤버를 재정의하려면 기반 클래스에서 명시적으로 virtual이나 abstract를 지정해야 한다.
public interface IUndoable { void Undo(); }

public class TextBox : IUndoable
{
  public virtual void Undo() => Console.WriteLine("TextBox.Undo");
}

public class RichTextBox : TextBox
{
  public override void Undo() => Console.WriteLine("RichTextBox.Undo");
}

RichTextBox r = new RichTextBox();
r.Undo();  // RichTextBox.Undo
((IUndoable)r).Undo();  // RichTextBox.Undo
((TextBox)r).Undo();  // RichTextBox.Undo

파생 클래스에서 인터페이스를 재구현

  • 파생 클래스는 기반 클래스에서 이미 구현한 임의의 인터페이스 멤버를 다시 구현할 수 있다. 이러한 재구현은 기존의 멤버 구현을 ‘하이재킹’하는 것에 해당하는 것으로 기반 클래스에서 해당 멤버가 virtual로 선언되어 있든 아니든 언제나 가능하다. 또한 해당 멤버가 암묵적으로 구현되어 있든 명시적으로 구현되어 있든 가능하다.
public interface IUndoable { void Undo(); }

public class TextBox : IUndoable
{
 public void IUndoable.Undo() => Console.WriteLine("TextBox.Undo");
}

public class RichTextBox : TextBox, IUndoable
{
 public void Undo() => Console.WriteLine("RichTextBox.Undo");
}

RichTextBox r = new RichTextBox();
r.Undo(); // RichTextBox.Undo
((IUndoable)r).Undo(); // RichTextBox.Undo
((TextBox)r).Undo(); // TextBox.Undo

인터페이스와 박싱

  • 구조체를 인터페이스로 변환하면 박싱이 발생한다. 그러나 암묵적으로 구현된 멤버를 구조체에 대해 호출하면 박싱은 일어나지 않는다.
interface I { void Foo(); }
struct S : I { public void Foo() { } }

S s = new S();
s.Foo();  // 박싱 없음

I i = s;  // 인터페이스로의 캐스팅에서 박싱이 발생
i.Foo();

클래스 작성 대 인터페이스 작성

  • 다음은 언제 클래스를 사용하고 언제 인터페이스를 사용할 것인지에 관한 일반적인 지침이다.
    • 구현을 공유하는 것이 자연스러운 형식들에는 클래스와 파생 클래스를 사용한다.
    • 구현이 각자 독립적인 형식들에 대해서는 인터페이스를 사용한다.

열거형

  • 열거형(enum type)은 일련의 수치 상수들에 이름을 붙일 수 있는 특별한 값 형식이다.
  • 열거형의 각 멤버에는 바탕 정수 값이 있다. 기본적으로,
    • 바탕 값들의 형식은 int이고, 열거형 멤버들은 그 선언 순서대로 상수 0, 1, 2가 자동으로 배정된다.
    • int 이외의 정수 형식을 지정하는 것도 가능하다.
    • 또한 각 열거형 멤버에 명시적으로 바탕 정수 값을 배정할 수도 있다.

열거형의 변환

  • enum 인스턴스를 그 바탕 정수 값으로 또는 그 반대로 변환할 수 있다. 두 경우 모두 명시적 캐스팅이 필요하다.
  • 한 열거형을 다른 열거형으로 변환할 수도 있는데, 역시 명시적 캐스팅이 필요하다.

Flags 열거형

  • 열거형의 멤버들을 조합해서 사용할 수 있다. 이때 중의성(ambiguity)이 발생하지 않도록 조합 가능한 열거형의 멤버들에 값들을 명시적으로 배정할 필요가 있는데, 흔히 2의 거듭제곱수들을 배정한다.
[Flags]
public enum BorderSides { None = 0, Left = 1, Right = 2, Top = 4, Bottom = 8 }
  • 조합된 열거형 값들을 다룰 때는 | 나 & 같은 비트별 연산자를 사용한다. 이들은 열거형 인스턴스의 바탕 정수 값들에 대해 작용한다.
BorderSides topLeft = BorderSides.Top | BorderSides.Left;
string formatted = topLeft.ToString(); // "Left, Top"

BorderSides s = BorderSides.Left;
s |= BorderSides.Top;
Console.WriteLine(s == topLeft);  // True

s ^= BorderSides.Top;  // BorderSides.Top을 켠다.
Console.WriteLine(s);  // Left
  • 멤버들을 조합할 수 있는 열거형에는 항상 Flags 특성을 붙이는 것이 관례이다. Flags 특성을 붙이지 않은 enum 멤버들도 조합할 수 있지만, 그런 enum의 인스턴스에 ToString을 호출하면 이름들의 목록이 아니라 해당 바탕 수치가 반환된다.
    • 또한 조합 가능한 열거형의 이름으로는 단수형이 아니라 복수형을 사용하는 것이 관례이다.

제네릭

  • C#에는 여러 형식들에서 재사용할 수 있는 코드를 작성하기 위한 메커니즘이 2가지가 있는데, 하나는 상속이고, 다른 하나는 제네릭(Generic)이다.
  • 상속은 기반 형식을 이용해서 재사용성을 표현하는 반면, 제네릭은 ‘자리표(placeholder)’에 해당하는 형식들을 담은 ‘템플릿(template)’을 통해서 재사용성을 표현한다.
  • 상속과 비교할 때 제네릭을 사용하면 형식 안전성이 증가하고 캐스팅과 박싱이 줄어든다.
    • C#의 제네릭과 C++의 템플릿은 비슷한 개념이긴 하지만 그 작동 방식이 다르다.

제네릭 형식

  • 제네릭 형식은 형식 매개변수(type parameter)들을 선언한다. 형식 매개변수는 제네릭 형식이 실제로 쓰일 때 해당 코드가 제공한 실제 형식들이 대신할 자리를 표시하는 ‘자리표(placeholder)’에 해당한다. 후자, 즉 형식 매개변수를 대신할 실제 형식을 형식 인수(type argument)라고 부른다.
public class Stack<T>
{
  int position;
  T[] data = new T[100];
  public void Push (T obj) => data[position++] = obj;
  public T Pop() => data[--position];
}

var stack = new Stack<int>();
stack.Push(5);
stack.Push(10);
int x = stack.Pop();  // x = 10
int y = stack.Pop();  // y = 5
  • Stack<int>는 암묵적으로 컴파일러가 Stack<T>의 정의에 있는 형식 매개변수 T에 형식 인수 int를 채워 넣어서 즉성에서 만들어 낸 형식이다. 이 Stack<int>에 문자열을 넣으려 하면 컴파일 오류가 발생한다.
  • 전문 용어로 Stack<T>를 열린 형식(open type)이라고 부르고, Stack<int>를 닫힌 형식(closed type)이라고 부른다. 실행 시점에서 모든 제네릭 형식 인스턴스는 닫힌 형식이다.

제네릭 메서드

  • 서명 안에 형식 매개변수가 있는 메서드를 제네릭 메서드라고 부른다.
static void Swap<T> (ref T a, ref T b)
{
  T temp = a;
  a = b;
  b = temp;
}

int x = 5;
int y = 10;
Swap(ref x, ref y);
  • C# 에서 형식 매개변수를 채용할 수 있는 코드 구축 요소는 메서드와 형식 뿐이다. 속성이나 인덱서, 이벤트, 필드, 생성자, 연산자 등은 형식 매개변수를 선언할 수 없다. 그러나 그런 구축 요소들도 자신이 속한 형식에 정의된 임의의 형식 매개변수를 사용하는 것은 얼마든지 가능하다.

typeof 연산자와 묶이지 않는 제네릭 형식

  • 실행시점에서 열린 제네릭 형식이 존재하지 않는다. 열린 제네릭 형식들은 모두 컴파일 과정에서 닫힌다. 그러나 실행시점에서 묶이지 않은 제네릭 형식이 존재할 수는 있다. 그런 형식은 하나의 Type 객체로서 존재한다. C#에서 묶이지 않은 제네릭 형식을 지정하는 수단은 typeof 연산자 뿐이다.
class A<T> { }
class A<T1, T2> { }

Type a1 = typeof(A<>);  // 묶이지 않은 형식(형식 인수가 없음을 주목)
Type a2 = typeof(A<,>);  // 형식 인수가 여러 개임을 나타내기 위해 쉼표를 사용

// typeof를 이용해서 닫힌 형식을 지정하는 것도 가능하다.
Type a3 = typeof(A<int, int>);

// 열린 형식(실행시점에서는 닫힌 형식으로 존재하는) 역시 가능하다.
class B<T> { void X() { Type t = typeof(T); } }

제네릭의 기본값

  • default 키워드를 이용해서 제네릭 형식 매개변수의 인스턴스의 기본값을 지정할 수 있다. 참조 형식의 기본값은 null이며, 값 형식의 기본값은 값 형식 필드들의 모든 비트를 0으로 설정한 결과이다.

제네릭 제약

  • 기본적으로 형식 매개변수에는 그 어떤 형식도 대입할 수 있다. 그러나 형식 매개변수에 제약 조건(constraint)을 지정함으로써, 형식 매개변수에 대입할 수 있는 형식 인수들을 좀 더 제한하는 것이 가능하다.
where T : 기반-클래스  // 기반 클래스 제약 조건
where T : 인터페이스 // 기반 인터페이스 제약 조건
where T : class  // 참조 형식 제약 조건
where T : struct  // 값 형식 제약 조건(널 가능 형식은 예외)
where T : new ()  // 매개변수 없는 생성자 제약 조건
where U : T  // 적나라한 형식 제약 조건

제네릭 형식의 파생

  • 제네릭 형식도 제네릭이 아닌 클래스처럼 파생할 수 있다.
class Stack<T> { ... }
class SpecialStack<T> : Stack<T> { ... }

class IntStack : Stack<int> { ... }

class List<T> { ... }
class KeyedList<T, TKey> : List<T> { ... }

자신을 참조하는 제네릭 형식

  • 파생 형식이 기반 형식의 형식 매개변수를 닫을 때, 파생 형식 자신을 형식 인수로 지정하는 것이 가능하다.
public interface IEquatable<T> { bool Equals (T obj); }
public class Balloon : IEquatable<Balloon>
{
  public string Color { get; set; }
  public int CC { get; set; }

  public bool Equals (Balloon b)
  {
    if (b == null) return false;
    return b.Color == Color && b.CC == CC;
  }
}

정적 자료 멤버

  • 정적 자료 멤버는 각각의 닫힌 형식마다 고윻다.
class Bob<T> { public static int Count; }

class Test
{
  static void Main ()
  {
    Console.WriteLine(++Bob<int>.Count);  // 1
    Console.WriteLine(++Bob<int>.Count); // 2
    Console.WriteLine(++Bob<string>.Count); // 1
    Console.WriteLine(++Bob<object>.Count); // 1
  }
}

형식 매개변수와 변환

  • C#의 변환시 어떤 변환이 수행될지는 컴파일러가 피연산자들의 알려진 형식에 기초해서 컴파일 시점에 결정한다. 그런데 제네릭 형식이나 제네릭 메서드를 정의하는 코드에서는 형식 매개변수의 구체적인 형식이 아직 주어진 상태가 아니기 때문에 컴파일러가 피연산자의 정확한 형식을 알아내지 못할 수 있다.
StringBuilder Foo<T> (T arg)
{
  if (arg is StringBuilder)
    return (StringBuilder) arg;  // 컴파일 되지 않음
}
  • 이런 상황에서 가장 간단한 해결책은 as 연산자를 이용해서 변환을 수행하는 것이다.
StringBuilder Foo<T> (T arg)
{
  StringBuilder sb = arg as StringBuilder;
  if (sb != null) return sb;
}
  • 좀 더 일반적인 해법은 object로 캐스팅 한 후 원하는 형식으로 캐스팅하는 것이다.
StringBuilder Foo<T> (T arg)
{
  return (StringBuilder) (object) arg;
}

공변성

  • A가 B로 변환할 수 있는 형식이라고 할 때, X<A>를 X<B>로 변환할 수 있으면 X의 형식 매개변수를 가리켜 ‘공변이다(covariant)’ 또는 ‘공변성(covariance)이 있다’라고 말한다.
    • (쉽게 얘기해서 자식은 부모의 모든 기능을 갖고 있기 때문에, 부모의 형식에 자식이 들어 올 수 있는 것.)
    • (공변성(out), 반변성(in) 이야기로 넘어가면 조금 복잡해 진다. 이 둘을 합친거를 가변성이라 한다.)
  • C# 4.0부터 인터페이스에 공변 형식 매개변수가 허용된다. 그러나 클래스에는 공변 형식 매개변수가 허용되지 않느다. 배열에도 공변이 허용된다.
  • 정적 형식 안전성을 보장하기 위해 C#은 형식 매개변수를 기본적으로 가변성이 없는 것으로 간주한다.
    • (부모에 자식을 할당하는 것 자체는 문제가 아닌데, 할당된 자리에 또 다른 자식을 넣을 경우 문제가 된다. 아래 코드 참조)
class Animal { }
class Bear : Animal { }
class Camel : Animal { }

public class Stack<T>
{
  int position;
  T[] data = new T[100];
  public void Push (T obj) => data[position++] = obj;
  public T Pop() => data[--position];
}

Stack<Bear> bears = new Stack<Bear>();
Stack<Animal> animals = bears  // 컴파일 시점 오류. 왜나하면 이것을 허용할 경우 아래와 같은 코드가 있을 수 있기 때문

animals.Push(new Camel());  // 만일 부모의 자리에 bear가 들어왔다면, bear의 자리에 또 다른 자식인 camel을 넣을 수 있는데, 이건 문제다.
  • 그러나 공변성이 없으면 코드를 재사용하기가 어렵다. 예컨대 동물들을 씻기는 Wash라는 메서드를 작성한다고 하자.
public class ZooCleaner
{
  public static void Wash (Stack<Animal> animals) { ... }
}

Stack<Bear> bears = new Stack<Bear>();
ZooCleanser.Wash(bears);  // 컴파일 시점에 에러
  • 위와 같은 오류를 우회하는 방법은 Wash  메서드에 제약 조건을 부여하는 것이다.
public class ZooCleaner
{
  public static void Wash<T> (Stack<T> animals) where T : Animal { ... }
}

Stack<Bear> bears = new Stack<Bear>();
ZooCleanser.Wash(bears); // 오류 없음

배열

  • 몇 가지 역사적인 이유로 C#의 배열 형식은 공변성을 지원하는데, 이러한 재사용성의 대가로 실행시점에서 배열 원소를 배정할 때 실패할 수 있다.
Bear[] bears = new Bear[3];
Animal[] animals = bears;  // 오류 없음

animal[0] = new Camel();  // 실행 시점에 오류

공변 형식 매개변수 선언

  • C# 4.0부터, 인터페이스와 대리자의 형식 매개변수를 선언할 때 out 수정자를 지정하면 공변 형식 매개변수가 된다. 배열과 달리 이 수정자로 선언한 공변 형식 매개변수는 완전한 형식 안전성을 제공한다.
public interface IPoppable<out T> { T Pop(); }

var bears = new Stack<Bear>();
bears.Push(new Bear());

// Bears는 IPoppable<Bear>를 구현하므로 IPoppable<Animal>로 변환할 수 있다.
IPoppable<Animal> animals = bears;  // 적법
Animal a = animals.Pop();
  • T에 대한 out 수정자는 이 T가 출력 위치에만(즉, 메서드의 반환 형식으로만) 쓰인다고 컴파일러에게 알려주는 역할을 한다. out 수정자로 선언한 형식 매개변수는 공변성을 가지며, 따라서 다음과 같은 용법이 가능해진다.
    • (return에만 공변성이 있다는 것을 컴파일러에게 선언해 두는 것. 그러면 컴파일러는 그것을 인정하여 위와 같은 코드가 적법해진다. 이와 반대로 매개변수에만 반변성이 있다는 것을 선언하는게 in 키워드. 이건 아래 내용 참조)
    • (메서드에 참조 형식을 넘기는 out 키워드와는 다른 의미다.)
  • 형식 매개변수가 공변이므로 컴파일러는 bears에서 animals로의 변환을 허용한다. 이는 형식에 안전한 변환이다. 컴파일러가 피하고자 하는, 형식 안전성에 문제가 생길만한 상황이 애초에 벌어지지 않기 때문이다. 다시 말해 위의 코드에서 Bear가 들어간 스택에 Camel을 집어 넣는 것이 애초에 불가능하다. (앞선 배열 예시에서 bear가 대입된 자리에 camel을 집어 넣은 것과 달리)
// 공변 캐스팅을 이용하여 위의 재사용성 코드를 해결한 코드
public class ZooCleaner
{
 public static void Wash (IPoppable<Animal> animals) { ... }
}

Stack<Bear> bears = new Stack<Bear>();
ZooCleanser.Wash(bears); // 오류 없음
  • 이상하게도 메서드의 매개변수에 out을 적용한다고 해서 그 매개변수가 공변성을 가지지는 않는다. 이는 CLR의 한계에서 비롯한 것이다.
  • 공변성(그리고 반변성)은 참조 변환이 적용되는 요소에만 작동할 뿐, 박싱 변환이 적용되는 요소에는 작동하지 않는다. (형식 매개변수 가변성 뿐만 아니라 배열 가변성도 그렇다) 예컨대 IPoppable<object> 형식의 매개변수를 받는 메서드를 IPoppable<string> 형식의 인수로 호출할 수는 있어도 IPoppable<int> 형식의 인수로는 호출할 수 없다.

반변성

  • 반변성(contravariance)은 공변성의 반대 방향의 변환을 의미한다. (자식에 부모를 대입) 반변성을 갖추려면 형식 매개변수가 오직 입력(input) 위치에만 쓰여야 하며, 형식 매개변수를 선언할 때 in 수정자를 지정해야 한다.
public interface IPushable<in T> { void Push(T obj); }

IPushable<Animal> animals = new Stack<Animal>();
IPushable<Bear> bears = animals;  // 적법
bears.Push(new Bear());  // 부모가 대입된 자리에 다시 자식을 대입하므로 공변성이다. 고로 문제 없음. 설령 또 다른 자식(Camel)을 push해도 문제 없다.
  • IPushable의 그 어떤 멤버도 T를 출력 용도로 사용하지 않으므로, animals를 bear로 캐스팅해서 문제가 생기는 상황은 벌어지지 않는다.
  • 인터페이스 IPushable<T>와 IPoppable<T>의 각 T는 각자 다른 방향의 가변성을 가지지만, 그래도 Stack<T> 클래스가 두 인터페이스를 모두 구현하는 것이 가능하다. 그런 클래스를 안전하게 사용하려면 형식 매개변수의 가변성이 클래스가 아니라 인터페이스를 통해서 작용하게 해야 한다. 즉, 어떤 가변 변환을 수행할 때는 반드시 IPoppable과 IPushable 중 하나만 택해야 한다. 선택된 인터페이스는 해당 가변성 규칙 하에서 적법한 연산들만 보여주는 일종의 렌즈로 작용한다.
    • 이는 클래스에 가변 형식 매개변수가 허용되지 않는 이유를 말해주는 요인이기도 하다. 대체로 구체적인 구현에서는 자료가 두 방향 모두로 흘러야 하는 경우가 많기 때문이다.

C# 제네릭 대 C++ 템플릿

  • C# 제네릭은 C++의 템플릿과 비슷한 용도로 쓰이지만, 그 작동 방식은 아주 많이 다르다. 두 메커니즘 모두 ‘생산자’와 ‘소비자’의 합성이 꼭 필요하다. 여기서 생산자는 자리표 형식들을 뜻하고 소비자는 그 자리에 채워질 형식 인수들을 뜻한다.
  • C# 제네릭에서는 생산자 형식(이를테면 List<T> 같은 열린 형식)을 하나의 라이브러리(이를테면 mscorlib.dll)로 컴파일할 수 있다. 이것이 가능한 이유는 생산자와 소비자의 합성(닫힌 형식을 만들어 내는)이 실행시점에서야 비로소 일어나기 때문이다.
  • 반면 C++에서는 그러한 합성이 전적으로 컴파일 시점에서 수행된다. 그래서 C++에서는 템플릿 라이브러리를 .dll로 만들어서 배포하는 것이 불가능하다. 템플릿은 전적으로 소스코드 형태로만 존재할 수 있다. 이러한 엄격한 컴파일 시점 특징 때문에, 매개변수화된 형식을 동적으로 조사하는 것은 물론이고 그런 형식을 즉석에서 만들어 내기도 쉽지 않다.
// C#에서의 제네릭 메서드.
// 비교를 지원하는 IComparable 인터페이스를 구현하는 클래스들 간의 비교를 구현한다.
static T Max <T> (T a, T b) Where T : IComparable<T> => a.CompareTo(b) > 0 ? a : b;

// 아래와 같은 코드가 불가능한 것은 모든 클래스가 비교를 지원하지 않기 때문. 당연한 이야기다.
static T Max <T> (T a, T b) => (a > b ? a : b); // 컴파일 시점 오류

// C++에서의 템플릿
template <class T> T Max (T a, T b)
{
  return a > b ? a : b;
}
  • 위의 코드에서 C#은 2번째 코드가 불가능하지만, C++은 맨 아래 코드가 가능한데, C++ 템플릿은 그 자체로 컴파일 되는 것이 아니라 소비자가 T에 대해 제공한 구체적인 형식 마다 개별적으로 컴파일 되기 때문이다. >의 의미는 해당 T에 정의된 바를 따른다. 만일 T로 주어진 구체적인 형식이 >를 지원하지 않으면 컴파일은 실패한다.
[ssba]

The author

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

댓글 남기기