[ Item 19 : 베이스 클래스에 정의된 메서드를 오버로드해서는 안된다 ]
베이스 클래스에서 정의된 메서드를 파생 클래스에서 오버로드하면 어떤 메서드가 호출될지 정확히 이해하기가 어려워진다.
1. 명확한 메서드 이름을 사용한다
해당 기능을 명확하게 설명하는 이름을 지어주자.
2. 오버라이딩을 통해 기능을 확장한다
기존 메서드의 기능을 수정하거나 확장해야 할 때는, 오버라이딩을 사용하자.
3. 메서드 오버로딩 최소화
베이스 클래스의 메서드와 동일한 이름을 가진 새로운 메서드를 추가할 필요가 있을 때는, 혼란을 피하기 위해 가능한 한 메서드 오버로딩을 피하고 다른 이름을 지어주자.
[ Item 20 : 이벤트가 런타임 시 객체 간의 결합도를 증가시킨다는 것을 이해하라 ]
이벤트 기반 API에는 결합도를 증가시킬 수 있는 문제가 있다.
예시로 이벤트의 매개변수 타입이 진행 상태를 포함하는 경우를 확인해보자.
public class WorkerEngine
{
public event EventHandler<WorkerEventArgs> OnProgress;
public void DoLotsOfStuff()
{
for (int i = 0; i < 100; i++)
{
SomeWork();
var args = new WorkerEventArgs();
args.Percent = i;
OnProgress?.Invoke(this, args);
if (args.Cancel)
{
return;
}
}
}
}
public class WorkerEventArgs : EventArgs
{
public int Percent { get; set; }
public bool Cancel { get; set; }
}
이 코드는 OnProgress 이벤트를 구독하는 다수의 이벤트 핸들러를 하나로 묶어버리게 된다.
만약 첫 번째 이벤트 핸들러가 취소 요청을 하고, 다음 이벤트 핸들러는 이벤트 값을 바꾸려고 할 때, 코드가 꼬여서 생각한대로 동작하지 않을 가능성이 높아지기 때문이다.
이처럼 여러 개의 이벤트 핸들러가 이벤트에 결합되어 있고 이벤트의 매개변수가 변경가능하다면
마지막으로 수행되는 이벤트 핸들러는 이전에 수행된 변경사항을 모두 가지고 실행되어 버린다.
public class WorkerEventArgs : EventArgs
{
public int Percent { get; set; }
public bool Cancel { get; private set; }
public void RequestCancel()
{
Cancel = true;
}
}
Cancel의 접근 제한자를 변경해서 Cancel 값이 true가 된 이후에는 어떤 이벤트 핸들러도 이 값을 수정할 수 없도록 했다.
하지만 늘 이렇게 코드를 수정할 수 있는게 아니기 때문에 2가지 방법을 추가로 사용할 수 있다.
- 인터페이스를 하나 정의해서 상속받은 후 그 인터페이스의 메서드를 호출해주는 방법
- 이벤트를 수신하기 위한 델리게이트를 정의하고 이 타입의 객체를 취해서 호출해주는 방법
이렇게 하면 다중 이벤트 핸들러 지원 여부와 Cancel 값을 어떻게 처리할지 등을 결정할 수 있다.
결합도 증가로 인해 이벤트 해제의 중요성도 같이 증가한다
1. 메모리 누수의 위험
구독자가 이벤트 생성자에 대한 참조를 유지하고 있기 때문에, 생성자의 생명 주기가 끝나더라도 가비지 컬렉터(GC)에 의해 회수되지 않을 수 있다. 즉, 구독자가 계속해서 생성자를 참조하고 있으면, 생성자는 메모리에서 해제되지 않아 메모리 누수가 발생할 수 있다.
2. 의도되지 않은 동작
이벤트가 핸들러가 제거되지 않았다면, 생성자는 여전히 이벤트를 발생시킬 수 있다.
생성자는 이벤트가 발생할 때마다 구독자의 핸들러를 호출할 것인데, 이러한 동작은 이벤트 구독자가 삭제된 후에도 계속되어서는 안된다.
그래서 이벤트 구독자가 더 이상 이벤트를 수신할 필요가 없을 때(구독자를 삭제할 때), 반드시 이벤트 핸들러의 연결을 끊은 후에 삭제되어야 한다.
[ Item 21 : 이벤트는 가상으로 선언하지 말라 ]
가상 이벤트는 프로퍼티 이벤트와 동일하게, 내부적으로 이벤트 핸들러를 보관하는 private 필드를 생성하고 add(), remove() 같은 함수도 생성해준다.
public abstract class WorkerEngineBase
{
public virtual event EventHandler<WorkerEventArgs> OnProgress;
}
public class WorkerEngineDerived : WorkerEngineBase
{
public override event EventHandler<WorkerEventArgs> OnProgress;
}
오버라이딩 된 파생클래스의 이벤트에 핸들러를 추가하면 베이스 클래스에는 핸들러가 추가되지 않고, 파생 클래스의 핸들러 저장 필드에 핸들러가 추가된다.
그래서 베이스 클래스에서 이벤트를 발생 시키더라도 아무런 일이 일어나지 않는다.
이를 해결하기 위해 아래와 같이 파생 클래스에 add()와 remove() 접근자를 명시적으로 작성할 수 있는데,
public override event EventHandler<WorkerEventArgs> OnProgress
{
add => base.OnProgress += value;
remove => base.OnProgress -= value;
}
이러다 보면 오히려 클래스간의 결합도가 높아지고 가상 이벤트는 사용하지 않아야 함을 알 수 있다.
[ Item 22 : 명확하고 간결하며 완결된 메서드 그룹을 생성하라 ]
1. 의미가 명확한 이름 사용
메서드와 그 매개변수의 이름은 수행하는 작업과 기대하는 결과를 명확히 가지고 있어야 한다. 이름만 보고도 메서드의 기능을 유추할 수 있어야 한다는 뜻이다.
2. 일관된 용어 사용
전체 API 내에서 동일한 개념에 대해 일관된 용어를 사용해야 한다.
3. 메서드 오버로드 최소화
접근 가능한 오버로드들 중 컴파일러가 어느 것을 선택할지를 사용자가 직관적으로 알 수 있어야 한다. 오버로드 메서드가 추가될 수록 유용해지기보다 복잡성만 커지기 때문에 오버로드는 최소화한다.
[ Item 23 : 생성자, 변경자, 이벤트 핸들러를 위해 partial 클래스와 메서드를 제공하라 ]
partial 클래스와 메서드는 코드의 관리성과 확장성을 높여주는 강력한 기능이다.
특히 자동 생성된 코드와 개발자가 작성하는 코드를 분리하고자 할 때 유용하다.
Partial 클래스의 장점
- 큰 클래스나 복잡한 클래스를 여러 파일로 분리하여 관리할 수 있다. 덕분에 코드의 가독성을 높이고, 여러 개발자가 동시에 작업하기 쉽게 해준다.
- UI 디자이너나 다른 코드 생성 도구에 의해 자동 생성된 코드와 개발자가 작성하는 코드를 분리할 수 있다. partial을 사용해 자동 생성된 코드가 개발자의 코드와 혼합되어 꼬이는 것을 방지할 수 있다.
Partial 메서드의 장점
- 개발자가 필요에 따라 선택적으로 구현할 수 있는 메서드를 제공할 수 있다. 메서드의 선언은 있지만 구현은 없는 상태로 남겨둘 수 있으며, 이 경우 컴파일러가 해당 메서드 호출을 자동으로 제거한다.
주의점
- partial 클래스와 메서드를 남용하면 코드 구조 파악이 어려워질 수 있다.
- 자동 생성된 코드를 절대 수정해서는 안된다.
[ Item 24 : 설계 선택지를 제한하는 ICloneable은 사용을 피하라 ]
ICloneable의 문제점
- ICloneable 인터페이스가 제공하는 Clone 메서드는 얕은 복사와 깊은 복사 중 어떤 것을 수행해야 하는지 명시하지 않는다. 때문에 구현체에 따라 동작이 달라지며 예상치 못한 동작이 수행될 수 있다.
- Clone 메서드는 object 타입을 반환하기 때문에, 반환된 객체를 해당 타입으로 캐스팅해야 한다. 이 과정에서 타입 불일치로 인한 런타임 에러가 발생할 수 있다.
ICloneable 인터페이스는 그 설계상의 모호성과 타입 안정성 문제가 있기 때문에 사용하지 말자.
만약 전체 계층 구조에서 ICloneable을 반드시 구현해야 하는 경우라면 추상 protected Clone() 메서드를 만들어서 모든 파생 클래스가 강제로 이를 구현하도록 할 수 있다.
그리고 정 사용하고 싶다면 최하단의 파생 클래스에만 추가하도록 하자.
[ Item 25 : 배열 매개변수에는 params 배열만 사용해야 한다 ]
var labels = new string[] { "one", "two", "three", "four", "five" };
ReplaceIndices(labels);
private void ReplaceIndices(object[] param)
{
for (int i = 0; i < param.Length; i++)
{
param[i] = "aa";
}
}
배열 자체는 값(Call by Value) 으로 전달되지만 배열의 원소들은 여전히 참조타입으로 전달(Call by Reference)된다.
따라서 배열을 전달받은 메서드 내에서 배열의 내용을 임의로 수정해버릴 수 있는 위험이 있다.
params 배열의 장점
- params 키워드를 이용하면 사용자는 배열을 명시적으로 생성하지 않고, 메서드에 여러 인자를 직접 전달할 수 있다.
- 메서드가 다양한 수의 인자를 받을 수 있게 할 수 있으므로 재사용성이 크게 증가한다. 특히 다양한 인자를 받아 처리해야하는 유틸 메서드에서 유용하다.
public void LogMessages(params string[] messages)
{
foreach (var message in messages)
{
Console.WriteLine(message);
}
}
LogMessages("메시지1", "메시지2", "메시지3");
주의사항
- params 키워드 사용 시 매 호출마다 새로운 배열이 생성된다. 성능이 중요한 상황에서는 이를 고려해야 한다.
- 배열 전달 시 타입이 엄격히 고려되지 않기 때문에 런타임에 타입미스매치 에러 등이 발생할 수 있다.
더 나은 대안으로, 매개 변수가 시퀀스를 나타낸다면 IEnumerable<T> 매개변수를 사용하는 것이 좋다.
[ Item 26 : 지역 함수를 사용해서 반복자와 비동기 메서드의 오류를 즉시 보고하라 ]
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 frequency must be a positive integer", paramName: nameof(sampleFrequency));
}
var index = 0;
foreach (var item in sequence)
{
if (index % sampleFrequency == 0)
{
yield return item;
}
}
}
var sequence = new List<int>() { 1, 2, 3, 4, 5 };
Console.WriteLine("Exception not thrown yet!");
var samples = GenerateSample(sequence, -8);
foreach (var element in samples) // 실제 사용할 때 예외가 발생한다.
{
Console.WriteLine(element);
}
위 코드 처럼 IEnumerable을 반환하는 메서드는 실제로 실행되기 전까지 오류를 검출하지 못할 수 있다.
IEnumerable 메서드는 실제로 시퀀스를 순회하거나 요소를 사용하려고 할 때 평가되기 때문이다. (Lazy 연산 개념)
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 frequency 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;
}
}
}
GenerateSampleImpl() 처럼 지역함수를 정의해서 문제를 해결할 수 있다.
지역 함수는 메서드 내에 정의된 함수로, 이를 사용하면 메서드의 실행을 시작하는 시점에서 바로 파라미터 검증 같은 오류 검출 로직을 수행할 수 있다.
동일한 기법을 비동기 메서드에도 적용할 수 있다.
public static Task<string> LoadMessageFinal<T>(string userName)
{
if (string.IsNullOrEmpty(userName))
{
throw new ArgumentNullException(nameof(userName));
}
return LoadMessageImpl();
async Task<string> LoadMessageImpl()
{
var settings = await Loaduser();
var message = "message : " + settings.Message ?? "Empty";
return message;
}
}
이렇게 코드를 작성하면 다른 메서드처럼 호출 도중 발생한 오류를 즉시 throw 할 수 있으므로 문제를 해결하기 쉬워진다.
'Lang > C#' 카테고리의 다른 글
[More Effective C#] Chapter 3. 요약 (2) (0) | 2024.04.07 |
---|---|
[More Effective C#] Chapter 3. 요약 (1) (0) | 2024.03.31 |
[More Effective C#] Chapter 2. 요약 (1) (4) | 2024.03.17 |
[More Effective C#] Chapter 1. 요약 (0) | 2024.03.10 |
[Effective C#] C# 언어 요소 (3) | 2023.02.02 |