[ Item 11 : API에는 변환 연산자를 작성하지 말라 ]
1. 다른 타입을 원하는 커스텀 타입으로 변환하고 싶을 때는 생성자를 사용하라.
생성자는 새로운 객체를 만든다는 사실을 명확히 알려준다.
public class Circle
{
...
static public implicit operator Ellipse(Circle c)
{
return new Ellipse(...)
}
}
public static void Flatten(Ellipse e)
{
e.r1 /= 2;
}
var c = new Circle(...);
Flatten(c); // Circle -> Ellipse 로 암묵적 변환
따라서 위와 같은 상황에서 Flatten() 함수는 암묵적 변환 과정에서 새롭게 생성된 Ellipse 객체를 매개변수로 받는다.
이 임시 객체는 Flatten() 함수에 의해 변경되지만, 그 즉시 가비지가 된다.
Flatten() 함수에 의한 변환 결과는 오직 임시 객체에만 영향을 미친다.
변환 연산자의 문제점
- 가독성 저하: 코드에서 변환 연산자가 사용될 때, 그것이 명시적인지 암시적인지 구분하기 어려워 코드의 의도를 파악하기 어려울 수 있다.
- 예상치 못한 동작: 변환 연산자가 암시적으로 호출되면서 예상치 못한 결과를 초래할 수 있다.
특히, 다른 타입으로의 변환이 자동으로 이루어질 때, 그 과정에서 데이터 손실이나 변형이 발생할 위험이 있다. - 디버깅 어려움: 변환 연산자 때문에 발생한 문제는 디버깅하기 어렵다. 변환 연산자가 암시적으로 호출되는 경우, 코드의 흐름을 따라가기가 더 복잡해진다.
Flatten() 에 객체를 전달해 봐야 변경 사항이 적용되지 않기 때문에 아래와 같이 해결할 수 있다.
var e = new Ellipse(c);
Flatten(e);
Circle을 명시적으로 Ellipse 객체로 생성해준 후 Flatten()을 적용한 모습이다.
이처럼 명시적으로 변환을 보여주거나, 팩토리 메서드 패턴등을 사용해 변환 과정을 명확히 보여주는 것이 중요하다.
[ Item 12 : 선택적 매개변수를 사용하여 메서드 오버로드를 최소화하라 ]
선택적 매개변수와 명명된 매개변수를 사용하면 메서드 오버로드를 최소화 하면서도 유연성을 추구할 수 있다.
명명된 매개변수 예시
PrintOrderDetails(orderNum: 31, productName: "Red Mug", sellerName: "Gift Shop");
PrintOrderDetails(productName: "Red Mug", sellerName: "Gift Shop", orderNum: 31);
선택적 매개변수 예시
// 필수 매개변수 1개와 선택적 매개변수 2개
public void ExampleMethod(int required, string optionalstr = "default string",
int optionalint = 10)
1. 메서드의 매개변수 이름은 public 인터페이스로 취급하라.
Setname(lastName: "c", firstName: "h");
SetName()의 매개변수 이름을 다음과 같이 변경했다고 하자.
public void SetName(string last, string first);
SetName()을 기존에 호출하던 모든 어셈블리는 문제없이 실행된다.
매개변수의 이름은 IL에 저장되는데, 메서드 호출부가 아닌 메서드 정의부에 포함되기 때문에 이름을 변경하더라도 기존에 컴파일된 다른 어셈블리에는 영향을 주지 않는다.
하지만 호출 코드에 일부 수정이 있어 다시 컴파일 하려고 하면 이름 변경 때문에 컴파일 에러가 발생하게 된다.
2. 초기 배포 시에는 사용자가 원하는 매개변수의 조합을 다양하게 활용할 수 있도록 선택적 매개변수와 명명된 매개변수를 충분히 사용한다.
하지만 이후에 기존 메서드에 매개변수를 추가해야 한다면 이 부분은 반드시 메서드 오버로드 형태로 구현해야 한다.
[ Item 13 : 타입의 가시성을 제한하라 ]
1. public 인터페이스로는 가능한 적은 수의 클래스만 노출하라
코드 작성 시 뭔가를 알 필요가 없다는 사실은 개발할 때 크게 도움이 되는 것 같다.
해당 타입이 어디서 사용될 지를 신중히 고민해보자.
2. 인터페이스는 public, 구현체는 internal 로 사용하면 다른 어셈블리에 영향을 주지 않고 추가할 수 있다.
예를 들어 팩토리 패턴을 사용해서 적절한 구현체를 생성할 때, 어셈블리 밖에서는 인터페이스만 보이고, 구현체들은 어셈블리 내부에서만 볼 수 있다.
즉, 인터페이스를 통해 다른 어셈블리에서 정의된 클래스의 행동을 사용할 수 있지만, 실제로 그 구현은 해당 어셈블리 내부에서만 알고 있어야 하는 경우에 유용하다.
이렇게 클래스의 가시범위를 제한함으로서 시스템을 업데이트 하거나 확장할 때 변경해야할 코드를 최소화할 수 있다.
[ Item 14 : 상속보다는 인터페이스를 정의하고 구현하는 것이 낫다 ]
상속 : Is a (~는 ~이다)
인터페이스 : behave like (~처럼 동작한다)
추상 베이스 클래스와 인터페이스 중 어떤 것을 선택할 것인가에 대한 문제는 향후 추상화를 어떻게 지원할 것인지에 대한 결정에 달려있다.
인터페이스는 해당 인터페이스를 구현하는 타입을 어떻게 사용할지에 대한 계약과 같아서 한번 배포되고 나면 그 이후에 변경하기 어렵다.
반면 베이스 클래스는 배포 이후에도 기능을 확장할 수 있으며, 이렇게 확장된 내용은 모든 파생 클래스에 즉각 반영된다.
굳이 사용할 상황을 나누자면
- 관련된 클래스들의 공통 동작을 기술하고 구현한다면 베이스 클래스를 생성하고 상속을 사용하자.
- 재사용성을 높여 범용적인 타입을 만들고 싶다면 인터페이스를 사용하자.
하지만 이 둘은 서로 대치되는 기능이 아니므로 함께 잘 사용하는것이 중요하다.
실제로 베이스 클래스 최상단에 인터페이스가 있는 경우처럼 함께 사용하는 상황이 많다. (Item 15)
특히 재사용성을 높이기 위해서 제네릭 인터페이스를 사용할 수 있다.
예시로 닷넷에서 제공하는 IEnumerable<T> 인터페이스가 있다.
IEnumerable<T> 를 구현한 클래스는 System.Linq.Enumerable 에 정의된 모든 확장 메서드를 지원하게 된다.
구현 클래스는 대표적으로 Linq의 쿼리 메서드인 Select, Where 등을 사용할 수 있고, Linq를 활용해 데이터를 처리할 수 있다.
[ Item 15 : 인터페이스 메서드와 가상 메서드의 차이를 이해하라 ]
인터페이스를 구현하는 것과 가상 함수를 재정의하는 것은 용도도 다르고 개념도 다르다.
베이스 클래스의 추상 멤버를 구현하려면 반드시 가상화가 필요하지만 인터페이스 멤버의 경우에는 항상 가상화가 필요하지는 않다.
그리고 인터페이스는 명시적으로도 구현할 수 있어 클래스의 public 멤버와 달리 어느 정도 숨길 수도 있다.
인터페이스 메서드
- 인터페이스 메서드는 인터페이스에 선언되며, 구현 클래스에서 반드시 구현해야 한다
- 인터페이스는 구현을 제공하지 않으며, 모든 메서드는 기본적으로 추상 메서드이다.
- 인터페이스를 통해 다형성을 구현할 수 있으며, 다른 클래스가 이를 구현할 때는 인터페이스에 정의된 모든 메서드를 구현해야 한다.
- 인터페이스를 사용하면 클래스의 기능을 확장하는 데 유용하다. 다양한 클래스가 동일한 인터페이스를 구현함으로써, 다형성을 통한 유연성을 제공할 수 있다.
가상 메서드
- 가상 메서드는 클래스 내에 선언되며, virtual 키워드를 사용하여 선언한다.
- 가상 메서드는 기본 구현을 제공하지만, 파생 클래스에서 override 키워드를 사용하여 재정의할 수 있다.
- 가상 메서드를 사용하면, 파생 클래스에서 부모 클래스의 메서드 구현을 변경할 수 있어, 코드의 재사용성과 유연성이 증가한다.
- 가상 메서드는 실행 시간에 결정되는 동적 바인딩을 사용한다. 이는 객체의 실제 유형(생성 시 결정)에 따라 호출되는 메서드가 결정되는 방식이다.
차이점
- 인터페이스 메서드는 구현을 제공하지 않지만, 가상 메서드는 기본 구현을 제공한다.
- 인터페이스 메서드는 다형성을 제공하는 데 중점을 두고, 여러 클래스가 같은 인터페이스를 구현할 수 있도록 한다.
반면, 가상 메서드는 상속을 통해 클래스 계층 내에서 메서드의 구현을 커스터마이징할 수 있게 한다. - 가상 메서드는 동적 바인딩을 사용하며, 실행 시간에 메서드를 결정한다.
인터페이스 메서드도 동적 바인딩을 사용하지만, 구현 클래스에서 명시적으로 메서드를 구현해야 한다.
interface IMessage
{
void Message();
}
public class MyClass : IMessage
{
public void Message() => ...;
}
public class MyDerivedClass : MyClass, IMessage
{
public new void Message() => ...;
}
위와 같은 상속, 구현 관계인 경우 MyDerivedClass.Message() 메서드는 아직 new 키워드가 필요하다. 따라서 아래와 같은 문제가 발생하는데
MyDerivedClass d = new MyDerivedClass();
d.Message(); // "MyDerivedClass" 출력
IMessage m = d as IMessage;
m.Message(); // "MyDerivedClass" 출력
MyClass b = d;
b.Message(); // "MyClass" 출력
이 문제를 해결하는 방법은 베이스 클래스의 Message() 메서드를 가상(virtual)으로 선언하는 것이다.
public class MyClass : IMessage
{
public virtual void Message() => ...;
}
public class MyDerivedClass : MyClass
{
public override void Message() => ...;
}
이제 MyDerivedClass, MyClass, IMessage 참조 중 어느 것이든 항상 오버라이딩 된 버전이 호출된다.
가상 메서드는 실행 시간에 결정되는 동적 바인딩을 사용한다. 이는 객체의 실제 유형에 따라 호출되는 메서드가 결정되는 방식이다.
가상 함수를 더 적극적으로 사용하고 싶다면 MyClass 를 추상 클래스로 변경할 수 있다.
public abstract class MyClass : IMessage
{
public abstract void Message();
}
인터페이스 내의 메서드를 실제로 구현하지 않으면서도, 인터페이스를 구현한 것처럼 할 수 있다.
[ Item 16 : 상태 전달을 위한 이벤트 패턴을 구현하라 ]
이벤트는 객체나 시스템의 상태 변화를 알리기 위해 사용되며,
특히 GUI 프로그래밍, 실시간 시스템, 네트워크 통신 등에서 중요한 역할을 한다.
예를 들어, 사용자의 클릭, 시스템의 상태 변화 등이 있다.
이때 이벤트 프로듀서(이벤트를 발생시키는 객체)와 이벤트 컨슈머(이벤트에 반응하는 객체) 사이의 결합도를 낮춰, 코드의 유연성과 재사용성을 높일 수 있다.
상태 전달을 위한 이벤트 패턴 구현
상태 전달 이벤트 패턴은 이벤트가 발생했을 때, 이벤트와 함께 추가적인 정보(상태)를 전달하고자 할 때 사용된다.
이벤트 핸들러 메서드는 일반적으로 두 개의 매개변수를 가진다.
object sender와 EventArgs e
sender는 이벤트를 발생시킨 객체, e는 이벤트와 함께 전달되는 추가 정보를 담고 있다.
EventArgs 클래스를 상속받아 필요한 상태 정보를 포함하는 커스텀 클래스를 만들어 사용할 수 있고 이를 통해 이벤트 컨슈머가 필요한 정보를 얻을 수 있다.
구현 요령
필요에 따라 커스텀 EventArgs 클래스를 정의하여 이벤트와 함께 전달할 상태 정보를 구체화할 수 있다.
이벤트 구독 및 발행은 += 연산자와 -= 연산자를 사용하여 간단히 할 수 있으며, 이벤트를 발생시킬 때는 ?.Invoke 메서드를 사용하여 안전하게 이벤트 핸들러가 존재하는지 확인한 후 호출한다.
이벤트를 사용하게 되면 이벤트를 전달하는 측과 수신하는 측을 분리할 수 있다.
즉, 이벤트를 전달하는 측의 코드를 개발할 때, 수신 측을 고려할 필요가 없으므로 완전히 독립적으로 개발할 수 있다.
[ Item 17 : 내부 객체를 참조로 반환해서는 안 된다 ]
객체의 캡슐화와 데이터 무결성을 유지하는 데 중요하다.
예를 들어, 클래스 내부에 List<T>와 같은 컬렉션이 있을 때, 이 컬렉션을 외부에 그대로 반환해서는 안된다.
반환된 컬렉션을 통해 외부에서 항목을 추가하거나 제거함으로써, 클래스의 내부 상태가 예상치 못하게 변경될 수 있기 때문이다.
1. 값 타입을 반환하라
값 타입은 클라이언트가 프로퍼티를 통해 내부 객체에 접근할 때 복사본을 넘기기 때문이다.
2. 불변 객체를 반환하라
System.String과 같은 변경 불가능한 타입을 반환하는 한 내부 상태는 안전하다.
3. 읽기 전용 인터페이스를 제공하라
내부 컬렉션에 대한 읽기 전용 인터페이스를 제공하여 외부에서는 조회만 가능하도록 한다.
예를 들어 List<T> 를 IEnumerable<T> 인터페이스의 참조로 노출하는 것도 이러한 전략의 예시이다.
4. 래퍼(wrapper)객체를 만들어서 전달하라
내부 객체로의 접근을 최소화하도록 래퍼 객체를 만들고, 그 래퍼의 인스턴스를 전달하는 방법이다. ReadOnlyCollection<T> 타입은 컬렉션을 래핑하여 읽기 전용 버전으로 노출하는 표준 방법을 제공한다.
[ Item 18 : 이벤트 핸들러보다는 오버라이딩을 사용하라 ]
처음 이벤트 핸들러와 오버라이딩이 서로 비교가 된다는 것 자체가 이해가 되지 않았다.
그러다 이 둘을 비교하는 것 자체가 특정 상황에 국한된 것이라고 생각하니 내용이 눈에 들어왔다.
"이벤트 핸들러보다는 오버라이딩을 사용하라" 이 말이 적용될 수 있는 상황을 살펴보면
목적
- 이벤트 핸들러는 주로 이벤트에 반응하는 로직을 구현할 때 사용된다.
- 오버라이딩은 상속받은 메서드의 동작을 변경하고자 할 때 사용된다.
사용 상황
- 이벤트 핸들러는 사용자의 입력, 시스템 이벤트 등에 반응해야 할 때 주로 사용된다.
- 오버라이딩은 객체 지향 설계에서 다형성을 구현하거나 기존 동작을 수정할 때 사용된다.
이벤트 처리를 위해 오버라이딩을 사용한다는 말은, 특정 이벤트가 발생했을 때 그에 대한 반응으로 특정 메서드의 동작을 오버라이딩하여 사용한다는 의미이다.
이는 특히 WPF 같은 GUI 프로그래밍에서 자주 볼 수 있는 패턴이다.
예를 들어, WPF 버튼 클릭 이벤트를 처리하고 싶은 상황이다. 버튼에 클릭 리스너를 설정하고, 그 안에서 OnMouseDown 메서드를 오버라이딩하여 버튼이 클릭되었을 때 원하는 동작을 정의할 수 있다.
protected override void OnMouseDown(MouseButtonEventArgs e)
{
DoMouseThings(e);
base.OnMouseDown(e);
{
이벤트 핸들러의 단점
- 이벤트 발생 시마다 핸들러를 찾아 호출하는 과정에서 추가적인 오버헤드가 발생할 수 있다.
- 이벤트 핸들러를 잘못 작성해 예외를 던진 경우 이벤트 체인에 있는 다른 핸들러가 호출되지 않는다.
- 이벤트 핸들러의 관리와 디버깅이 복잡해질 수 있다.
이를 해결하기 위해 오버라이딩을 사용할 수 있다. 보통 상속 가능한 클래스에서는 이벤트를 처리하는 메서드를 오버라이드하여 사용한다.
이 방법은 컴파일 타임에 결정되므로, 런타임에 발생하는 오버헤드를 줄일 수 있다.
즉, 성능과 코드 복잡성에 민감한 상황에서는 오버라이딩을 고려해야 한다는 것이다.
'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. 요약 (2) (0) | 2024.03.24 |
[More Effective C#] Chapter 1. 요약 (0) | 2024.03.10 |
[Effective C#] C# 언어 요소 (3) | 2023.02.02 |