C#/Advanced C#

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

로파이 2021. 12. 12. 22:40

아이템 20. 이벤트가 런타임 시 객체 간의 결합도를 증가시킨다는 것을 이해하라

 

이전 이벤트 핸들러 패턴의 예시로 만든 SystemChannel 클래스를 본다.

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);
    }
}

이벤트 핸들러는 델리게이트 형식으로 등록된 메서드를 모두 호출하도록 한다. 하지만 이때 전달하는 ChannelChangedEvnetArgs는 참조 타입으로 호출되는 메서드 내에서 상태가 변경될 가능성이 있다.

 

따라서 이벤트 핸들러는 정적 시간의 결합도를 느슨하게 하지만 런타임 시 객체간의 결합도를 증가시킨다는 특징이 있다.

 

아이템 21. 이벤트는 가상으로 선언하지 말라

public GameEngine
{
	public virtual event EventHandler<EventArgs> OnResourceProcessing { add; remove; }
}

public AMyEngine : GameEngine
{
	public override event EventHandle<EventArgs> OnResourceProcessing { add; remove; }
}

가상으로 정의된 이벤트 필드는 속성과 마찬가지로 private 필드에 실제 이벤트 핸들러를 보관하며 add와 remove에 따른 접근 메서드를 컴파일러가 생성해준다.

 

파생 클래스가 이를 재정의하여 사용하는 것은 이벤트를 베이스 클래스에 추가하고 삭제하는 것이 아니라 파생 클래스의 private 필드에 이벤트 핸들러를 보관하며 파생 클래스의 이벤트 핸들러에 추가하고 삭제한다.

 

아이템 22. 명확하고 간결하며 완결된 메서드 그룹을 생성하라

 

오버로드(overload), new, 오버라이드(override)를 잘 이해하고 사용하고 계층을 가지는 클래스에서 특히 메서드의 모호함에 주의해야한다.

 

오버로드 : 함수 이름은 같지만 입력 매개변수만 다른 메서드를 의미한다. 같은 이름을 가지는 오버로드된 메서드는 항상 같은 기능을 수행해야 하며 파생 클래스에서 기반 클래스의 메서드의 오버로딩을 하지 않도록 한다(되도록이면 override 하여 사용할 것이다).

 

new : 기반 클래스에서 메서드 이름과 매개변수가 모두 일치하여 시그니쳐가 같다. 하지만 new를 꼭 사용하는 경우는 기반 클래스의 메서드를 사용하지 않고 새로운 기능으로 동작하게끔 만들 때 사용한다.

 

오버라이드(override) : 기반 클래스의 기능을 재사용하기 위해 사용한다. 기반 클래스는 가상 함수를 포함하고 공통된 기능을 수행한다. 

 

아이템 23. 생성자, 변경자, 이벤트 핸들러를 위해 partial 클래스와 메서드를 제공하라

 

C#에서 partial 클래스는 두 개 이상의 파일에 나누어 정의할 수 있게 하였다. 주로 사용자가 작성한 코드와 이를 기반으로 자동 생성한 코드를 연계할 때 사용한다.

 

- partial 메서드의 사용 예시 - Update 메서드

2개의 partial 메서드를 사용하여 하나는 변경 전에 호출하는 훅 메서드를 제공하고 유효성 검사뒤 변경 후에 호출되는 훅 메서드를 제공한다.

public partial class AutoGeneratedClass
{ 
    private struct ResponseChange
    {
        public readonly int oldVal;
        public readonly int newVal;

        public ResponseChange(int o, int n)
        {
            oldVal = o;
            newVal = n;
        }
    }

    private class RequestChange
    {
        public ResponseChange Values { get; set; }
        public bool IsCancel { get; set; }
    }

    partial void OnValueChanging(RequestChange data);
    partial void OnValueChanged(ResponseChange result);

    private int storage;

    public void Update(int newValue)
    {
        // 데이터 변경 요청
        RequestChange request = new RequestChange()
        {
            Values = new ResponseChange(storage, newValue),
            IsCancel = false
        };

        // 첫번째 훅 메서드 : 데이터 검증
        OnValueChanging(request);

        if (!request.IsCancel)
        {
            storage = newValue;

            // 두번째 훅 메서드 : 데이터 적용
            OnValueChanged(request.Values);
        }
    }
}

위 코드는 코드 생성기 프로그램에 의해 작성된 partial 클래스의 일부분이다.

Update 메서드안에 두 개의 훅 메서드를 사용하며 각각 데이터 변경 전과 검증 후 변경된 데이터를 알리기 위해 사용한다.

두 메서드는 partial 메서드로 정의되며 abstract나 vritual이 될 수 없고 반드시 void 형식이어야한다.

 

사용자는 훅 메서드의 본문을 작성해서 직접 partial 메서드를 작성해준다.

public partial class AutoGeneratedClass
{
    partial void OnValueChanging(RequestChange data)
    {
        if (data.Values.newVal >= 0)
        {
            Console.WriteLine($"Changes to : {data.Values.newVal}");
        }
        else
        {
            data.IsCancel = true;
        }
    }

    partial void OnValueChanged(ResponseChange result)
    {
        Console.WriteLine($"Update is finished : {result.newVal}");
    }
}

 

아이템 24. 설계 선택지를 제한하는 ICloneable은 사용을 피하라

 

기본적으로 값 타입에 대해서 복사 기능을 지원할 필요가 없다. 기본적으로 모든 필드를 복사하여 새로운 값타입의 인스턴스를 만들기 때문이다. 

 

값 타입에 참조 타입 필드가 있다면 해당 타입은 참조 복사만 할 것인지 아에 새로운 객체를 만들 것인지를 택해야한다.

 

참조 타입의 복사는 ICloneable 인터페이스를 구현해서 사용할 수 있다.

예시) 게임에서 씬과 씬을 구성하는 액터 클래스

public class UScene 
{
    private SortedList<string, Dictionary<int, AActor>> namedActors = new SortedList<string, Dictionary<int, AActor>>();
    public void Spawn(AActor actor)
    {
        if (!namedActors.TryGetValue(actor.Name, out Dictionary<int, AActor> container))
        {
            container = new Dictionary<int, AActor>();
            namedActors.Add(actor.Name, container);
        }

        container.Add(actor.Id, actor);
    }
    public void Erase(AActor actor) 
    {
        if (!namedActors.TryGetValue(actor.Name, out Dictionary<int, AActor> container))
        {
            throw new InvalidOperationException($"{actor.Name} : Not Found Type in this Scene");
        }

        if (!container.ContainsKey(actor.Id))
        {
            throw new InvalidOperationException($"{actor.Name} : Not Found Actor in this Scene");
        }

        container.Remove(actor.Id);
    }
}

public class AActor : ICloneable
{
    public static int genKeyId;
    public int Id { get; }
    public string Name { get; }
    public UScene Scene { get; private set; }

    public AActor(string name)
    {
        Id = Interlocked.Increment(ref genKeyId);
        Name = name;
    }

    public virtual void OnCreated(UScene scene)
    {
        Scene?.Erase(this);
        Scene = scene;
        Scene.Spawn(this);
    }

    public object Clone()
    {
        return new AActor(Name);
    }
}

Clone()는 해당 인스턴스를 복제하는 방법을 제공한다.

ICloneable 인터페이스를 구현하였다면 이를 상속한 클래스에서도 같은 인터페이스를 구현해야한다.

이를 위해 protected 복사생성자를 활용하여 Clone을 구현하는 것이 좋다.

protected AActor(AActor other)
{
	Id = Interlocked.Increment(ref genKeyId);
	Name = other.Name;
}

public virtual object Clone()
{
	return new AActor(this);
}
public class APawn : AActor, ICloneable
{
    public RigidBody rigid;
    public TimeSpan lifeTime;
    public TimeSpan lifeTimeElapsed;
    public APawn(string name, TimeSpan life)
      :
      base(name)
    {
        rigid = new RigidBody();
        lifeTime = life;
        lifeTimeElapsed = TimeSpan.Zero;
    }
    protected APawn(APawn other)
      :
      base(other)
    {
        rigid = (RigidBody)other.rigid.Clone();
        lifeTime = other.lifeTime;
        lifeTimeElapsed = TimeSpan.Zero;
    }

    public object Clone(APawn other)
    {
        return new APawn(this);
    }
    public override object Clone()
    {
        return new APawn(this);
    }
}

이제 Clone()을 사용할 차례이다.

UScene scene = new UScene();
APawn pawn1 = new APawn("Monster", TimeSpan.FromSeconds(5));

for (int i = 0; i < 10; ++i)
{
	scene.Spawn((APawn)pawn1.Clone());
}

하지만 Clone() 인터페이스는 반드시 object를 반환해야하므로 꼭 인터페이스를 상속해야하는 지는 생각해봐야한다. 

차라리 모든 파생클래스에 구현을 강제할 예정이라면 추상 클래스로 선언하고 해당 타입을 반환하도록하는 것이 낫다.

 

아이템 25. 배열 매개변수에는 params 배열만 사용해야 한다.

 

params 배열은 가변 타입의 매개변수를 받을 수 있어, 입력 매개변수를 배열로 받거나 여러 인수를 나열한 매개변수로 (a,b,c,d) 혹은 매개변수가 없는 호출을 가능하게 만든다.

 

만약 매개변수가 변경 가능한 컬렉션을 작성하고 싶다면 입력과 반환 타입이 모두 IEnumerable<T>인 메서드를 정의하고 새로운 시퀀스를 생성해서 반환하는 것이 좋다. 

 

아이템 26. 지역 함수를사용해서 반복자와 비동기 메서드의 오류를 즉시 보고하라

 

반복자 메서드, IEnumerable<T>를 반환하는 메서드는 실제 순회가 이루어질 때 해당 메서드가 평가된다.

public static IEnumerable<T> GenerateSample<T>(IEnumerable<T> sequence, int sampleFrequency)
{
    if (sequence == null)
        throw new ArgumentException("Source sequence cannot be null", paramName: nameof(sequence));

    if (sampleFrequency < 1)
        throw new ArgumentException("Sample frequence must be a positive integer", paramName: nameof(sampleFrequency));

    int index = 0;
    foreach (T item in sequence)
    {
        if (index % sampleFrequency == 0)
            yield return item;
    }
}

따라서 해당 메서드 구현에서 순회를 하기 전에 입력 매개변수에 대한 검증이나 다른 내용으로 예외를 발생시킨다면, 실제 순회를 하기전까지 예외가 발생하지 않는다.

public static void Main(string[] args)
{
    List<int> list = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8, 9 };

    var samples = GenerateSample(list, 2);
           
    // 순회를 할때 예외가 던져진다.
    foreach (var element in samples)
    {
        Console.WriteLine(element);
    }

    Console.ReadLine();
}

 

순회 시점이 언제인지 알 수 없으므로 예외가 발생했을 때 어디서 예외가 발생했는지 알기가 어려울 수 도 있다. 따라서 오류 보고가 있는 내용이 앞서 있다면 yield return 을 구현하는 구현부와 검증을 담당하는 래퍼 메서드로 분리해야한다.

 

구현 메서드를 정의하기 위해 메서드 안에 지역 메서드로 정의하고 사용하는 것이 바람직할 것이다.

public static IEnumerable<T> GenerateSampleWrapper<T>(IEnumerable<T> sequence, int sampleFrequency)
{
    if (sequence == null)
        throw new ArgumentException("Source sequence cannot be null", paramName: nameof(sequence));

    if (sampleFrequency < 1)
        throw new ArgumentException("Sample frequence must be a positive integer", paramName: nameof(sampleFrequency));

    return generateSampleImpl();

    IEnumerable<T> generateSampleImpl()
    {
        int index = 0;
        foreach (T item in sequence)
        {
            if (index % sampleFrequency == 0)
                yield return item;
        }
    }
}

이제 래퍼 메서드를 호출할 때 예외가 발생하여 전달된다. 

public static void Main(string[] args)
{
    List<int> list = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
	
    // 예외 발생
    var samples = GenerateSampleWrapper(list, 2);
           
    foreach (var element in samples)
    {
        Console.WriteLine(element);
    }

    Console.ReadLine();
}

async-await를 포함하는 Task를 반환하는 비동기 메서드는 반환된 Task 객체가 await 혹은 Wait()이 될 때만 예외를 발생시킨다. 검증 시 실패한 예외는 나중에 던져지며 이 예외를 미리 알 방법이 없다.

 

이 또한 async-await를 포함하지 않는 Task를 반환하는 래퍼 메서드와 실제 비동기 메서드를 수행하는 async-await 구현부를 따로 작성하여 검증에 대한 예외를 메서드 호출에서 알 수 있도록 작성한다.

public Task<List<Resource>> LoadResources(string path)
{
    if (string.IsNullOrEmpty(path))
        throw new ArgumentException(message: "Invalid path argument", nameof(path));

    return loadResourcesImpl();

    async Task<List<Resource>> loadResourcesImpl()
    {
        var resources = await ResourceLoader.Load(path);
        return resources.Where(r => r.IsValid);
    }
}

 

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