C# 6.0 완벽 가이드/ 고급 C#

Contents

대리자

  • 대리자(delegate)는 어떤 메서드를 호출하는 방법을 담은 객체
  • 대리자 형식은 그 형식의 인스턴스, 즉 대리자 인스턴스가 호출할 수 있는 종류의 메서드를 정의한다.
  • 메서드를 대리자 변수에 배정하면 대리자 인스턴스가 생성된다.
  • 대리자 인스턴스는 이름 그대로 호출자의 대리자 역할을 한다. 즉, 호출자가 대리자를 호출하면 대리자가 대상 메서드를 대신 호출해 준다. 이러한 간접 호출에 의해, 호출자와 대상 메서드 사이의 결합(coupling)이 끊어진다.
// 다음과 같은 문장은
Transformer t = Square;

// 다음 문장을 줄여 쓴 것이다.
Transformer t = new Transformer(Square);

// 한편 다음과 같은 표현식은
t(3);

// 다음을 줄여 쓴 것이다.
t.Invoke(3);

대리자를 이용한 플러그인 메서드 작성

  • 대리자 변수에 메서드를 배정하는 연산은 실행시점에서 일어난다. 따라서 대리자는 플러그인 메서드를 구현하기에 좋은 수단이다.
public delegate int Transformer (int x);

class Util
{
  public static void Transform(int[] values, Transformer t)
  {
    for (int i = 0; i < values.Length; i++)
      values[i] = t(values[i]);
  }
}

class Test
{
  static void Main()
  {
    int[] values = { 1, 2, 3, };
    Util.Transform (values, Square);
    foreach(int i in values)
      Console.Write(i + " ");  // 1 4 9
  }

  static int Square (int x) => x * x;
}

다중 캐스트 대리자

  • 모든 대리자 인스턴스에는 다중 캐스트(multicast) 능력이 있다. 하나의 대리자 인스턴스가 여러 개의 대상 메서드를 지칭할 수 있다는 것이다.
  • 대리자 인스턴스에 +, += 연산자를 이용해서 새로운 대상 메서드를 추가한 후 호출하면 추가된 순서에 따라 모든 메서드가 호출된다.
    • -, -= 연산자는 대리자에서 메서드를 제거한다.
  • 대리자는 불변이(immutable) 객체이다. 따라서 +=나 -=를 호출하면 실제로는 새로운 대리자 인스턴스가 생성된 후 그것이 기존 변수에 배정된다.
  • 다중 캐스트의 대리자 반환 형식이 void가 아니라면 호출자는 마지막으로 호출된 메서드가 돌려준 값을 받게 된다.

다중 캐스트 대리자 예제

  • 시간이 오래 걸리는 작업을 수행하는 메서드의 경우 주기적으로 대리자를 호출함으로써 메서드 호출자에게 작업 진척 정도를 알려주는 것이 바람직할 것이다.
public delegate void ProgressReporter (int percentComplete);

public class Util
{
  public static void HardWork (ProgressReporter p)
  {
    for (int i = 0; i < 10 i++)
    {
      p (i * 10);
      System.Threading.Thread.Sleep(100); // 어려운 작업을 흉내냄.
    }
  }
}

class Test
{
  static void Main()
  {
    ProgressReporter p = WriteProgressConsole;
    p += WriteProgressToFile;
    Util.HardWork(p);
  }

  static void WriteProgressToConsole (int percentComplete)
    => Console.WriteLine(percentComplete);

  static void WriteProgressToFile(int percentComplete)
    => System.IO.File.WriteAllText("progress.txt",percentComplete.ToString());
}

대상 메서드로서의 인스턴스 메서드 대 정적 메서드

  • 인스턴스 메서드를 대리자 인스턴스에 등록하는 경우, 그 메서드를 제대로 호출하려면 대리자 인스턴스는 그 메서드에 대한 참조 뿐만 아니라 그 메서드가 속한 인스턴스에 대한 참조도 기억해야 한다. System.Delegate 클래스의 Target 속성이 바로 그 인스턴스를 나타낸다. (정적 메서드의 경우 이 속성은 null이 된다.)

제너릭 대리자 형식

  • 대리자 형식에 제네릭 형식 매개변수를 둘 수도 있다.
public delegate T Transform<T> (T arg);

표준 Func 대리자와 Action 대리자

  • 제네릭 대리자를 이용하면 임의의 반환 형식과 임의의 개수의 매개변수를 가진 그 어떤 메서드에도 작동할 정도로 일반적인 대리자 형식들 몇 개만 작성해서 재사용하는 것이 가능하다. System 이름공간에 정의된 Func 대리자들과 Action 대리자들이 바로 그러한 대리자이다.
  • 실무에서 이 대리자들로 해결되지 않는 유일한 시나리오는 ref, out 매개변수들과 포인터 매개변수 뿐이다.

대리자 대 인터페이스

  • 대리자로 풀 수 있는 문제는 인터페이스로도 풀 수 있다.
  • 다음 조건 중 하나 이상이 참이라면 인터페이스를 이용한 설계보다 대리자를 이용한 설계가 더 나은 선택일 수 있다.
    • 인터페이스가 메서드를 하나만 정의한다.
    • 다중 캐스팅 능력이 필요하다.
    • 구독자가 인터페이스를 여러 번 구현해야 한다.

대리자의 호환성

형식 호환성

  • 모든 대리자 형식이 다른 모든 대리자 형식과 호환되지 않는다. 심지어 서명이 같아도 호환되지 않는다.
delegate void D1();
delegate void D2();

D1 d1 = Method1;
D2 d2 = d1;  // 컴파일 시점 오류

// 그러나 아래는 허용된다.
D2 d2 = new D2(d1);
  • 메서드 대상이 동일한 대리자 인스턴스들은 서로 같다고 간주된다.
    • 다중 캐스트 대리자는 같은 대상 메서드들이 같은 순서로 등록되어 있어야 서로 같다고 간주된다.

매개변수 호환성

  • 어떤 메서드를 호출할 때, 그 메서드의 매개변수가 요구하는 것보다 더 구체적인 형식의 인수를 지정하는 것이 가능하다. 이는 다형성을 가진 메서드의 정상적인 작동 방식이다.
  • 이와 정확히 동일한 이유로 대리자의 매개변수 형식이 대상 메서드의 매개변수 형식보다 더 구체적일 수 있다. 이를 반변성이라 부른다.

반환 형식 호환성

  • 어떤 메서드를 호출할 때 호출자가 요구한 것보다 더 구체적인 형식의 값을 메서드가 돌려줄 수 있다. 이는 다형성을 가진 메서드의 정상적인 작동 방식이다.
  • 이와 정확히 같은 이유로 대리자는 대상 메서드의 반환 형식보다 더 구체적인 형식을 돌려줄 수 있다. 이를 공변성이라 부른다.

제네릭 대리자 형식 매개변수의 가변성

  • 제네릭 인터페이스의 형식 매개변수가 공변이거나 반변일 수 있는데, 대리자도 그와 같은 능력을 갖고 있다. (C# 4.0 부터)
  • 제네릭 대리자 형식을 정의할 때는 다음과 같은 관행을 따르는 것이 좋다.
    • 반환값에만 쓰이는 형식 매개변수는 공변으로 지정한다(out 수정자)
    • 매개변수에만 쓰이는 형식 매개변수는 반변으로 지정한다(in 수정자)

이벤트

  • 대리자를 사용하는 코드의 설계에는 방송자와 구독자라는 두가지 역할로 구성된 모형이 흔히 쓰인다.
    • 방송자(broadcaster)는 대리자 필드가 있는 형식을 말한다. 방송자는 적당한 때에 그 대리자를 호출해서 정보를 방송한다.
    • 구독자(subscribers)는 대리자가 호출할 대상 메서드를 등록하는 형식을 말한다. 구독자는 방송자의 대리자에 대해 +=나 -=를 호출함으로써 해당 방송의 청취를 시작하거나 중단한다. 이 모형에서 구독자는 다른 구독자를 간섭하지 않으며, 사실 다른 구독자의 존재를 아예 알지 못한다.
  • 이벤트는 이러한 모형 또는 패턴을 공식화하는 언어 기능이다. 구체적으로 이벤트는 대리자의 기능 중 방송자/구독자 모형에 필요한 기능들만 노출하는 객체이다. 이벤트의 주된 목적은 구독자들이 서로 간섭하지 못하게 하는 것이다.
  • 이벤트를 선언하는 가장 쉬운 방법은 대리자 멤버를 선언할 때 접근 수정자 다음에 event 키워드를 집어 넣는 것이다.
public delegate void PriceChangedHandler(decimal oldPrice, decimal newPrice);

public class Broadcaster
{
  public event PriceChangedHandler PriceChanged;
}
  • Broadcaster 형식 안의 코드에서는 PriceChanged의 모든 것에 접근할 수 있으며 PriceChanged를 하나의 대리자처럼 사용할 수 있다. Broadcaster 바깥의 코드에서는 PriceChanged 이벤트에 대해 += 연산과 -= 연산만 수행할 수 있다.

표준 이벤트 패턴

  • .NET Framework는 이벤트 작성을 위한 표준적인 패턴 하나를 정의한다. 이 패턴의 목적은 .NET Framework와 사용자 정의 코드 모두에서 일관성을 유지하기 위한 것이다.
  • 표준 이벤트 패턴의 핵심은 .NET Framework에 미리 정의되어 있는 클래스인 System.EventArgs 이다. 이 클래스에는 아무런 멤버도 없다. EventArgs는 이벤트에 관한 정보를 전달하기 위한 기반 클래스이다.
  • 앞의 Stock 예제를 이 패턴에 맞게 작성한다면, 우선은 EventArgs를 상속하는 다음과 같은 파생 클래스를 작성해야 한다. 이 클래스는 PriceChanged 이벤트를 발동할 때 기존 가격과 새 가격을 전달하는 용도로 쓰인다.
public class PriceChangedEventArgs : System.EventArgs
{
  public readonly decimal LastPrice;
  public readonly decimal NewPrice;
  public PriceChangedEventArgs (decimal lastPrice, decimal newPrice)
  {
    LastPrice = lastPrice;
    NewPrice = newPrice;
  }
}
  • EventArgs 파생 클래스를 정의한 다음에는 이벤트를 위한 대리자를 선택 또는 정의한다. 이 대리자는 다음 3가지 규칙을 지켜야 한다.
    • 반환 형식이 반드시 void 여야 한다.
    • 인수를 두 개 받아야 한다. 첫 인수의 형식은 object이고 둘째 인수의 형식은 EventArgs 파생 클래스이다. 첫 인수는 이벤트 방송자를 지정하고, 둘째 인수는 전달할 추가 정보를 담는다.
    • 이름이 반드시 EventHandler로 끝나야 한다.
  • .NET Framework에는 이 3 규칙을 모두 만족하는 System.EventHandler<>라는 제네릭 대리자가 정의되어 있다.
  • 다음 단계는 선택된 대리자 형식의 이벤트를 정의하는 것이다. 지금 예에서는 다음과 같은 제네릭 EventHandler 대리자를 사용한다.
public class Stock
{
  ...
  public event EventHandler<PriceChangedEventArgs> PriceChanged;
}
  • 마지막으로 표준 이벤트 패턴을 따르려면 이벤트를 발동하는 보호된 가상 메서드를 하나 작성해야 한다. 이 메서드는 그 이름이 반드시 이벤트 앞에 On을 붙인 것이어야 하며, EventArgs 형식의 인수 하나를 받아야 한다.
public class Stock
{
  ...
  public event EventHandler<PriceChangedEventArgs> PriceChanged;

  protected virtual void OnPriceChanged (PriceChangedEventArgs e)
  {
    if (PriceChanged != null)
      PriceChanged(this, e);
  }
}
// 다중 스레드 상황에서는 대리자를 먼저 임시 변수에 배정한 후 점검, 호출해야 스레드 안전성 관련 오류를 피할 수 있다.
var temp = PriceChanged;
if (temp != null) temp (this, e);

// 단, C# 6에서는 다음과 같이 널 조건부 연산자를 이용해서 temp 변수 없이도 같은 결과를 얻을 수 있다.
// 스레드에 안전할 뿐만 아니라 더 간결하다는 점에서 이것이 이벤트를 발동하는 최선의 일반적 방식이라 할 수 있다.
PriceChanged?.Invoke(this, e);
  • 이제 파생 클래스들이 이벤트를 호출하거나 재정의할 수 있는 하나의 기준점이 만들어 졋다. 아래는 이 예제의 전체 코드이다.
using System;

public class PriceChangedEventArgs : EventArgs
{
  public readonly decimal LastPrice;
  public readonly decimal NewPrice;

  public PriceChangedEventArgs(decimal lastPrice, decimal newPrice)
  {
    LastPrice = lastPrice; NewPrice = newPrice;
  }
}

public class Stock
{
  string symbol;
  decimal price;

  public Stock(string symbol) { this.symbol = symbol; }

  public event EventHandler<PriceChangedEventArgs> PriceChanged;

  protected virtual void OnPriceChanged (PriceChangedEventArgs e)
  {
    PriceChanged?.Invoke(this. e);
  }

  public decimal Price
  {
    get { return price; }
    set 
    { 
      if (price == value) return;
      decimal oldPrice = price;
      price = value;
      OnPriceChanged(new PriceChangedEventArgs(oldPrice, price));
    }
  }
}

class Test
{
  static void Main()
  {
    Stock stock = new Stock("THPW");
    stock.Price = 27.10M;
    stock.PriceChanged += stock_PriceChanged;
    stock.Price = 31.59M;
  }

  static void stock_PriceChanged(object sender, PriceChangedEventArgs e)
  {
    if ((e.NewPrice - e.LastPrice) / e.LastPrice > 0.1M)
      Console.WriteLine("Alert, 10% stock price increase!");
  }
}
  • 이벤트가 추가 정보를 전달하지 않는다면 미리 정의되어 있는 비제네릭 EventHandler 대리자를 사용해도 된다.
  • 다음은 가격이 바뀌었을 때 추가 정보 없이 PriceChanged 이벤트를 발생하도록 Stock을 재작성한 코드이다.
    • 또한 쓸데없이 EventArgs 인스턴스를 생성하지 않기 위해 EventArgs.Empty 속성을 사용했다는 점도 주목하길 바란다.
public class Stock
{
  string symbol;
  decimal price;

  public Stock (string symbol) { this.symbol = symbol; }

  public event EventHandler PriceChanged;

  protected virtual void OnPriceChanged(EventArgs e)
  {
    PriceChanged?.Invoke(this, e);
  }

  public decimal Price
  {
    get { return price; }
    set 
    {
      if (price == value) return;
      price = value;
      OnPriceChanged(EventArgs.Empty);
    }
  }
}

이벤트 접근자

  • 이벤트의 접근자들은 이벤트에 대한 +=와 -= 연산을 그 이벤트에 맞는 방식으로 구현하기 위한 것이다. 기본적으로 컴파일러가 암묵적으로 접근자들을 구현해 준다.
  • public event EventHandler PriceChaged 라는 이벤트에 대해 컴파일러는 다음과 같은 멤버들을 암묵적으로 생성한다.
    • 전용 대리자 필드 하나
    • 두 개의 공용 이벤트 접근자 함수(add_PriceChanged와 remove_PriceChanged). 이들은 += 연산과 -= 연산을 앞의 전용 대리자 필드에 전달한다.
  • 이와는 다른 방식의 접근자 구현을 원한다면 이벤트 접근자들을 명시적으로 정의하면 된다. 아래 코드 참조
private EventHandler priceChanged;

public event EventHandler PriceChanged
{
  add { priceChanged += value; }
  remove { priceChanged -= value; }
}
  • 컴파일러의 기본 구현 대신 명시적 이벤트 접근자를 이용하면 바탕 대리자의 저장과 접근에 대해 좀 더 복잡한 전략을 적용할 수 있다. 다음은 그런 접근방식이 유용할만한 시나리오 3가지이다.
    • 이벤트 접근자들이 그냥 다른 클래스에 이벤트 방송을 위임하는 역할만 한다.
    • 클래스가 많은 수의 이벤트를 노출하나, 대부분의 경우 그 이벤트들은 구독자가 극히 소수이다. 이런 상황이라면 구독자의 대리자 인스턴스를 사전 자료구조에 저장하는 것이 낫다. 널 대리자 필드 참조들을 수십 개씩 저장해야 하는 경우에 비해 사전의 저장소 추가 부담이 더 적기 때문이다.
    • 이벤트를 선언하는 인터페이스를 명시적으로 구현한다.

이벤트 수정자

  • 메서드처럼 이벤트에도 여러 수정자를 적용해서 가상, 재정의, 추상, 봉인 이벤트로 만들 수 있다. 정적 이벤트도 가능하다.
public class Foo
{
  public static event EventHandler<EventArgs> StaticEvent;
  public virtual event EventHandler<EventArgs> VirtualEvent;
}

람다 표현식

  • 람다 표현식(lambda expression), 줄여서 람다식은 대리자 인스턴스가 쓰이는 곳에 대리자 인스턴스 대신 지정할 수 있는 이름 없는 메서드이다. 컴파일러는 람다식을 그 자리에서 다음 중 하나로 바꾼다.
    • 대리자 인스턴스
    • 운행 가능(traversable) 객체 모형의 람다 표현식 안에 있는 코드를 나타내는 Expression<TDelegate> 형식의 표현식 트리(expression tree). 이 경우에는 람다식을 실행시점에서 해석할 수 있다.

람다 매개변수 형식의 명시적 지정

  • 보통은 람다 매개변수의 형식을 컴파일러가 추론(inference) 할 수 있다. 그러나 추론이 불가능한 경우에는 각 매개변수의 형식을 명시적으로 지정해 주어야 한다.

외부 변수 갈무리

  • 람다식의 코드에서 람다식 자신이 정의된 메서드의 지역 변수들과 매개변수들을 참조하는 것이 가능하다. 람다식의 관점에서 그런 변수들을 외부 변수(outer variable)라고 부른다.
static void Main()
{
  int factor = 2;
  Func<int, int> multiplier = n => n * factor;
  Console.WriteLine(multiplier(3));  // 6
}
  • 람다식이 참조하는 외부 변수를 갈무리된 변수(captured variable)라고 부르고, 외부 변수를 갈무리하는 람다식을 닫힘(closure)라고 부른다.
  • 갈무리된 변수는 변수가 갈무리 될 때가 아니라 대리자가 실체로 호출 될 때 평가 된다.
int factor = 2;
Func<int, int> multiplier = n => n * factor;
factor = 10;
Console.WriteLine(multiplier(3));  // 30
  • 갈무리된 변수를 람다식 자신이 갱신할 수도 있다.
int seed = 0;
Func<int> natural = () => seed++;
Console.WriteLine(natural());  // 0
Console.WriteLine(natural()); // 1
Console.WriteLine(seed); // 2
  • 갈무리된 변수의 수명은 대리자의 수명과 같아진다. 다음 예에서 지역 변수 seed는 보통의 경우에는 Natural의 실행이 끝나면 사라질 것이지만, seed를 람다식이 갈무리 했기 때문에, 그 수명은 해당 대리자 natural의 수명으로 연장 된다.
static Func<int> Natural()
{
  int seed = 0;
  return () = > seed++;
}

static void Main()
{
  Func<int> natural = Natural();
  Console.WriteLine(natural());  // 0
  Console.WriteLine(natural()); // 1
}
  • 람다식 안에서 인스턴스화 되는 지역 변수는 대리자 인스턴스가 호출될 때마다 고유하게 인스턴스화 된다. 아래의 예제에서는 seed가 람다식 안에서 인스턴스화 되기 때문에 이전과는 다른 결과가 나온다.
    • 컴파일 시 갈무리된 변수를 만나면 컴파일러는 그 변수를 한 전용 클래스의 필드들로 ‘끌어 올려서(hoisting)’ 처리한다. 메서드 호출 시 그 클래스가 인스턴스화 되는데, 그 인스턴스의 수명은 대리자 인스턴스의 것과 같다.
static Func<int> Natural()
{
 return () = > { int seed = 0; seed++; };
}

static void Main()
{
 Func<int> natural = Natural();
 Console.WriteLine(natural()); // 0
 Console.WriteLine(natural()); // 0
}

반복 변수의 갈무리

  • for 루프의 반복 변수를 갈무리할 때, C#은 그 변수를 루프 바깥에서 선언된 것처럼 취급한다. 이는 반복마다 같은 변수가 갈무리됨을 뜻한다. 그래서 아래의 코드에서 결과는 012가 아니라 333이 된다.
    • 갈무리된 변수는 갈무리되는 시점이 아니라 대리자가 호출되는 시점에서 평가 된다. 나중에 대리자들이 호출되는 시점에서 i 값은 3이 되기 때문에 333이 출력 된다.
    • 만일 012가 출력되게 하고 싶다면 해결책은 루프 내부 범위에 지역 변수에 반복 변수를 배정하는 것이다.
Action[] actions = new Action[3];

for (int i = 0; i < 3; i++)
  action[i] = () => Console.Write(i);

foreach (Action a in actions) a();  // 333

익명 메서드

  • 익명 메서드(anonymous method)는 C# 2.0에서 도입된 기능으로 C# 3.0에서 람다식이 도입되면서 거의 쓰이지 않게 되었다. 익명 메서드는 람다식과 비슷하지만 다음과 같은 기능을 지원하지 않는다.
    • 매개변수 형식 생략
    • 표현식 구문
    • Expression<T>에 배정해서 표현식 트리로 컴파일
  • 익명 메서드를 작성할 때는 다음처럼 delegate 키워드 다음에 매개변수 선언(생략 가능)과 메서드 본문을 써준다.
delegate int Transformer (int i);
Transformer sqr = delegate (int x) { return x*x; };
Console.WriteLine(sqr(3));  // 9

try 문과 예외

  • try 문은 오류 처리 또는 마무리(cleanup) 코드를 위한 코드 블록을 지정한다.
  • try 블록 다음에 catch 블록이나 finally 블록이 올 수 있다. (또는 둘 다 올 수 있다.)
  • try 블록 안에서 오류가 발생하면 catch 블록이 실행된다.
  • 마무리 작업을 수행하기 위한 finally 블록은 실행이 try 블록을 벗어나면, 또는 catch 블록을 벗어나면 실행된다. 즉, finally 블록은 오류가 발생하든 발생하지 않든 항상 실행된다.
  • catch 블록에는 발생한 오류, 즉 예외에 관한 정보를 담은 Exception 객체가 전달된다. catch 블록은 그 예외를 처리해서 해소하거나, 아니면 다시 던질(rethrow) 수 있다.
  • finally 블록은 프로그램의 결정론(determinism)을 강화한다. CLR은 이 블록이 항상 실행됨을 보장한다. 따라서 이 블록은 네트워크 연결을 닫는 등의 마무리 작업을 하는데 유용하다.
  • 어떤 함수에서 예외가 발생하면 CLR은 그 예외를 잡을 수 있는 try 블록 안에서 예외가 던져졌는지 점검한다.
    • 만일 그렇다면 실행은 해당 catch 블록으로 넘어가다. 그 catch 블록의 실행이 성공적으로 끝난다면 실행은 try 문 다음 문장으로 간다.(단, finally 블록이 있다면 그것이 먼저 실행된다.)
    • 만일 그렇지 않다면 실행은 함수를 호출한 곳으로 돌아간다.(단, finally 블록이 있다면 그것이 먼저 실행된다.) 그런 다음 처리가 반복된다.
  • 그 어떤 함수도 예외를 처리하지 않으면 결국에는 오류 대화상자가 사용자에게 표시되고 프로그램이 강제로 종료된다.

catch 절

  • catch 블록 또는 catch 절을 작성할 떄는 그 블록으로 잡고자 하는 예외 형식을 괄호 쌍 안에 지정한다. 그러한 예외 형식은 반드시 System.Exception 클래스이거나System.Exception의 파생 클래스여야 한다.

예외 필터(C# 6)

  • C# 6.0 부터는 catch 절에 when 절을 포함시켜서 예외 필터(exception filter)를 지정할 수 있다.
catch (WebException ex) when (ex.Status == WebExceptionStatus.Timeout)
{
  ...
}

finally 블록

  • finally 블록은 항상 실행된다. 예외가 던져지든 아니든 try 블록이 끝까지 실행되든 아니든 이 블록은 항상 실행된다. 그래서 finally는 흔히 마무리 코드에 쓰인다.
    • finally 블록이 실행되지 않는 경우는 프로그램이 무한루프에 빠졌거나 강제 종료되었을 때 뿐이다.
  • finally 블록이 실행되는 시점은 다음 세 가지이다.
    • catch 블록의 실행이 끝난 후에
    • 점프문 때문에 실행이 try 블록을 벗어난 후에
    • try 블록이 끝난 후에

using 문

  • 클래스 중에는 파일 핸들이나 그래픽 핸들, 데이터베이스 연결 같은 비관리 자원들을 캡슐화하는 것들이 많은데, 그런 클래스들은 System.IDisposable 인터페이스를 구현한다. 이 인터페이스에는 그런 자원들을 정리하기 위한 Dispose라는 메서드가 있다.
  • C#은 finally 블록 안에서 IDisposable 구현 객체에 대해 Dispose를 호출하는 패턴을 좀 더 우아하게 표기하는 수단으로 using 문을 제공한다.
  • 아래 두 코드는 정확하게 같은 의미이다.
// using을 이용한 방법
using (StreamReader reader = File.OpenText("file.txt"))
{
  ...
}

// finally를 이용한 방법
{
  StreamReader reader = File.OpenText("file.txt");
  try
  {
    ...
  }
  finally
  {
    if (reader != null)
      ((IDisposable)reader).Dispose();
  }
}

예외 던지기

  • 예외는 사용자 코드가 던질 수도 있고 런타임이 던질 수도 있다. 다음의 예에서 Display 메서드는 System.ArgumentNullException 예외를 던진다.
class Test
{
  static void Display(string name)
  {
    if (name == null)
      throw new ArgumentNullException(nameof(name));
  }

  static void Main()
  {
    try { Display (null); }
    catch (ArgumentNullException ex) { Console.WriteLine("Caught the exception"); }
  }
}

예외 다시 던지기

  • 예외를 잡아서 다시 던질 수도 있다.
try { ... }
catch (Exception ex)
{
  // 먼저 오류를 기록하고...
  ...
   throw;  // 같은 예외를 다시 던진다.
}
  • 발생한 오류를 삼키지(swallow) 않고 기록하려 할 때 이러한 예외 다시 던지기가 유용하다. 또한, 예상한 것과는 상황이 다른 경우 오류 처리를 다른 곳에 미루려할 때에도 이러한 다시 던지기가 유용하다.
string s = null;
using (WebClient wc = new WebClient())
{
  try 
  {
    s = wc.DownloadString(...);
  }
 catch (WebException ex)
  {
    if (ex.Status == WebExceptionStatus.Timeout)
      Console.WriteLine("Timeout");
    else
      throw;
  }
}
  • C# 6.0부터는 이런 처리 논리를 예외 필터를 이용해서 좀 더 간결하게 표현할 수 있다.
catch (WebException ex) when (ex.Status == WebExceptionStatus.Timeout)
{
  Console.WriteLine("Timeout");
}
  • 예외 던지기의 또 다른 흔한 용도는 다음 예처럼 좀 더 구체적인 형식의 예외를 다시 발생하는 것이다.
try
{
  ... // XML 요소 자료를 파싱해서 DateTime 객체를 채운다.
}
catch (FormatException ex)
{
  throw new XmlException("유효하지 않은 DateTime", ex);
}
  • XmlException 인스턴스를 생성할 때 원래의 예외 ex를 둘째 인수로 지정했음을 주목하자. 이 인수는 새 예외의 InnerException 속성을 채우며, 이는 디버깅에 도움이 된다. 거의 모든 예외 형식에 이와 비슷한 생성자가 있다.
  • 덜 구체적인 예외를 다시 던져야 하는 경우도 있다. 이를테면 신뢰 경계(trust boundary)를 넘나드는 상황에서 잠재적인 해커에게 기술적인 정보를 누출하지 않기 위해 그런 식으로 예외를 다시 던지기도 한다.
  • C#의 모든 예외는 실행시점 예외다. Java의 ‘컴파일 시점에서 점검되는 예외(compile-time checked exception)’ 같은 것은 없다.

열거자와 반복자

열거자

  • 열거자(enumerator)는 값들의 순차열(sequence of values)을 반복하는 읽기 전용/전진 전용(forward-only) 커서이다.
    • 열거자는 다음 두 인터페이스 중 하나를 구현하는 객체이다.
      • System.Collection.IEnumerator
      • System.Collection.Generic.IEnumerator<T>
    • 엄밀히 말하면 MoveNext라는 메서드와 Current라는 속성이 있는 객체이면 그 어떤 것도 열거자로 간주된다. 이러한 조건 완화는 C# 1.0에서 값 형식 요소들을 열거할 때 박싱/언박싱 부담을 위해 도입된 것이나 C# 2에서 제네릭이 도입되면서 그런 부담 자체가 사라졌기 때문에 도입 의도가 무색해졌다.
  • foreach 문은 열거 가능(enumerable) 객체를 반복한다. 열거 가능 객체는 순차열의 논리적 표현이다.
    • 열거 가능 객체는 그 자체로 커서는 아니고, 자신에 대한 커서를 돌려주는 객체이다.
    • 다음 두 조건 중 하나를 만족하는 객체는 열거 가능 객체로 간주된다.
      • IEnumerable이나 IEnumerable<T>를 구현한다.
      • 열거자를 돌려주는 GetEnumerator 라는 메서드가 있다.
  • 열거자와 열거 가능 객체를 흔히 다음과 같은 패턴으로 정의한다.
class 열거자   // 흔히 IEnumerator나 IEnumerator<T>를 구현
{
  public 반복자-변수-형식 Current { get { ... } }
  public bool MoveNext() { ... }
}

class 열거-가능  // 흔히 IEnumerable이나 IEnumerable<T>를 구현
{
  public 열거자 GetEnumerator() { ... }
}
  • 다음은 영단어 beer의 문자들을 고수준(foreach)와 저수준으로 훑는 예이다.
// 고수준으로 훑는 예
foreach(char c in "beer")
  Console.WriteLine(c);

// 저수준으로 훑는 예
using(var enumerator = "beer".GetEnumerator())
{
  while (enumerator.MoveNext())
  {
    var elemnet = enumerator.Current;
    Console.WriteLine(element);
  }
}
  • 열거자가 IDisposable을 구현하는 경우, foreach 문은 using 문으로도 작용한다. 즉, 반복이 끝나면 열거자 객체가 암묵적으로 처분된다.

컬렉션 초기치

  • 열거 가능 객체를 한 문장으로 인스턴스화 할 수 있다.
List<int> list = new List<int> { 1, 2, 3, };
  • 컴파일러는 이를 다음으로 바꾸어서 컴파일 한다.
List<int> list = new List<int>();
list.Add(1);
list.Add(2);
list.Add(3);
  • 이런 구문이 가능하려면 열거 가능 객체가 반드시 System.Collections.IEnumerable 인터페이스를 구현해야 하며, 적절한 개수의 매개변수들을 가진 Add라는 메서드가 있어야 한다.

반복자

  • foreach 문은 열거자의 값들을 ‘소비’하는 소비자라 할 수 있다. 열거자의 생산자에 해당하는 것이 바로 반복자(iterator)이다. 다음 예제는 피보나치 수열의 수들을 돌려주는 반복자 메서드를 사용한다.
class Test
{
  static void Main()
  {
    foreach(int fib in Fibs(6))
      Console.WriteLine(fib + " ");
  }

  static IEnumerable<int> Fibs (int fibCount)
  {
    for (int i = 0, preFib = 1, curFib = 1; i < fibCount; i++) // 여기에 선언된 i, preFib, curFib은 for 문 밖에 선언된 변수와 같다. 고로 이후 for 문에서 newFib과 달리 계속 값이 유지된다.
    {
      yield return prevFib;
      int newFib = prevFib + curFib;
      prevFib = curFib;
      curFib = newFib;
    }
  }
}
  • return 문이 호출자에게 ‘이 메서드에 원하셨던 값이 여기 있으니 받으세요’ 라고 말하는 것이라면, yield return 문은 ‘이 열거자에 원하셨던 다음 요소가 여기 있으니 받으세요’ 라는 것과 같다. yield 문에서 실행의 흐름이 호출자에게 다시 돌아가지만, yield 문이 있던 함수의 내부 상태는 그대로 유지된다. 따라서 다음에 호출자가 다시 열거를 수행하면 열거자는 다음 요소를 돌려주게 된다.
  • 이 내부 상태의 수명은 열거자에 묶여 있다. 좀 더 구체적으로 말하면 상태는 호출자가 열거를 마쳤을 때 해제된다.
  • 컴파일러는 반복자 메서드를 IEnumerable<T> 또는 IEnumerator<T>를 구현하는 전용 클래스로 바꾸어서 컴파일한다. 컴파일러는 반복자 블록 안의 논리를 “뒤집은” 후, 내부적으로 생성한 열거자 클래스의 MoveNext 메서드와 Current 속성에 나누어 넣는다. 즉, 반복자 메서드를 호출하면 컴파일러가 작성한 클래스의 인스턴스화가 일어날 뿐, 반복자 메서드의 코드가 실제로 실행되는 것은 아니다. 반복자 메서드는 그 결과로 만들어진 순차열에 대해 열거를 시작해야 (주로 foreach 문을 이용해서) 비로소 실행된다.

반복자 의미론

  • 반복자는 하나 이상의 yield 문이 있는 메서드나 속성, 인덱서이다 반복자의 반환 형식은 반드시 다음 네 인터페이스 중 하나와 호환되어야 한다. (그렇지 않으면 컴파일러가 오류를 발생한다)
    • System.Collections.IEnumerable
    • System.Collections.Generic.IEnumerable<T>
    • System.Collections.IEnumerator
    • System.Collections.Generic.IEnumerator<T>
  • 반복자의 의미론은 열거 가능 인터페이스를 돌려주느냐 열거자 인터페이스를 돌려주느냐에 따라 다르다.
  • 한 메서드 안에 yield 문을 여러 개 둘 수 있다.
static IEnumerable<string> FOO()
{
  yield return "One";
  yield return "Two";
  yield return "Three";
}

yield break 문

  • yield break 문은 반복자 블록이 더 이상의 요소를 돌려주지 않고 일찍 종료되어야 함을 나타낸다.
    • 반복자 블록 안에서 return 문ㅇ르 사용하는 것은 위법이다. return 대신 반드시 yield break를 사용해야 한다.
static IEnumerable<string> FOO(bool breakEarly)
{
  yield return "One";
  yield return "Two";

  if (breakEarly)
    yield break;

  yield return "Three";
}

반복자와 try/ catch/ finally 블록

  • catch 절이 있는 try 블록 안에 yield return 문을 둘 수는 없다.
  • 또한 yield return 문은 catch 블록이나 finally 블록 안에서도 금지된다.
    • 이러한 제약은 컴파일러가 반복자를 MoveNext, Current, Dispose 멤버가 있는 보통의 클래스로 바꾸어야 하는데, 그와 함께 예외 처리 블록들을 그런 식으로 바꾸려면 복잡도가 과도하게 증하기 때문이다.
  • 그러나 finally 블록만 있는 try 블록 안에서는 yield return 이 가능하다.
  • finally 블록의 코드는 소비자인 열거자가 순차열의 끝에 도달하거나 열거자 자체가 처분되었을 때 실행된다. 실행이 break 문에 의해 루프를 일찍 벗어나느 경우 foreach 문은 열거자를 암묵적으로 처분한다. 이 덕분에 열거자의 순차열 소비가 안전해진다.
    • 그러나 열거자를 foreach 없이 명시적으로 다룰 때는 열거자를 처분하지 않고 finally 블록을 우회해서 열거를 일찍 끝내는 실수를 저지르지 않도록 조심해야 한다. 그런 실수를 방지하는 좋은 방법은 열거형을 명시적으로 사용하는 코드를 using 문으로 감싸는 것이다.
string firstElement = null;
var sequence = Foo();
using (var enumerator = sequence.GetEnumerator())
  if (enumerator.MoveNext())
    firstElement = enumerator.Current;

순차열 합성

  • 반복자는 합성 능력이 아주 뛰어나다.
  • 아래의 코드는 앞의 예를 확장해서 짝수 피보나치 수들만 출력하는 코드이다.
using System;
using System.Collections.Generic;

class Test
{
  static void Main()
  {
    foreach (int fib in EvenNumberOnly(Fibs(6)))
      Console.WriteLine(fib);
  }

  static IEnumerable<int> Fibs (int fibCount)
  {
    for (int i = 0, prevFib = 1, curFib = 1; i < fibCount; i++)
    {
      yield return prevFib;
      int newFib = prevFib + curFib;
      prevFib = curFib;
      curFib = newFib;
    }
  }

  static IEnumerable<int> EvenNumberOnly(IEnumerable<int> sequence)
  {
    foreach (int x in sequence)
      if ((x % 2) == 0)
        yield return x;
  }
}
  • 요소들의 계산은 최대한 지연된다. 각 요소는 소비자가 MoveNext()를 호출해서 요청해야 비로소 계산된다. 아래 그림은 자료 요청들과 자료 출력이 시간순으로 나열되어 있다.

널 가능 형식

  • 값 형식에서 널을 표현하려면 널 가능 형식(nullable type)이라는 특별한 코드 구축 요소를 사용해야 한다. 널 가능 형식은 보통의 값 형식 다음에 ? 기호를 붙여서 표기한다.
int? i = null;

Nullable<T> 구조체

  • 컴파일러는 T?를 가벼운 불변이 구조체인 System.Nullable<T>로 바꾸어서 컴파일한다. 이 구조체에는 값과 값의 존재 여부를 뜻하는 Value와 HasValue라는 두 필드만 있다. System.Nullable<T>의 핵심부는 아주 단순하다.
public struct Nullable<T> where T : struct
{
  public T Value { get; }
  public bool HasValue { get; }
  public T GetValueOrDefault();
  public T GetValueOrDefault(T defaultValue);
  ...
}
  • 컴파일러는 아래의 코드를 Nullable<T> 형식으로 바꾸어서 컴파일한다.
int? i = null;

// 컴파일러는 위의 코드를 아래와 같이 바꾸어서 컴파일 한다
Nullable<int> i = new Nullable<int>();
  • HasValue가 거짓인 널 가능 객체의 Value를 조회하려 하면 InvalidOperationException 예외가 발생한다.
  • GetValueOrDefault() 메서드는 HasValue가 참이면 Value를 돌려주고 그렇지 않으면 new T() 또는 지정된 커스텀 기본값을 돌려준다.
  • T?의 기본값은 null 이다.

암시적/명시적 널 가능 변환

  • T에서 T?로의 변환은 암묵적이고 T?에서 T로의 변환은 명시적이다.

널 가능 값의 박싱과 언박싱

  • T?가 박싱된 경우 힙에 있는 박싱된 값은 T?가 아니라 T를 담는다. 이러한 최적화가 가능한 것은 박싱된 값은 이미 널을 표현할 수 있는 참조 형식이기 때문이다.
  • 또한 C#은 널 가능 형식을 as 연산자로 언박싱하는 연산도 허용한다. 만일 캐스팅이 실패하면 결과는 null이 된다.

연산자 끌어올리기

  • Nullable<T> 구조체는 < 나 > 같은 연산자들을 정의하지 않는다. 심지어 == 도 정의하지 않는다. 그러나 아래 코드는 오류 없이 컴파일되고 정확하게 실행되는데, 이것이 가능한 이유는 컴파일러가 바탕 값 형식에서 적절한 연산자를 빌려오기 때문이다. 이러한 처리를 ‘연산자 끌어올리기’라고 부른다. 컴파일러는 앞의 비교문을 다음과 같이 변환해서 컴파일 한다.
    • 만일 x와 y 둘 다 값이 있으면 둘을 int의 미만 연산자로 비교하고 그렇지 않으면 false를 돌려준다.
    • (이하 연산자별 설명은 생략)
int? x = 5;
int? y = 10;
bool b = x < y;  // 참으로 평가 됨

// 위 코드의 마지막 줄은 아래와 같이 변환되어 컴파일 된다.
bool b = (x.HasValue && y.HaValue) ? (x.Value < y.Value) : false;

널 가능 형식의 용도

  • 널 가능 형식의 흔한 용도는 미지의 값을 표현하는 것이다. 데이터베이스 프로그래밍에서 널일 수도 있는 열을 가진 테이블에 클래스를 대응할 때 이런 용법을 흔히 볼 수 있다.
  • 열의 자료 형식이 문자열이면 문제가 없지만 –CLR에서 문자열은 참조 형식이므로– 그 외의 SQL 열 형식들은 대부분 CLR의 구조체 형식들에 대응된다. 따라서 SQL을 CLR에 대응시킬 때 널 가능 형식이 아주 유용하다.
  • 널 가능 형식은 종종 주변 속성(ambient property)이라고 부르는 속성의 배경 필드(backing field)에도 쓰인다. 주변 속성은 만일 자신의 값이 널이면 부모의 값을 돌려준다.

연산자 중복 적재

  • C#은 연산자 중복적재(operator overloading)을 지원한다. 연산자 중복적재의 주된 목적은 커스텀 형식을 좀 더 자연스러운 구문으로 사용할 수 있게 하는 것이다. 연산자 중복적재는 기본 자료 형식에 비교적 가까운 형식을 나타내는 커스텀 구조체에 적용하는 것이 가장 적합하다.

연산자 함수

  • 연산자를 중복적재할 때는 연산자 함수(operator function)라는 것을 선언한다. 연산자 함수는 다음의 규칙을 따른다.
    • 함수 이름은 operator 키워드 다음에 연산자 기호가 오는 형태이다.
    • 연산자 함수는 반드시 static과 public을 모두 지정한 함수, 즉 정적 공용 함수이어야 한다.
    • 연산자 함수의 매개변수들은 피연산자들에 대응된다.
    • 연산자 함수의 반환 형식은 연산자가 쓰인 표현식의 결과를 나타낸다.
    • 피연산자 중 적어도 하나는 연산자 함수가 선언되어 있는 형식이어야 한다.
  • 다음은 음표를 뜻하는 Note라는 구조체에서 + 연산자를 중복적재하는 예이다.
public struct Note
{
  int value;
  public Note (int semitonesFromA) { value = semitonesFromA; }
  public static Note operator + (Note x, int semitones)
  {
     return new Note (x.Value + semitones);
  }
}

// 이 중복 적재 덕분에 int를 Note에 더할 수 있다.
Note B = new Note(2);
Note CSharp = B + 2;

// 연산자를 중복적재하면 해당 복합 배정 연산자도 자동으로 중복적재된다. 위 예제에서는 +를 중복 적재했으므로 +=도 사용할 수 있다.
CSharp += 2;

커스텀 암묵적/ 명시적 변환

  • C#에서는 암묵적 변환과 명시적 변환도 implicit 키워드와 explicit 키워드를 이용해서 중복적재할 수 있다.
    • 이런 중복적재의 주된 목적은 강하게 연관된 형식들 사이의 변환을 간결하고 자연스럽게 표현하는 것이다.
  • 약하게 연관된 형식들 사이의 변환에 대해서는 다음 전략들이 더 적합하다.
    • 변환 원본에 해당하는 형식의 매개변수가 있는 생성자를 작성한다.
    • 형식 변환을 수행하는 ToXXX 메서드와 (정적) FromXXX 메서드를 작성한다.
// 음표를 헤르츠로 변환
public static implicit operator double (Note x)
  => 440 * Math.Pow(2, (double) x.value / 12);

//헤르츠를 음표로 변환 (가장 가까운 반음으로 근사됨)
public static explicit operator Note (double x)
  => new Note ((int) (0.5 + 12 * (Math.Log(x/440) / Math.Log(2))));

Note n = (Note)554.357;  // 명시적 변환
double x = n;  // 암묵적 변환

// as 연산자와 is 연산자는 이런 식으로 중복적재된 커스텀 변환을 무시한다.
Console.WriteLine(554.37 is Note);  // false
Note n = 554.37 as Note;  // 오류

true와 false의 중복적재

  • ‘의미상으로는’ 부울 형식이지만 bool과의 변환은 정의되어 있지 않은 형식들이 있다. 그런 형식들에 대해서는 true 연산자와 false 연산자를 중복적재하는 것이 의미가 있다.
  • 한 예는 3상태 논리(three-state logic)를 구현하는 형식이다. 그런 형식에 대해 true와 false를 중복적재하면 조건문들과 조건 관련 연산자들, 즉 if, do, while, for, &&, ||, ?: 을 그런 형식들에 자연스러운 구문으로 적용할 수 있다.

확장 메서드

  • 확장 메서드(extension method)는 기존 형식의 정의를 변경하지 않고도 기존 형식에 새 메서드를 추가하는 수단이다. 구문상으로 확장 메서드는 정적 클래스의 정적 메서드이되, 첫 매개변수 앞에 this 수정자를 붙인다는 점이 특징이다. 그 첫 매개변수의 형식은 확장하려는 형식이다.
    • 인터페이스도 확장할 수 있다.
public static class StringHelper
{
  public static bool IsCapitalized(this string s)
  {
    if (string.IsNullOrEmpty(s)) return false;
    return char.IsUpper(s[0]);
  }
}

// IsCapitalized는 다음과 같이 호출 할 수 있다.
Console.WriteLine("Perth".IsCapitalized());

// C# 컴파일러는 이 확장 메서드 호출을 다음과 같이 보통의 정적 메서드 호출로 바꾸어서 컴파일한다.
Console.WriteLine(StringHelper.IsCapitalized("Perth"));

중의성 해소

확장 메서드 대 인스턴스 메서드

  • 주어진 호출문과 호환되는 인스턴스 메서드와 확장 메서드가 모두 존재하는 경우 항상 인스턴스 메서드가 우선시 된다.

익명 형식

  • 익명 형식(anonymous type)은 일단의 값들을 저장하기 위해 컴파일러가 컴파일 도중에 즉석에서 생성한느 간단한 클래스이다. 익명 형식의 인스턴스를 생성할 때에는 다음 예처럼 new 키워드 다음에 객체 초기치를 지정하면 된다.
  • 익명 형식에는 말 그대로 이름이 없으므로 익명 형식을 참조할 때는 반드시 var 키워드를 사용해야 한다.
var dude = new { Name = "bob", Age = 23 };

// 컴파일러는 위 문장을 아래와 같은 코드로 바꾸어서 컴파일 한다.
internal class AnonymouseGeneratedTypeName
{
  private string name; // 실제 필드 이름은 중요하지 않음.
  private int age;  // 실제 필드 이름은 중요하지 않음
  publicAnonymouseGeneratedTypeName(string name, int age)
  {
    this.name = name, this.age = age;
  }
  public string Name { get { return name; } }
  public int Age { get { return age; } }
}

var dude = new AnonymouseGeneratedTypeName("bob", 23);
  • 같은 어셈블리 안에서 언언된 두 익명 형식 인스턴스의 속성 이름들이 동일하면 두 인스턴스는 같은 익명 형식에 속하는 것으로 간주된다.
var a1 = new { x = 2, y = 4 };
var a2 = new { x = 2, y = 4 };
Console.WriteLine(a1.GetType() == a2.GetType());  // true

// 이 경우 인스턴스들의 상등 판정을 위해 Equals 메서드가 재정의된다.
Console.WriteLine(a1 == a2);  // false
Console.WriteLine(a1.Equals(a2)); // true

동적 바인딩

  • 바인딩(binding)이란 형식이나 멤버, 연산을 구체적으로 결정(resolution)하는 과정을 말한다. 동적 바인딩(dynamic binding)은 그러한 바인딩을 컴파일 시점이 아니라 실행시점으로 미루는 것이다.
  • 동적 바인딩은 컴파일 시점에서 어떤 함수나 멤버, 연산이 존재한다는 것을 프로그래머는 알고 있지만 컴파일러는 알지 못하는 경우에 유용하다. 동적 언어(IronPython 같은)나 COM과 연동하는 프로그램을 작성하는 경우에 그런 상황이 흔히 발생하며 반영(reflection) 기능이 필요한 시나리오에서도 그런 상황이 발생한다.
  • 동적 형식은 문맥 키워드인 dynamic을 이용해서 선언한다.
dynamic d = GetSomeObject();
d.Quack();
  • 실제 형식에 관한 정보가 거의 없다는 점에서 dynamic 형식은 object와 비슷하다. 차이는 컴파일 시점에서 컴파일러가 확인할 수 없는 일들이 허용된다는 점이다. 동적 객체는 실행시점에서 동적으로 바인딩 된다.
  • 실행시점에서 바인딩이 일어나는 방식은 이렇다. 만일 동적 객체가 IDynamicMetaObejctProvider 인터페이스를 구현한다면, 런타임은 그 인터페이스를 이용해서 바인딩을 수행한다. 그 인터페이슬르 구현하지 않는다면, 마치 컴파일러가 동적 객체의 실행시점 형식을 알았다면 적용했을 방식과 거의 같은 방식으로 바인딩이 일어난다. 이상의 두 가지 방식을 순서대로 커스텀 바인딩(custom binding)과 언어 바인딩(language binding)이라고 부른다.

커스텀 바인딩

  • IDynamicMetaObjectProvider 인터페이스를 구현하는 동적 객체에 대해서는 커스텀 바인딩이 일어난다. C#에서 독자가 형식을 직접 작성할 때 이 IDynamicMetaObjectProvider를 구현하는 것이 가능하며 그것이 유용한 일이긴 하지만, 그보다는 DLR(Dynamic Language Runtime) 상에서 .NET으로 구현된 동적 언어에서 IDynamicMetaObjectProvider 객체를 얻는 경우가 더 흔하다. 그런 언어들에서 가져온 객체들은 자신에게 수행되는 연산들의 의미를 직접 제어하는 수단으로서 IDynamicMetaObjectProvider를 암묵적으로 구현한다.

언어 바인딩

  • IDynamicMetaObjectProvider를 구현하지 않는 동적 객체에 대해서는 언어 바인딩이 적용된다. 언어 바인딩은 불완전하게 설계된 형식의 결함이나 .NET 형식 체계의 본질적인 제약을 우회할 떄 유용하다. 전형적인 예로 공통의 인터페이스가 없는 수치 형식들을 사용할 떄 이러한 동적 언어 바인딩이 유용하다.

RuntimeBinderException

  • 실행시점에서 멤버를 바인딩할 수 없으면 RuntimeBinderException 예외가 발생한다. 이를 실행시점에서의 컴파일 시점 오류라고 생각해도 될 것이다.

동적 객체의 실행시점 표현

  • dynamic 형식과 object 형식 사이에는 깊숙한 동치 관계(equivalence)가 존재한다. 런타임은 다음 표현식을 true로 평가한다.
typeof(dynamic) == typeof(object)
  • 구조적으로 객체 참조와 동적 참조는 아무런 차이도 없다. 동적 참조는 그냥 참조가 가리키는 객체에 대해 동적 연산들을 허용하는 것일 뿐이다. 따라서 object를 dynamic으로 변환하고 나면 해당 참조에 대해 그 어떤 동적 연산도 수행할 수 있다.

동적 변환

  • dynamic으로 선언된 동적 형식은 다른 모든 형식과의 암묵적 변환을 지원한다.

var 대 dynamic

  • var와 dynamic은 겉으로 보기에는 비슷하지만 심오한 차이가 존재한다.
    • var는 형식을 컴파일러가 파악하게 한다는 뜻이고
    • dynamic은 형식을 런타임이 파악하게 한다는 뜻이다.
dynamic x = "hello";  // 정적 형식은 dynamic, 실행시점 형식은 string
int i = x; // 실행시점 오류

var y = "hello";  // 정적 형식은 string, 실행시점 형식은 string
int j = y;  // 컴파일 시점 오류

동적 표현식

  • 필드, 속성, 메서드, 이벤트, 생성자, 인덱서, 연산자, 형식 변환 모두 동적으로 호출될 수 있다.
  • 반환 형식이 void인 동적 표현식의 결과를 소비하는 것은 위법이다. 이는 정적 형식 표현식에서도 마찬가지다. 차이는 오류가 실행시점에서 발생한다는 것이다.
dynamic list = new List<int>();
var result = list.Add(5);  // RuntimeBinderException 예외 발생
  • 일반적으로 표현식에 동적 피연산자가 있으면 그 표현식 자체도 동적이 된다. 이는 형식 정보의 부재가 표현식 전체로 전파되기 때문이다.
dynamic x = 2;
var y = x * 3;  // y의 정적 형식은 dynamic
  • 그러나 이 규칙에는 두 가지 자명한 예외가 존재한다. 첫째로, 동적 표현식을 정적 형식으로 캐스팅하면 정적 표현식이 된다.
dynamic x = 2;
var y = (int)x; // y의 정적 형식은 int
  • 둘째로 생성자 호출은 항상 정적 표현식이 된다. 생성자를 동적 인수로 호출해도 그렇다. 다음 예에서 x의 정적 형식은 StringBuilder이다.
dynamic capacity = 10;
var x = new System.Text.StringBuilder(capacity);

동적 수신자가 없는 동적 호출

  • dynamic의 전형적인 용법에는 동적 수신자가 관여하기 마련이다. 예컨대 동적 객체는 동적 함수 호출의 수신자이다.
  • 그런데 정적으로 알려진 함수를 동적 인수로 호출하는 것도 가능하다. 그런 호출에는 동적 중복적재 해소 규칙이 적용된다. 다음 함수들은 그런 식으로 호출 할 수 있다.
    • 정적 메서드
    • 인스턴스 생성자
    • 형식이 정적으로 알려진 수신자에 대한 인스턴스 메서드
  • 다음 예의 두 Foo 호출에는 동적 바인딩이 적용된다. Foo의 두 가지 중복적재 중 구체적으로 어떤 것이 선택되는지는 호출에 지정된 동적 인수의 실행시점 형식에 의해 결정된다.
class Program
{
  static void Foo (int x) { Console.WriteLine("1"); }
  static void Foo (string x) { Console.WriteLine("2"); }

  static void Main()
  {
    dynamic x = 5;
    dynamic y = "watermelon";

    Foo(x);
    Foo(y);
  }
}

동적 표현식 안의 정적 형식

  • 동적 바인딩에서 동적 형식들을 사용한다는 점은 자명하다. 그리 자명하지 않은 것은 동적 바인딩에서 가능하면 정적 형식들도 사용한다는 점이다.
class Program
{
  static void Foo (object x, object y) { Console.WriteLine("oo"); }
  static void Foo (object x, string y) { Console.WriteLine("os"); }
  static void Foo (string x, object y) { Console.WriteLine("so"); }
  static void Foo (string x, string y) { Console.WriteLine("ss"); }

 static void Main()
 {
    object o = "hello";
    dynamic d = "goodbye";
    Foo (o, d);
 }
}
  • Foo(o, d) 호출은 동적으로 바인딩된다. 인수 중 하나인 d가 dynamic이기 때문이다. 그런데 o의 형식은 정적으로 알려지므로, 바인딩은 그 사실을 활용한다. 지금 예에서는 중복적재 해소 규칙에 의해 Foo의 두 번째 구현이 선택된다. o의 정적 형식과 d의 실행시점 형식에 가장 잘 부합하는 것이 그것이기 때문이다. 컴파일러는 ‘가능한 한 정적으로’ 작동한다.

동적으로 호출할 수 없는 함수

  • 다음 메서드는 동적으로 호출할 수 없다.
    • 확장 메서드 (확장 메서드 구문으로 호출하려는 경우)
    • 인터페이스 멤버 (호출을 위해서는 그 인터페이스로의 캐스티잉 필요한 경우)
    • 파생 클래스가 숨긴 기반 클래스 멤버
  • 동적 바인딩에는 두 조각의 정보가 필요한데, 하나는 호출할 함수의 이름이고 다른 하나는 그 함수의 호출 대상이 되는 객체이다. 그런데 위의 세 가지 호출 불가능 함수에는 그 둘 이외에 추가적인 형식이 관여한다. 그러나 그 형식은 오직 컴파일 시점에만 알려진다. C# 6에는 그러한 추가 형식을 동적으로 지정하는 방법이 없다.

특성

  • 특성(attribute)은 코드 요소들(어셈블리, 형식, 멤버, 반환값, 매개변수, 제네릭 형식 매개변수)에 커스텀 정보를 추가하기 위한 확장 메커니즘이다.
  • 특성을 적용하기에 좋은 대상으로는 직렬화(serialization)를 들 수 있다. 특성을 이용해서 클래스를 직렬화하는 경우, C#의 필드 표현을 커스텀 서식의 필드 표현으로 변환하는 방식을 하나의 특성 클래스로 정의해두고, 그 특성을 직렬화하고자 하는 클래스의 필드에 지정하면 된다.

특성 클래스

  • 특성은 추상 클래스 System.Attribute를 상속하는 클래스로 정의된다. 특성을 특정 코드 요소에 부여할 때는 그 코드 요소 바로 앞에 특성 형식의 이름을 대괄호 싸응로 감싸서 지정한다.
// 아래 특성은 이 코드가 '폐기 예정(obsolete)'임을 뜻한다.
[ObsoleteAttribute]
public class Foo { ... }

특성의 명명된 매개변수와 위치 매개변수

  • 특성에 임의의 개수의 매개변수를 둘 수 있다.
  • 다음은 미리 정의된 XmlElement Attribute라는 특성을 적용하는 예이다. 이 특성은 XML 직렬화 기능에게 이 클래스의 객체를 XML로 표현하는 방법을 알려주는 역할을 하는데, 이를 위해 여러 개의 특성 매개변수를 받는다.
    • 다음 특성은 CustomEntity 클래스를 http://oreilly.com 이름 공간에 속하는 Customer라는 XML 요소에 대응시킨다.
[XmlElement ("Customer", Namepace="http://oreilly.com")]
public class CustomerEntity { ... }

특성의 대상

  • 암묵적으로 특성의 적용 대상은 특성 바로 다음에 있는 코드 요소이다. 흔히 형식이나 형식의 멤버가 특성의 대상이 된다. 어셈블리에도 특성을 부여할 수 있는데, 그런 경우에는  반드시 특성의 대상을 명시적으로 지정해야 한다.
  • 다음은 현재 어셈블리 전체가 CLS(공용 언어 규격)를 준수한다는 점을 나타내기 위해 CLSCompliant 특성을 어셈블리에 부여한 예이다.
[assembly:CLSCompliant(true)]

다중 특성 지정

  • 하나의 코드 요소에 여러 개의 특성을 지정할 수 있다. 이 경우 특성들을 하나의 대괄호 쌍 안에 쉼표로 구분해서 나열할 수도 있고, 아니면 각각을 개별적인 대괄호 쌍으로 지정할 수도 있다.
[Serializable, Obsolete, CLSCompliant(false)]
public class Bar { ... }

[Serializable] [Obsolete] [CLSCompliant(false)]
public class Bar { ... }

[Serializable, Obsolete] 
[CLSCompliant(false)]
public class Bar { ... }

호출자 정보 특성(C# 5)

  • C# 5부터는 선택적 매개변수에 다음과 같은 세 가지 호출자 정보 특성(caller info attribute) 중 하나를 부여할 수 있다. 호출자 정보 특성이 지정되어 있으면 컴파일러는 호출자의 소스 코드에서 얻은 정보를 매개변수의 기본값으로 제공한다.
    • [CallerMemberName]은 호추자의 멤버 이름을 제공한다.
    • [CallerFilePath]는 호출자의 소스 코드 파일 경로를 제공한다.
    • [CallerLineNumber]는 호출자의 소스 코드 파일 행번호를 제공한다.
using System;
using System.Runtime.CompilerServices;

class Program
{
  static void Main() => Foo();

  static void Foo(
    [CallerMemberName] string memberName = null,
    [CallerFilePath] string filePath = null,
    [CallerLineNumber] int lineNumber = 0)
  {
    Console.WriteLine(memberName);
    Console.WriteLine(filePath);
    Console.WriteLine(lineNumber);
  }
}
  • 호출자 정보 특성들을 로킹에 유용하다. 또한 한 객체의 임의의 속성이 변할 때마다 변경 통지를 보내는 패턴을 구현하는데도 유용하다. 실제로 .NET Framework 에는 그런 패턴을 위한 인터페이스가 존재한다. 바로 INotifyPropertyChanged이다
public interface INotifyPropertyChanged
{
  event PropertyChangedEventHandler PropertyChanged;
}

public delegate void PropertyChangedEventHandler (object sender, PropertyChangedEventArgs e);

public class PropertyChangedEventArgs : EventArgs
{
  public PropertyChangedEventArgs (string propertyName);
  public virtual string PropertyName { get; }
}
  • PropertyChangedEventArgs가 변경된 속성의 이름을 요구한다는 점에 주목하기 바란다. 이 인터페이스를 구현할 때 [CallerMemberName] 특성을 이용하면 속성 이름을 지정하지 않고도 이벤트를 발동할 수 있다.
public class Foo : INotifyPropertyChanged
{
  public event PropertyChangedEventHandler PropertyChanged = delegate { };

  void RaisePropertyChanged([CallerMemberName] string propertyName = null)
  {
    PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
  }
 
  string customerName;
  public string CustomerName
  {
    get { return customerName; }
    set
    {
      if (value == customerName) return;
      customerName = value;
      RaisePropertyChanged();
      
      // 컴파일러는 위의 줄을 다음으로 바꾼다.
      // RaisePropertyChanged("CustomerName");
    }
  }
}

비안전 코드와 포인터

  • C#은 포인터를 통한 직접적인 메모리 조작을 지원한다. 단, 그런 작업은 ‘비안전(unsafe)’, 즉 안전하지 않다고 표시된 코드 블록 안에서만, 그리고 /unsafe 옵션을 지정해서 프로그램을 컴파일한 경우에만 허용된다.
  • 포인터 형식은 기본적으로 C API와의 연동에 필요하나, 관리되는 힙 바깥에 있는 메모리에 접근하거나 성능이 아주 중요한 핫스팟(hotspot)을 최적화하는데도 유용하다.

포인터의 기초

  • 모든 값 형식 또는 참조 형식 V에 대해, 그에 대응되는 포인터 형식 V*가 존재한다. 포인터 형식의 인스턴스는 변수의 주소를 담는다. 포인터 형식은 다른 그 어떤 포인터 형식으로도 캐스팅할 수 있다(안전하지는 않음).
  • 다음은 포인터 형식에 대한 주요 연산자들이다.
연산자 의미
& 이 주소(address-of) 연산자는 변수의 주소를 담은 포인터를 돌려준다.
* 이 역참조(dereference) 연산자는 포인터의 주소에 있는 변수를 돌려준다.
-> 이 포인터-멤버(pointer-to-member) 연산자 또는 멤버 접근 연산자는 구문 단축용으로 쓰인다. x->y는 (*x).y와 같다.

 

비안전 코드

  • 형식이나 형식의 멤버 또는 문장 블록에 unsafe 키워드를 붙이면 해당 범위 안에서는 포인터 형식을 이용해서 메모리에 대해 C++ 스타일의 포인터 연산을 수행할 수 있다.
  • 다음은 포인터를 이용해서 비트맵을 빠르게 처리하는 예이다.
unsafe void BlueFilter(int[,] bitmap)
{
  int length = bitmap.Length;
  fixed (int* b = bitmap)
  {
    int* p = b;
    for (int i = 0; i < length; i++)
      *p++ &= 0xFF;
  }
}
  • 비안전 코드는 그에 상응하는 안전한 구현보다 빠르게 실행된다. 지금 예제를 안전하게 구현한다면 중첩된 루프 안에서 배열 색인 접근과 범위 점검이 여러 번 일어날 것이다. 그리고 비안전 C# 메서드를 호출하는 것이 외부 C 함수를 호출하는 것보다 빠를 수 있다. 관리되는 실행 환경을 벗어나는데 관련된 추가 부담이 없기 때문이다.

fixed 문

  • fixed 문은 앞의 예제에 나온 비트맵 같은 관리되는 객체를 ‘고정하는’ 역할을 한다.
    • 프로그램 실행 도중 수많은 객체가 힙에 할당, 해제된다. 쓰레기 수거기는 불필요한 메모리 낭비나 단편화(fragmentation)를 막기 위해 객체들을 이리저리 이동한다.
    • 포인터가 가리키고 있는 객체가 다른 곳으로 이동하면 그 포인터는 더 이상 유효하지 않게 되므로 포인터를 사용할 때에는 쓰레기 수거기에게 이 객체를 다른 곳으로 옮기지 말고 그 자리에 고정하라고 지시할 수 있어야 한다. fixed 문이 바로 그러한 수단이다.
  • 객체를 고정시키면 실행시점 효율성이 나빠질 수 있으므로 객체 고정은 잠깐만 사용해야 하며, 고정된 블록 안에서는 힙 할당을 피해야 한다.
  • fixed 문 안에서는 임의의 값 형식이나 값 형식의 배열, 문자열에 대한 포인터를 얻을 수 있다. 배열이나 문자열의 경우 포인터가 실제 가리기는 것은 그 첫 원소(값 형식)이다.
  • 참조 형식 안에 선언된 값 형식을 고정하려면 다음 예에서처럼 해당 참조 형식 자체를 고정해야 한다.
class Test
{
  int x;
  static void Main()
  {
    Test test = new Test();
    unsafe
    {
      fixed(int* p = &test.x)
      {
        *p = 9;
      }
      System.Console.WriteLine(test.x);
    }
  }
}

포인터-멤버 연산자

  • &, * 연산자 외에 C#은 C++ 스타일의 -> 연산자도 지원한다. 이 연산자는 구조체에 대해 사용할 수 있다.
struct Test
{
  int x;
  unsafe static void Main()
  {
    Test test = new Test();
    Test* p = &test;
    p->x = 9;
    System.Console.WriteLine(test.x);
  }
}

배열

stackalloc 키워드

  • stackalloc 키워드를 이용하면 힙이 아니라 스택에서 메모리 블록을 할당해서 배열처럼 사용할 수 있다. 이 블록은 스택에 할당되므로, 그 수명이 다른 지역 변수들과 같다. 즉, 메서드의 실행이 끝나면 해제된다.(단, 변수를 람다 표현식이나 반복자 블록, 비동기 함수가 갈무리 했다면 수명이 그만큼 더 늘어난다).
  • 해당 배열의 특정 원소에 [] 연산자를 이용해서 접근할 수 있다. 다음이 그러한 예이다.
int* a = stackalloc int[10];
for (int i = 0; i < 10; i++)
  Console.WriteLine(a[i]);  // 생(raw) 메모리 내용을 출력

고정 크기 버퍼

  • fixed 키워드에는 또 다른 용도가 있다. 바로 구조체 안에 고정 크기 버퍼(fixed-size buffer)를 만드는 것이다.
unsafe struct UnsafeUnicodeString
{
  public short Length;
  public fixed byte Buffer[30];  // 30바이트 블록을 할당
}

unsafe class UnsafeClass
{
  UnsafeUnicodeString uus;

  public UnsafeClass(string s)
  {
    uus.Length = (short)s.Length;
    fixed (byte* p = uus.Buffer)
      for (int i = 0; i < s.Length; i++)
        p[i] = (byte) s[i];
  }
}

class Test
{
  static void Main() { new UnsafeClass("Christian Troy"); }
}
  • 이 예에서 fixed 키워드는 또한 버퍼를 담은 객체를 힙 안에 고정하는 용도로도 쓰인다. 정리하자면 fixed는 서로 다른 두 가지의 의미로 쓰인다. 하나는 크기를 고정하는 것이고 또 하나는 장소를 고저아는 것이다.
    • 일반적으로 고정 크기 버퍼를 제대로 활용하려면 그 버퍼가 한 장소에 고정되어 있어야 하므로 이 두 용도가 함께 등장하는 경우가 많다.

void 포인터(void*)

  • void 포인터, 즉 void* 형식의 포인터는 바탕 자료의 형식에 대해 그 어떤 것도 가정하지 않는 포인터이다. 이 포인터는 생 메모리(raw memory)를 다루는 함수에 유용하다. 그 어떤 포인터 형식도 void*로의 암묵적 변환이 가능하다. void*는 역참조 할 수 없으며, 산술 연산도 적용할 수 없다. 예를 들면 다음과 같다.
class Test
{
  unsafe static void Main()
  {
    short[] a = { 1, 1, 2, 3, 5, 8, 13, 21, 34, 55 };
    fixed (short* p = a)
    {
      Zap(p, a.Length * sizeof(short));
    }
    foreach(short x in a)
      System.Console.WriteLine(x);  // 모두 0을 출력한다.
  }

  unsafe static void Zap(void* memory, int byteCount)
  {
    byte* b = (byte*) memory;
      for (int i = 0; i < byteCount; i++)
        *b++ = 0;
  }
}

관리되지 않는 코드에 대한 포인터

  • 포인터는 관리되는 힙 외부에 있는 자료에 접근할 때(이를테면 COM 객체나 C DLL과 상호작용할 때) 또는 주 메모리가 아닌 저장 공간(이를테면 그래픽 메모리나 내장 기기의 저장 매체)에 있는 자료를 다룰 때에도 유용하다.

전처리기 지시자

  • 전처리기 지시자(preprocessor directive) 들은 코드의 특정 영역에 대한 추가적인 정보를 컴파일러에게 제공한다. 가장 흔히 쓰이는 전처리기 지시자는 조건부 컴파일 지시자들이다. 다음 예에서 보듯이, 이런 지시자들은 코드의 특정 영역을 조건에 따라 컴파일에 포함 또는 제외하는데 쓰인다.
#define DEBUG
class MyClass
{
  int x;
  void Foo()
  {
    #if DEBUG
    Console.WriteLine("Testing");
    #endif
  }
}
  • #if와  #elif 지시자 다음의 조건절에서는 ||와 &&, ! 연산자를 이용해서 여러 기호들에 대해 논리합, 논리곱, 부정 연산을 수행할 수 있다.
전처리기 지시자 효과/ 의미
#define 기호 기호를 정의한다.
#undef 기호 기호의 정의를 해제한다.
#if 기호 [연산자 기호2] … 기호를 판정한다.
연산자는 ==나 !=, &&, ||이다.
이 다음에 #else나 #elif, #endif가 온다.
#else 이후의 #endif 까지에 있는 코드를 컴파일한다.
#elif 기호 [연산자 기호2] #else 절과 #if 판정을 합친 것이다.
#endif 조건부 지시문의 끝을 나타낸다.
#warning 텍스트 텍스트를 컴파일러 경고 메시지로서 출력한다.
#error 텍스트 텍스트를 오류 메시지로 해서 컴파일 오류를 발생한다.
#pragma warning [disable | restore] 번호 주어진 번호에 해당하는 컴파일러 경고(들)를 비활성화 또는 활성화 한다.
#line [번호 [“파일”] | hidden] 주어진 번호를 소스 코드의 현재 행번호로 설정한다. 컴파일러 출력에서 파일이 현재 소스 코드의 파일이름으로 나타난다. hidden을 지정하면 디버거는 이 지점부터 다음번 #line 지시자까지의 코드를 무시한다.
#region 이름 개요(outline)의 시작을 나타낸다.
#endregion 개요의 끝을 나타낸다.

 

Conditional 특성

  • 커스텀 특성 클래스를 정의할 때 특정 전처리기 기호와 함께 Conditional 특성을 지정하면 그 커스텀 특성은 해당 전처리기 기호와 정의되어 있는 경우에만 적용된다.
// file1.cs
#define DEBUG
using System;
using System.Diagnostics;
[Conditional("DEBUG")]
public class TestAttribute: Attribute { }

// file2.cs
#define DEBUG
[TEST]
class Foo
{
  [Test]
  string s;
}

pragma warning 지시문

  • 컴파일러 경고는 버그를 발견하는데 대단히 도움이 되지만 가짜 경고들이 끼어들기 시작하면 경고의 유용함이 훼손된다. 이를 위해 컴파일러는 특정 경고들의 발생을 억제하는 수단을 제공하는데, #pragma warning 지시문이 바로 그것이다.

XML 문서화

  • 문서화 주석(documentation comment)이란 형식이나 멤버를 설명하는 XML 코드 조각을 소스 코드에 포함시킨 것이다. 문서화 주석은 형식 선언이나 멤버 선언 바로 앞에 위치하며, 슬래시 세 개로 시작한다.
    • 아래 예시는 모두 동일하다.
/// <summary> 실행 중인 질의를 취소한다. </summary>
public void Cancel() { ... }

/// <summary>
/// 실행 중인 질의를 취소한다.
/// </summary>
public void Cancel() { ... }

// 주석 시작에 별표가 두 개임을 주목할 것
/**
  <summary> 실행 중인 질의를 취소한다. </summary>
*/
  • 컴파일 시 /doc 옵션을 주면 컴파일러는 이런 문서화 주석들을 모두 추출해서 하나의 XML 파일로 합친다. 이 파일의 주된 용도는 다음 두 가지 이다.
    • 이 XML 파일을 컴파일된 어셈블리와 같은 폴더에 두면 Visual Studio는 자동으로 그 파일을 읽어 들여서, 해당 어셈블리를 사용하는 코드를 프로그래머가 입력할 때 IntelliSense 기능으로 멤ㅂ들을 나열하는 용도로 그 정보를 사용한다.
    • 서드파티 도구들(Sandcastle이나 NDoc)을 이용해서 XML 파일을 HTML 도움말 파일로 변환한다.
  • (이하 XML 문서화 주석 태그 설명 생략)

 

[ssba]

The author

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

댓글 남기기

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