C#/Advanced C#

[More Effective C#] 2장 API 설계 (1)

로파이 2021. 12. 12. 20:44

아이템 11. API에는 변환 연산자를 작성하지 말라

 

변환 연산자 Conversion Operator, 대체가능 한 클래스로 변환하는 것을 의미한다.

 

어떤 타입이든 System.Object 타입으로 변환 가능하다.

인터페이스를 구현한 클래스는 인터페이스로 베이스 클래스를 상속한 클래스는 베이스 클래스로 대체 가능하다.

public class Circle : Shape
{
    private Coord center;
    private int radius;
	
    public Circle(Coord c, int r)
    {
    	this.center = c;
        this.radius = r;
    }
    
    static public implicit operator Ellipse(Circle c)
    {
    	return new Ellipse(c.center, c.center, c.radius, c.radius);
    }
}

Shape를 상속하는 Circle이 있고 Ellipse 클래스로 암묵적 변환을 허용하는 변환 연산자를 오버로딩했다 하자.

암묵적 변환의 결과는 대체되어 만들어진 새로운 인스턴스가 반환 된다는 것이다.

public void Flatten(Ellipse e, int r)
{
    e.lengthX * r;
    e.lengthY / r;
}

// Circle 인스턴스에 적용되지 않는다.
Circle c = new Circle(new Coord(0,0), 10);
Flatten(c, 2);

Flatten 메서드는 Ellipse 객체의 상태를 바꾸는 함수이다. Circle 클래스는 Ellipse로 암묵적 변환이 가능하므로 Flatten 메서드 인자에 전달하여 호출가능하며, 이때 만들어지는 Ellipse는 변환 연산에 의해 새로운 객체를 가리키기 때문에 바깥쪽 Circle과는 무관한 동작이다. 따라서 외부로 보여지는 것과 실제 동작 방식이 달라 오해를 만든다.

 

아이템 12. 선택적 매개변수를 사용하여 메서드 오버로드를 최소화하라

 

선택적 매개변수는 매개변수에 기본값이 지정된 매개변수를 말한다.

public void Access(string userName, DateTime time = DateTime.Now)

또한 C#에서는 매개변수 이름를 사용하여 매개변수를 전달할 수 있다. 이는 명명된 매개변수라고 하며 다음과 같이 사용할 수 있다.

public void Access(string userName, int tokenTypeId, long tokenId)

// 
Access(userName = "Jeew", tokenTypeId = 2, tokenId = receivedTokenId);

 

선택적 매개변수 혹은 명명된 매개변수를 사용하면 메서드를 오버로드하는 방법보다 선택적으로 매개변수를 전달하여 간단하게 사용할 수 있다.

매개변수의 이름은 MSIL에 저장되고 메서드 호출부가 아닌 정의부에 포함되므로 매개변수 이름이 변경된 일부 메서드를 포함하는 어셈블리가 배포되었을 때 기존 사용되던 이름과 다르면 컴파일 오류가 발생한다.

public void Access(string userName, int authTokenTypeId, long authTokenId)

// 컴파일 오류 (명명된 이름이 변경됨)
Access(userName = "Jeew", tokenTypeId = 2, tokenId = receivedTokenId);

 

업그레이드의 일부로 기존 메서드에 기본값이 있는 선택적 매개변수를 추가하여 메서드를 변경하면 실제 사용하는 호출부에서 런타임시에 어플리케이션에 문제를 일으킬 수 있다. 선택적 매개변수를 사용하여 호출하는 것이 호출부의 MSIL에 의존되기 때문이다.

 

아이템 13. 타입의 가시성을 제한하라

 

public / internal / private 접근 제한자를 이용하여 타입의 가시성을 제한해야한다.

인터페이스의 사용은 해당 인터페이스를 사용하는 타입의 공통적인 행동을 정의한다. 따라서 public 인터페이스 메서드만 노출되는 효과를 지닌다.

 

아이템 14. 상속보다는 인터페이스를 정의하고 구현하는 것이 낫다

 

상속 : 'is a' 객체를 정의한다.

인터페이스 : 'behave like' ~처럼 동작한다를 의미하며 객체의 기능을 표현한다.

 

인터페이스 사용 유의점

  • 인터페이스는 기능을 정의하기 때문에 포함된 메서드, 속성, 인덱서 등을 public으로 반드시 구현해야한다.
  • 인터페이스는 간략히 정의하고 확장 메서드를 이용해서 기능을 추가한다. (IEnumerable<T>의 예처럼)

추상 베이스 클래스는 파생 클래스의 구현부를 포함할 수 있다. 이는 추상 클래스가 구현한 내용을 파생 클래스에서 호출하여 재사용할 수 있기 때문이다.

 

인터페이스를 사용하는 이점

  • 인터페이스의 구현체에서 확장 메서드에 해당하는 내용을 따로 구현할 필요가 없시 해당 기능을 사용할 수 있다.
  • 인터페이스를 매개변수로 사용하거나 반환 타입으로 사용하는 메서드는 인터페이스 구현체를 모두 지원하므로 재사용성이 높다.
  • 인터페이스를 반환하는 메서드는 해당 객체의 가시성을 제한하여 필요한 메서드만 노출시킬 수 있다.
  • 인터페이스를 구현한 값 타입은 박싱과 언박싱을 겪지 않고 인터페이스의 public 메서드를 호출할 수 있다.
public struct NameCard : IComparable<NameCard>, IComparable
{
    private string Name;
    private string Depart;
    
    public NameCard(string name, string depart)
    {
    	this.Name = name;
        this.Depart = depart;
    }

    public int CompareTo(NameCard other)
    {
        if (Depart == other.Depart)
          return Name.CompareTo(other.Name);
         
        return Depart.CompareTo(other.Depart);
    }
    
    int IComparable.CompareTo(object obj)
    {
    	if ((obj is NameCard card))
        {
        	return CompareTo(card);
        }
        
        throw new ArgumentException("Not Matching Type");
        return 1;
    }
}

인터페이스를 사용하면 제너릭 타입을 사용하는 CompareTo를 박싱과 언박싱을 사용하지 않게 된다. 논 제터릭 타입의 CompareTo를 사용한더라도 호출하는 인스턴스는 박싱과 언박싱을 하지 않기 때문에 사용 이점이 있다.

 

아이템 15. 인터페이스 메서드와 가상 메서드의 차이를 이해하라

 

동작 방식을 이해한다.

public interface IMessage
{
	public void Message();
}

public class A : IMessage
{
	public void Message() => Console.WriteLine("Class A");
}

public class B : A
{
	public new void Message() => Console.WriteLine("Class B");
}

클래스 B는 A를 상속하지만 Message()를 가상으로 선언하지 않기 때문에 new로 새로 정의해야한다.

B b = new B();
b.Message(); // "Class B"
IMessage m = b as IMessage;
m.Message(); // "Class A"

new는 기존 메서드를 숨기려고 사용하지만 IMessage로 지칭하면 A의 Message()를 호출할 수 있게 된다.

public class B : A, IMessage
{
	public new void Message() => Console.WriteLine("Class B");
}

B b = new B();
b.Message(); // "Class B"
IMessage m = b as IMessage;
m.Message(); // "Class B"

B를 인터페이스 구현체로 만들면 이제 A의 Message()를 호출하지 않는다.

하지만 여전히 A 참조로 접근하면 A의 Message()를 호출할 수 있다.

B b = new B();
b.Message(); // "Class B"
A m = b as A;
m.Message(); // "Class A"

문제를 완벽히 해결하려면 Message() 함수를 가상으로 선언해야한다.

public class A : IMessage
{
	public virtual void Message() => Console.WriteLine("Class A");
}

public class B : A
{
	public new void Message() => Console.WriteLine("Class B");
}

혹은 실제 구현부를 protected 가상 메서드로 정의하고 인터페이스에서 해당 메서드를 호출하는 것으로 클래스마다 호출해야하는 내용을 선택할 수 있게 한다.

public class A : IMessage
{
    protected void OnMessage()
    {
    }
    
    public virtual void Message()
    {
    	OnMessage();
    }
}

 

아이템 16. 상태 전달을 위한 이벤트 패턴을 구현하라

 

관찰자 패턴의 일종으로 이벤트 핸들러는 델리게이트를 사용하여 구현된다.

속성과 비슷하게 event 키워드로 수식된 필드는 add와 remove를 사용하여 델리게이트를 추가하고 삭제할 수 있다.

추가 삭제는 컴파일러가 만들며 멀티스레드에 안전하게 기능이 구현되어 있다.

public class SystemChannel
{
    private Channel curChannel;
    
    public event EventHandler<ChannelChangedEventArgs> OnChanged
    {
    	add { OnChanged += value; }
        remove { OnChanged -= value;
    }
    
    public void ChangeChannel(channel c)
    {
    	// 새로운 채널 할당으로 이벤트를 발생시킨다.
        var oldChannel = curChannel;
        curChannel = c;
        var args = new ChannelChangedEventArgs(oldChannel, curChannel);
    	OnChanged?.Invoke(this, args);
    }
}

 

이벤트 핸들러는 해당 이벤트의 구독자가 어떤 클래스인지 알 필요가 없다. 등록된 메서드를 호출함으로써 이벤트의 발생을 알리기만 할 뿐이다.

 

아이템 17. 내부 객체를 참조로 반환해서는 안 된다.

 

객체를 참조로 반환하는 속성이 있다하자.

 public class Person
 {
   private string Name;
   public Person(string name)
   {
   	Name = name;
   }
   public List<Person> Friends { get; }
 }

Person 객체에 Friend라는 속성은 내부의 private 필드에 해당 참조를 저장하고 get 접근 방식을 제공한다.

해당 의도는 접근만 가능하게 하여 수정하지 말라는 의미를 지니지만 해당 참조를 통해서 필드의 참조를 바꾸지는 못해도 필드 인스턴스의 상태를 변경할 수 있다.

Person p = new Person("Apple");
p.Friends.Add(new Person("David")); // 외부에서 참조되어 변경된다.

 

내부 데이터를 변경 불가능하게 관리하려면 다음과 같은 전략을 사용해야한다.

 

1. 값 타입을 사용한다. 값 타입은 외부에서 접근하면 복사본이 전달된다.

2. 변경 불가능한 타입. 변경 불가능한 타입은 언제나 안전한다.

3. 접근을 제한하도록 인터페이스를 정의한다.

IEnumerable<T> 인터페이스는 내부 컬렉션의 수정과 관련된 메서드를 노출하지 않는다.

public class Person
{
    private string Name;
    public Person(string name)
    {
        Name = name;
    }
    private List<Person> friends;
    public IEnumerable<Person> Friends => friends; 
}

만약 어떤 방식으로 해당 내부 데이터를 수정하고 싶다면, 전용 메서드를 정의하거나 내부 데이터를 검증하고 이를 알리는 이벤트 패턴의 구현도 고려하는 것이 좋다.

4. 마지막 방법은 수정이 불가능한 읽기 전용 클래스를 반환하는 래퍼를 만드는 것이다.

System.Collections.ObjectModel에 포함된 ReadOnlyCollection타입은 컬렉션을 래핑하여 컬렉션을 수정하지 않는 메서드만 노출한다.

public IReadOnlyCollection<Person> FriendsAsCollection => new ReadOnlyCollection<Person>(friends);

 

아이템 18. 이벤트 핸들러보다는 오버라이딩을 사용하라

 

WPF 어플리케이션에서 버튼 콜백을 만드는 방법은 프로그래밍적으로 두 방법으로 구현할 수 있다.

public partial class MainWindow : Window
{
    public MainWindow()
    {
    	InitializeComponent();
    }
	
    // 가상함수 재정의
    protected override void OnMouseDown(MouseButtonEventArgs e)
    {
    	DoMouseThings(e);
        base.OnMouseDown(e);
    }
    
    // 이벤트 핸들러의 콜백
    private void OnMouseDownHandle(object sender, MouseButtonEventArgs e)
    {
    	DoMouseThings(e);
    }
}

이벤트 핸들러는 델리게이트로 구현되어 있기 때문에 등록된 여러 핸들러를 호출하는 과정에서 예외가 발생하면 아직 호출되지 않은 다른 핸들러가 호출되지 않는다.

가상 함수를 재정의하면 가장 먼저 호출되고 그 다음 베이스 클래스의 가상 함수가 호출된다. 베이스 클래스의 함수는  동록된 이벤트 핸들러를 호출한다.

 

아이템 19. 베이스 클래스에 정의된 메서드를 오버로드해서는 안된다.

 

가상 함수를 만들어 상속 클래스간에 공통적인 역할을 수행할 때를 제외하고 베이스 클래스의 메서드를 같은 이름으로 파생 클래스에서 오버로드하면 안된다.

 

오버로드의 의미는 같은 동작을 하는 메서드를 다른 입력 매개변수로 동작하게끔 하기위해 정의한다. 하지만 다른 동작을 하는 메서드를 오버로딩하여 사용자로 하여금 혼란을 유발시키면 안된다.

public class Food {}
public class Chicken : Food {}

public class Animal
{
    public void Feed(Chicken f) => WriteLine("Animal : Feed");
    public void Call(Chicken f) => Feed(f);
    public void Ate(IEnumerable<Chicken> f) => Feed(f);
}

public class Tiger
{
    public void Feed(Food f) => WriteLine("Tiger : Feed");
    public void Call(Food f, Food p = null) => Feed(f);
    public void Ate(IEnumerable<Food> f) => Feed(f);
}

오버로드된 메서드의 예시

오버로드 메서드의 선택은 컴파일 시점에서 호출 가능한 클래스 계통 중 가장 파생클래스의 타입 메서드를 선택한다.

 

다음 호출은 모두 Tiger의 메서드를 호출한다.

 

var t = new Tiger();
t.Feed(new Chicken()); // "Tiger : Feed"
t.Feed(new Food()); // "Tiger : Feed"

만약 Tiger를 Animal로 참조하여 Feed를 호출하면 다음 호출은 Animal의 Feed를 호출한다.

Animal a = t;
a.Feed(new Chicken()); // "Animal : Feed"

가상 메서드가 아니기 때문에 컴파일 타입을 따르고 Animal의 메서드를 택하게 된다.

 

다음 두 메서드도 첫번째는 Tiger의 선택적 매개변수를 가진 오버로드 메서드를 선택할 수 있고 IEnumerable<T>의 경우 공변성이 적용되기 때문에 호출 가능하므로 모두 Tiger의 메서드가 호출된다.

t.Call(new Chicken()); // "Feed : Tiger"
t.Ate(new List<Chicken>()); // "Feed : Tiger"

 

참고 : More Effective C# 빌 와그너 지음 - 한빛 미디어