[ Item 1 : 접근 가능한 데이터 멤버 대신 속성을 사용하라 ]
- public 필드 대신 프로퍼티를 사용하도록 권장한다.
실제로 .NET 프레임워크의 데이터 바인딩 클래스들은 public 필드 대신 프로퍼티에 대해서만 동작한다. 예를 들어 WPF의 INotifyPropertyChanged, 윈폼 등에 포함된 모든 데이터 바인딩 라이브러리가 그렇다.
Java에서는 getter, setter 메서드를 만드는 것 처럼(lombok을 쓰지만..) C# 에서는 { get; set; } 키워드로 간편히 설정할 수 있다는 게 비슷한 점이라고 생각된다. OOP 에서의 캡슐화에 큰 도움이 된다. 프로퍼티는 메서드로 구현되기에 멀티스레딩 환경도 쉽게 지원할 수 있고, Virtual 으로도 설정해줄 수 있다.
멀티스레딩 환경에서의 프로퍼티 설정 예시
public class Customer
{
private object _syncHandle = new object();
private string _name; // 필드(데이터 멤버)
public string Name // 프로퍼티()
{
get
{
lock (_syncHandle)
{
return _name;
}
}
set
{
if (string.IsNullOrEmpty(value))
{
throw new ArgumentNullException($"Name cannot be blank {nameof(Name)}");
}
lock (_syncHandle)
{
_name = value;
}
}
}
}
- 프로퍼티의 get과 set 각각에 대해 서로 다른 접근 한정자를 지정할 수 있다.
- public 필드를 사용하다가 나중에 필요해지면 프로퍼티로 수정할 것이다? -> 매우 지양하는 방식이다. 필드와 프로퍼티는 소스 코드 수준에서는 호환성이 있다. 하지만 컴파일 시 서로 다른 중간언어(IL)를 생성하기 때문에 바이너리 수준에서는 전혀 호환성이 없다. 따라서 앞서 배포한 어셈블리가 있는 경우 업데이트하기가 까다로워진다.
- public 이나 protected 로 데이터를 노출할 때는 항상 프로퍼티를 사용하라.
- 시퀀스, 딕셔너리를 노출시킬때는 인덱서를 활용하라.
- 모든 필드는 private 이다. 예외는 없다.
- 성능 문제를 지적할 수도 있겠지만 JIT 컴파일러가 프로퍼티 메서드를 인라인화 하여 크게 문제되지 않는다. 다만 프로퍼티 접근자 내에서는 시간이 오래 걸리는 작업을 해서는 안된다.
[ Item 2 : 변경 가능한 데이터에는 암묵적 속성을 사용하는 것이 낫다 ]
프로퍼티를 사용해 데이터를 추가하는 경우, 단순히 데이터 필드를 감싸는 래퍼 역할만을 수행하는 경우가 많다.
이럴 때 암묵적 속성(implicit property)을 이용하면 코드를 읽기 쉽다.
public string Name { get; set; }
별도의 필드 연결 없이 프로퍼티만 선언된 것을 암묵적 속성(프로퍼티)이라 부른다.
컴파일러는 프로퍼티의 값을 저장한 필드를 임의의 이름을 부여해 생성한다. → 개발자가 직접 수정하지 못하고 프로퍼티를 통해서만 접근 가능하다.
암묵적 속성의 장점
- 향후 데이터 검증 등을 위해서 암묵적 속성을 명시적 속성으로 구현부를 추가해도 클래스의 바이너리 호환성이 유지된다.
- 여전히 프로퍼티를 사용하고 있으므로 데이터 검증 코드를 한 군데만 두면 된다.
암묵적 속성의 단점
- Serializable 특성을 사용한 타입에는 적용될 수 없다. 클래스를 바이너리화 하는 것을 직렬화 라고 하는데, 암묵적 속성은 컴파일러가 필드를 임의로 만들기 때문에 필드명이 바뀔 수 있다. 따라서 직렬화와는 어울리지 않는다.
[ Item 3 : 값 타입은 변경 불가능한 것이 낫다 ]
변경 불가능한 타입(Immutable type)
- 멀티스레드 환경에서 안전하다
- 호출자 측에 객체가 노출되더라도 안전하다.
- 불필요한 오류 확인에 들이는 노력이 줄어든다.
- 해시값이 항상 같다.
불변 타입을 초기화하는 세가지 전략
- 적절한 생성자를 정의한다. 가장 단순하고 확실한 전략이다.
- 초기화 팩토리 메서드를 생성한다.
- 불변 타입을 한 번에 초기화하기 어렵하면 동반(companion)클래스를 만들어 사용할 수 있다. .NET에서 StringBuilder 라고 부르는 동반 클래스를 사용해 빌더 패턴과 비슷한 방식으로 불변 문자열을 얻을 수 있다.
주의사항
- 별다른 생각없이 프로퍼티에 get, set 접근자를 만들지 말자.
[ Item 4 : 값 타입과 참조 타입을 구분하라 ]
- 값 타입은 다형성이 없으므로 일반적으로 애플리케이션이 사용하는 데이터를 저장하는 데 적합하다.
- 참조 타입은 다형성을 지니므로 애플리케이션의 동작을 정의할 때 사용해야 한다.
구조체(struct)는 데이터를 저장하고 클래스(class)는 동작을 정의한다.
.NET과 C#이 값 타입과 참조 타입을 구분한 이유는 C++과 Java에서 자주 발생하는 문제를 해결하기 위함이다.
- C++ 은 모든 매개변수와 반환값이 값으로 전달되기 때문에 부분 복사와 관련된 문제가 많이 발생한다.
- Java 는 모든 타입이 참조이므로 모든 객체를 힙에 할당하고 GC 비용을 감내해야 한다. 그리고 객체의 멤버에 접근할 때마다 this를 역참조하기 때문에 시간도 오래걸린다.
메모리 할당과 관련된 값-참조 타입의 차이
public class C
{
private MyType a = new MyType();
private MyType b = new MyType();
}
C cThing = new C();
MyType 이 구조체(값 타입)라면 한 번의 메모리 할당만 일어나며 할당 크기는 MyType 크기의 두 배이다.
하지만 MyType 이 참조 타입이면 메모리 할당이 세 번 일어난다.
- C 타입 객체를 저장하기 위해 8바이트 할당(64비트 컴퓨터 기준 포인터 크기)
- C 객체에 포함되는 MyType 객체를 저장하기 위해 2번의 메모리 할당
var arrayOfTypes = new MyType[100];
MyType이 값 타입이면 메모리 할당이 한 번만 발생한다.
참조 타입이면 101번 발생한다.
- 메모리 할당 기준은 값 타입 vs 참조 타입을 결정할 때 가장 낮은 우선순위로 고려된다.
- 가장 높은 우선순위는 타입의 용도이다. 데이터를 저장할 지, 동작을 정의할 지
- low level 데이터 저장용 타입은 값 타입으로 만든다. - 객체가 외부로 내보내려는 데이터를 안전하게 복사해줄 수 있고, 스택을 활용하여 메모리를 효율적으로 사용할 수 있다.
- 동작을 정의할 때는 참조 타입으로 만든다. - OOP 기술을 활용해서 동작을 쉽게 구현할 수 있다.
[ Item 5 : 값 타입에서는 0이 유효한 상태가 되도록 설계하라 ]
- 열거형(Enum)은 반드시 0을 유효한 값으로 선언해야 한다.
- 일반적으로 기본값을 0을 설정하는 것이 좋다.
- 만약 기본값을 지정하기 모호하다면 0을 None 과 같이 초기화되지 않음 이라는 의미로 사용하고 나중에 원하는 값으로 수정하도록 유도한다.
- Flag 를 사용하는 열거형은 0을 어떤 플래그로 설정하지 않았음을 의미하도록 한다. 비트 연산을 사용할 목적으로 Flag 를 지정했기 때문이다.
[Flags]
public enum Styles
{
None = 0,
Flat = 1,
Sunken = 2,
Raised = 4
}
[ Item 6 : 속성을 데이터처럼 동작하게 만들라 ]
프로퍼티는 외부에서는 수동적인 데이터 필드처럼 보인다.
따라서 get, set 접근자에 기능을 추가할 때 필드에 접근하는 듯한 느낌이 들도록 적절한 기능만 추가해야 한다.
- 유효성 검증 기능
- 캐싱 → Lazy<T> 사용 캐싱 방법은 멀티 스레드 환경에서 주의깊게 사용해야 한다.
- get, set 접근자를 통해 프로퍼티를 사용할 때 API 호출과 같은 작업은 적절하지 않다.
- 따라서 프로퍼티는 객체의 상태를 보여주는 용도로만 사용하자.
[ Item 7 : 튜플을 사용해서 타입의 사용 범위를 제한하라 ]
익명 타입이나 튜플과 같이 상대적으로 간단한 타입을 사용하면 가독성에 도움이 될 수 있다.
var aPoint = new { X = 5, Y = 67 }
이 코드는 익명 타입(변경이 불가능한 참조 타입)을 생성하는 코드이다.
익명 타입을 생성하면 컴파일러는 X, Y 프로퍼티를 가지는 sealed 클래스를 생성해준다.
컴파일러를 통해 생성되므로 실수가 줄어들고 직접 관리해야 할 코드가 줄어든다.
하지만 익명 타입을 사용하면 타입의 이름을 모르기 때문에 매개변수로 전달할 수 없고, 리턴값으로도 사용할 수 없다.
생성된 익명 타입을 제어하는 방법은 다음과 같다.
- 람다 표현식이나 익명 델리게이트를 메서드 내에 정의하고, 각 코드에서 익명 타입의 객체를 처리하도록 코드를 작성한다.
- 함수를 매개변수로 취하는 제네릭 메서드를 작성 후, 익명 메서드와 익명 타입의 객체를 함께 이용한다.
static T Transform<T>(T element, Func<T, T> transformFunc)
{
return transformFunc(element);
}
var aPoint = new { X = 5, Y = 67 }
var anotherPoint = Transform(aPoint, (p) => new { X = p.X * 2, Y = p.Y * 2 });
위 방법은 제네릭 메서드를 통해 익명 타입 객체를 제어하는 방법이다.
이를 통해 여러 알고리즘을 구현할 수 있도록 확장성있게 코드를 작성할 수 있을 것이고,
덕분에 익명 타입은 중간 결과를 저장하기에 적합하다.
var aPoint = (X: 5, Y: 67)
위 코드는 튜플 인스턴스를 생성하는 코드이다.
튜플은 변경 가능한 값 타입이다.
- 중간 결과를 저장해야 하고, 변경 불가능한 타입과 잘 맞다면 익명 타입을 사용한다.
- 중간 결과가 독립적으로 변경 가능해야 한다면 튜플을 사용한다.
[ Item 8 : 익명 타입은 함수를 벗어나지 않게 사용하라 ]
함수를 단순하게 정의해두면 여러 용도로 활용할 수 있다.
익명 타입을 다루는 메서드를 작성할 때는 고차함수로 작성해야 한다.
고차함수 : 함수를 매개변수로 취하거나 함수를 반환하는 함수
특히 매개변수로 함수를 취하는 고차함수는 익명 타입을 다룰 때 유용하다.
고차함수와 제네릭은 다양한 경우에 익명 메서드와 함께 사용되곤 한다.
Java 에서 Stream 과 비슷한 역할을 하는 LINQ 메서드가 대부분 제네릭에 대해 동작하므로 익명 타입을 쉽게 사용할 수 있다.
- 동일한 타입을 반복적으로 사용하고 있다면, 그 타입은 익명 타입이 아니라 구체적인 타입으로 변경해야 할 가능성이 크다.
- 코드가 길고 복잡한 람다식을 사용하고, 그 이유가 익명 타입을 사용하기 위해서라면 구체적인 타입으로 바꿔야할 가능성이 크다.
[ Item 9 : 다양한 동일성 개념들 사이의 상관관계를 이해하라 ]
C#에서 서로 다른 객체의 동일성을 확인하는 함수는 4개나 있다.
- public static bool ReferenceEquals(object left, object right) 두 참조 타입 객체가 동일한 객체 ID를 가지고 있는 것을 뜻한다. 자기 자신과 비교를 하더라고 false를 반환하는데, 이는 박싱 메커니즘 때문이다.
- public static bool Equals(object left, object right) 런타임 타입을 알 수 없는 두 객체를 비교하기 위해 만들어 졌다.
- public virtual bool Equals(object right)
- public static bool operator ==(MyClass left, MyClass right)
- 1, 2 번 정적 메서드는 건드려서는 안된다.
- 3번 메서드는 타입이 가지는 값의 의미에 부합하도록 재정의 해야한다.
- 4번 연산자(==)는 주로 성능 개선을 위해서 재정의한다.
- 3번 Equals() 메서드 재정의하기
기본 동작 방식 : 객체 ID를 비교하는 ReferenceEquals(object left, object right) 메서드와 동일하다.
값 타입의 경우 ValueType의 Equals() 메서드가 재정의된다.
(struct의 베이스 클래스가 ValueType 이다.)
- 값 타입의 경우 : 성능에 영향을 끼치는 박싱과 언박싱을 최소화하여 재정의한다.
- 참조 타입의 경우 : Equals() 메서드의 기본 동작 방식과 동일하게 메서드를 구현하는 것이 중요하다.
- Equals() 메서드를 재정의 했다면, IEquatable<T> 도 함께 구현하는 것이 좋다.
public class Foo : IEquatable<Foo>
{
public override bool Equals(object right)
{
// null인지 확인
// C# 메서드 내에서 this는 절대 null이 될 수 없다.
if (object.ReferenceEquals(right, null))
return false;
if (object.ReferenceEquals(this, right))
return false;
if (this.GetType() != right.GetType())
return false;
// 이 타입의 내용을 비교한다.
return this.Equals(right as Foo);
}
// IEqutable<Foo>에 정의된 멤버
public bool Equals(Foo other)
{
// 생략
return true;
}
}
IEquatable<T> 를 구현한다는 것은 해당 타입이 타입 안정적인 방식으로 동일성을 비교할 수 있다는 것을 알려주는 것이기도 하다.
이 인터페이스를 구현하면 매개변수의 타입이 원본 객체의 타입과 다르게 주어질 다양한 가능성을 컴파일러의 힘을 빌려 차단하는 효과가 있다.
[ Item 10 : GetHashCode()의 위험성을 이해해라 ]
GetHashCode() 메서드는 해시 기반 컬렉션에서 키의 해시값을 정의할 때 사용된다.
구체적으로 HashSet<T>와 Dictionary<K, V> 와 같은 컨테이너 들이다.
GetHashCode()를 사용하기 위한 규칙.
- Equals()에 의해 2개의 객체가 같으면 두 객체는 동일한 해시값을 반환해야 한다.
- GetHashCode()의 반환값이 같은 인스턴스에 대해서 불변이어야 한다. 임의의 객체 내부 프로퍼티의 값이 바뀌는 경우 GetHashCode()가 해당 프로퍼티에 의존적이라면 이전 해시는 무쓸모해진다. 이를 해결하기 위해 불변 프로퍼티나 필드에 대해서 해시 코드를 생성하도록 메서드를 정의한다.
- GetHashCode()가 모든 입력 정수값에 대해 균일하게 분포된 값을 생성해야 한다. 일반적으로 사용되는 알고리즘은 해당 타입이 가지는 모든 필드로부터 해시 코드를 가져와서 XOR 연산을 수행하는 것이다. 이때 변경 가능한 필드는 이 계산에서 제외한다.
앞으로 작성할 대부분의 타입에 대해 최고의 접근법은 GetHashCode()를 절대로 사용하지 않는 것이다. 만약 해시 키로 사용해야 할 타입을 작성할 때가 올 수 있기 때문에 위 규칙을 알고 있으면 좋다.
'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 2. 요약 (1) (4) | 2024.03.17 |
[Effective C#] C# 언어 요소 (3) | 2023.02.02 |