C#/Advanced C#

[More Effective C#] 1장 데이터 타입

로파이 2021. 12. 4. 17:26

아이템 1. 접근 가능한 데이터 멤버 대신 속성을 사용하라

 

속성(Property)는 멤버 변수를 접근하는 방법을 제공하며 메서드로 구현된 요소이다.

get; set;에 접근 지정자를 지정할 수 있고 OOP의 은닉성을 유지할 수 있다.

속성을 통해 해당 변수의 접근을 통제하고 읽고 쓰는 방식을 한 곳에 관리할 수 있다.

public class Card
{
	public Name { get; set; }
}

C#의 암묵적 속성을 사용하면 컴파일러는 해당 클래스의 private 필드에 name이라는 멤버 변수를 정의해준다. get; set; 키워드는 해당 멤버 변수를 접근해줄 수 있는 메서드를 각각 생성하여 속성을 통해 쉽게 사용할 수 있도록 한다.

 

속성(혹은 인덱서)는 ref나 out 매개변수가 있는 메서드에 인자로 사용할 수 없으므로 내부 데이터가 외부로 노출되는 일을 막는다.

 

인덱서는 컬렉션과 같이 여러 요소를 포함한 클래스에 요소를 접근하는 방법을 제공한다. 속성과 마찬가지로 인덱서 안에서 유효성을 검증하거나 계산을 수행하는 코드를 둘 수 있고 virtual이나 abstract로 선언하는 것도 가능하다.

public class Container
{
  public int this[int index]
  {
      get => theValues[index];
      set => theValues[index] = value;
  }

  private int[] theValues = new int[100];
}


int v = Container[0]; // 인덱서 사용

 

C#은 속성에 대해 중간 언어(MSIL) 수준의 코드를 만들어낸다. public 멤버를 사용하는 것보다 public 속성을 사용한다고 해서 많이 느려지는 것도 아니다. JIT 컴파일러가 속성 접근자를 메서드 인라인화하기 때문이다.

 

아이템 2. 변경 가능한 데이터에는 암묵적 속성을 사용하는 것이 낫다.

 

암묵적 속성 Implicit Property

public string Tag { get; set; }

암묵적 속성을 사용하면 컴파일러는 알아서 뒷단에 필드를 추가하고 접근하는 메서드를 생성한다.

암묵적 속성을 사용하는 이유는 간결하고 private/protected 접근자를 통해 수정 방식을 설정하여 의도를 바로 알 수 있다.

암묵적 속성은 virtual, abstract 혹은 인터페이스로 선언할 수 있다. 상속을 한 경우 파생 클래스에서 재정의한 속성에서 base.xxx으로 자식 속성을 접근할 수 있다.

 

아이템 3. 값 타입은 변경 불가능한 것이 낫다.

 

변경 불가능한 타입 Immutable Type이란 한번 생성된 이후 그 값을 변경할 수 없는 타입을 말한다. 변경 불가능한 타입은 스레드 안전한 특징이 있다.

 

public sturct Address
{
    public string State { get; set; }
    public string City { get; set; }
    public string Street { get; set; }
    public string ZipCode { get; set; }
	
    public bool IsValid()
    {
    	return IsValid(State, City, Street, ZipCode);
    }
}

Address라는 주소를 저장하는 값 타입 형식이 있다. 해당 타입은 State, City, Street, Zipcode를 속성으로 갖고 있으며 State,City,Street에 설정된 값을 보고 ZipCode가 유효한지 판별하는 메서드가 있다.

 

Address의 속성들은 set 접근자로 그 값을 변경할 수 있다. 따라서 이미 생성한 주소 정보를 다음과 같이 변경가능하므로 mutable 타입이다.

Address myAddress = new Address() { State = "GyoengGiDo", City = "Suwon", Street = "ParkjiSung Ro" };

// 나의 주소지를 변경해야한다.
myAddress.Street = "Suseong Dae ro"
myAddress.ZipCode = 48291;

나의 주소지 값이 멀티 스레드가 접근가능하다면 나의 주소지가 원자적으로 변경이 불가능하기 때문에 (여러 속성을 수정하므로) 도로명 주소가 변경되었지만 우편번호가 아직 변경되지 않은 상태가 존재한다.

따라서 그런 상태에서 IsValid()를 호출한다면 유효하지 않은 결과를 내뱉을 것이다.

 

완전한 변경 불가 타입을 만들때 주의 사항

배열과 같이 기본적으로 참조를 사용하는 객체를 내부에 둘 때 외부에서 참조 가능성이 생긴다면 해당 타입은 변경 불가능하지 않다.

public struct ReadOnlyBlock
{
    private byte[] mem;
    
    public ReadOnlyBlock(byte[] shared)
    {
    	mem = shared; // 외부 참조를 허용한다.
    }
}

따라서 Immutable을 지원하는 타입을 활용해서 내부에 멤버로 두게 만든다. Collections.Immutable 네임스페이스에는 관련된 ImmutableArray 클래스를 사용할 수 있다.

public class ReadOnlyBlock
{
	private readonly ImmutableArray<byte> mem;

	public ReadOnlyBlock(byte[] mem)
	{
		this.mem = mem.ToImmutableArray();
	}

	public IEnumerable<byte> Data => mem;
}

 

흔히 Immutable 타입 객체를 만드는 방법은 예로 다음과 같다.

1. 매개변수가 있는 생성자를 이용한다.

2. 팩터리 메서드를 이용한다.

3. 빌더와 같이 내용을 중간에 수정하여 최종적으로 실제 완전한 객체를 생성하는 방법을 사용한다.

 

 

아이템 4. 값 타입과 참조 타입을 구분하라

값 타입은 위와 같이 변경 불가능한 데이터 형식에서 활용되고 참조 타입은 다형성이 필요한 계층적 클래스에서 사용된다.

MyData data = new MyData();

var value = data.Raw; // 값? 참조?

클래스의 속성의 경우 대부분 값 타입을 사용하는 것이 좋다. 참조의 경우 외부에서 내부 값을 원치않게 수정할 수 있는 여지를 주기 때문이다. 반면, 값 타입은 항상 복사를 하기 때문에 내부 값을 수정하려면 명시적으로 set 속성을 이용해야한다.

 

값 타입을 사용하는 경우

public class MyType
{
	private ValueType v1 = new ValueType();
	private ValueType v2 = new ValueType();
}

void call_for_value()
{
	ValueType[] arr = new ValueType[100];
}

MyType 클래스 내부에는 값 타입의 형식을 멤버로 둔다. MyType 하나를 인스턴스화 하면 MyType 자체의 크기 (ValueType의 두개)만큼 동적 메모리 할당이 한번에 이루어진다. 

값 타입을 배열로 만들경우 스택 메모리에 ValueType의 100개 짜리 메모리가 한번에 할당된다.

 

참조를 사용하는 경우

public class MyType
{
	private RefType v1 = new RefType();
	private RefType v2 = new RefType();
}

void call_for_reference()
{
    RefType[] arr = new RefType[100];
    for(int i = 0; i < 100; ++i)
    	arr[i] = new RefType();
}

MyType을 인스턴스화 하면 RefType이라는 참조 타입을 위해 각기 다른 두 번의 동적 할당과 두 참조 변수를 두는 16바이트 짜리 MyType을 위한 동적 메모리 할당이 이루어진다. 

배열을 사용하면 배열을 위한 8*100바이트 메모리(배열도 참조이므로 동적 할당이 이루어진다.)와 각 요소를 초기화하는 동적 할당 100번으로 총 101번의 메모리 할당이 이루어진다. 각 동적 메모리 할당은 연속된 공간을 사용하지 않을 수도 있기 때문에 메모리 단편화 가능성을 야기한다.

 

값 타입의 경우 값 복사의 비용을 안고 있기 때문에 크기가 적은 구조체나 데이터 저장 목적을 위해 사용한다. 참조의 경우 동적 할당 비용을 감안했을 때 한번 생성하고 자주 참조 형태로 사용하게 될 다형성 클래스를 위해 많이 사용한다. 값 타입의 경우 참조 타입으로 지칭할 수 있는데 이때 박싱과 언박싱이 일어나므로 주의하도록 한다.

public static void Main(string[] args)
{
    int a = 10;

    object a_box = a; // 박싱 (내부 힙 데이터 할당과 값을 복사)

    int a_unbox = (int)a_box; // 언 박싱 

    a_unbox = 20; // a와 다른 객체

    Console.WriteLine($"{a}, {a_unbox}");
}

 

아이템 5. 값 타입에서는 0이 유효한 상태가 되도록 설계하라

 

값 타입의 변수를 선언시 초기화하지 않으면 기본값 0을 가진다. 또한 값 타입을 가지는 필드는 해당 클래스가 인스턴스화될 때 0으로 초기화 된다.

public enum NetState
{
    Start = 1,
    Ready = 2,
    Stop = 3,
    Error = 4
}

enum 클래스의 경우 값 타입을 가지는 데 해당 enum을 사용하는 변수가 기본값으로 초기화된다면 0은 유효한 상태가 아니게 된다.

public string Name
{
    get
    {
    	return Name ?? String.Empty;
    }
}

string의 경우 기본값은 null이며 string 속성을 이용하는 경우 null 일 경우 String.Empty를 반환하는 것이 좋다.

 

아이템 6. 속성을 데이터처럼 동작하게 만들라

 

외부에서 속성을 사용하는 것은 마치 해당 필드를 직접 사용하는 것과 똑같이 보이기 때문에 사용자는 해당 속성이 필드를 접근하는 것과 동일하게 작동하는 것을 기대한다.

 

for(int i = 0; i < data.Length; ++i)

Length라는 속성을 for문에서 접근할 때 매 루프마다 속성을 접근하게 되는데 접근할 때마다 값을 얻어오는 호출이 값 싼 것이라 생각한다. 실제로 대부분 인라인화 되어 단순 필드를 확인하는 정도로 그칠 것이다.

 

이러한 속성의 접근과 수정이 비싸거나 예외를 발생시킬 수 있다면 속성대신 메서드를 사용하는 것이 좋다. 속성 자체에 기대하는 방식이 다르기 때문이다.

public Amount TotalSpentAmount
{
    get
    {
    	var result = LoadFromDatabase();	
    	return result.Sum();
    }
}

위 TotalSpendAmount 속성을 접근할 때마다 DB에 접근하게되고 최종 값을 얻어올 때까지 많은 시간이 걸릴 것이다. 클래스 내에서 캐시를 두고 get을 할 때마다 캐시를 확인해서 반환하거나 다시 계산하게 할 수 도 있다.

 

그 값이 만약 사용될 수도 있고 아닐 수도 있는 드문 접근이 요구된다면 Lazy<T>를 이용하는 것도 방법이다.

public class RarelyRequestedEnvironment
{
    private Lazy<RareEnviromentMetaInfos> info = new Lazy<...>(() => LoadFromMetaFile());
	
    public RareEnvironmentMetaInfos Info => info.Value;
}

 

아이템 7. 튜플을 사용해서 타입의 사용 범위를 제한하라

 

사용자 타입으로 구조체, 클래스, 튜플, 익명 타입을 사용할 수 있다. 그 중 구조체를 대신해서 변경불가능한 새로운 타입을 쉽게 만들어주는 튜플과 익명 타입을 사용하면 좋다.

 

- 튜플

System.Tuple<T1, T2, ...> 의 타입으로 정의되어 참조 타입을 가진다.

public class Tuple<T1, T2> : IStructuralComparable, IStructuralEquatable, IComparable, ITuple
{
	public Tuple(T1 item1, T2 item2);

	public T1 Item1 { get; }
	public T2 Item2 { get; }

	public override bool Equals(object? obj);
	public override int GetHashCode();
	public override string ToString();
}

특징

반드시 매개변수를 통해 튜플을 만들고 get만 가지는 속성을 통해 변경 불가를 보장한다. 

보통 Tuple.Create라는 생성자 메서드를 사용하여 튜플을 만든다.

 

vs System.ValueTuple 튜플 : 값 타입의 튜플

public struct ValueTuple : IStructuralComparable, IStructuralEquatable, IComparable, IComparable<ValueTuple>, IEquatable<ValueTuple>, ITuple
{
    public static ValueTuple Create();
    public static ValueTuple<T1> Create<T1>(T1 item1);
    public static (T1, T2) Create<T1, T2>(T1 item1, T2 item2);
    public static (T1, T2, T3) Create<T1, T2, T3>(T1 item1, T2 item2, T3 item3);
    public static (T1, T2, T3, T4) Create<T1, T2, T3, T4>(T1 item1, T2 item2, T3 item3, T4 item4);
    public static (T1, T2, T3, T4, T5) Create<T1, T2, T3, T4, T5>(T1 item1, T2 item2, T3 item3, T4 item4, T5 item5);
    public static (T1, T2, T3, T4, T5, T6) Create<T1, T2, T3, T4, T5, T6>(T1 item1, T2 item2, T3 item3, T4 item4, T5 item5, T6 item6);
    public static (T1, T2, T3, T4, T5, T6, T7) Create<T1, T2, T3, T4, T5, T6, T7>(T1 item1, T2 item2, T3 item3, T4 item4, T5 item5, T6 item6, T7 item7);
    public static (T1, T2, T3, T4, T5, T6, T7, T8) Create<T1, T2, T3, T4, T5, T6, T7, T8>(T1 item1, T2 item2, T3 item3, T4 item4, T5 item5, T6 item6, T7 item7, T8 item8);
    public int CompareTo(ValueTuple other);
    public override bool Equals(object? obj);
    public bool Equals(ValueTuple other);
    public override int GetHashCode();
    public override string ToString();
}

 

해당 구조체의 정의를 보면 반환 타입이 한 개인 경우를 제외하고 ( , ) 괄호 형태를 취하고 있는 것을 볼 수 있다.

Tuple 클래스는 Item1등이 속성으로 정의되어있지만 ValueTuple의 경우 필드를 가리킨다.

값 타입이고 필드를 사용하기 때문에 직접 튜플 내용을 수정할 수 있다.

Tuple<int, int> t = Tuple.Create(5,5);
t.Item1 = 10; // Error : 튜플 값을 변경할 수 없다.

var t2 = (5, 5); // 해당 타입은 값 타입의 튜플이다.
t2.Item1 = 10; // 값 타입의 튜플은 해당 값을 변경할 수 있다.

ValueTuple의 경우 필드 이름을 지정하여 사용할 수 있다.

var p = ( XCoord : 5, YCoord : 5);

(int XCoord, int YCoord) p2 = p;

 

- 익명 타입

var p = new { X = 5, Y = 5 };

중괄호를 이용하여 익명의 타입을 생성하여 해당 타입을 사용하는 인스턴스를 만들 수 있다.

익명 타입은 Tuple 클래스와 같이 변경 불가능한 참조 타입을 제공한다.

속성의 각 이름을 지정할 수 있다.

 

컴파일러는 위 코드를 다음과 같이 작성해준다.

internal sealed class a
{
    private readonly int x;
    public int X { get => x; }
    
    private readonly int y;
    public int Y { get => y; }
    
    public a(int X, int Y)
    {
    	this.X = X;
        this.Y = Y;
    }
}

익명 타입은 람다 함수나 델리게이트를 사용할때 익명 타입을 제너릭 인자로 받아 사용하는 경우가 많다.

 public static IEnumerable<TOut> Transform<TSrc, TOut>(IEnumerable<TSrc> collection, Func<TSrc, TOut> transform)
 {
     foreach(var iter in collection)
     {
		yield return transform(iter);
     }
 }
public static void Main(string[] args)
{
    List<int> list = new List<int>() { 1, 2, 3, 4, 5 };

    var cvList = Transform(list, (e) => new { X = e*e, Y = e*e*e });

    foreach(var v in cvList)
    {
        Console.WriteLine($"{v.X}, {v.Y}");
    }
}

 

아이템 8. 익명 타입은 함수를 벗어나지 않게 사용하라

 

메서드의 반환으로 튜플을 사용하는 것은 쉽다. 다음과 같이 ValueTuple 혹은 Tuple은 System 네임스페이스에 정의되어 있기 때문이다.

public (bool Success, int Sum) Calculate()
{
	return (true, 10);
}

var result = Calculate();
Console.WriteLine($"Result [{result.Sucess}]: {result.Sum}");

 

익명 타입의 경우 반환 타입으로 사용하기가 쉽지 않기 때문에 대부분의 경우 메서드의 제너릭 타입으로 쓰여질 경우에만 사용된다.

 

쿼리식에 자주 사용되며 간편하게 익명 타입을 생성해서 해당 범위에서 쉽게 사용하려고 사용한다. Linq 메서드는 제너릭 타입에 동작하는 메서드가 대부분이므로 익명 타입을 쉽게 사용할 수 있다.

public static void Main(string[] args)
{
  var sequence = (from x in MyGenerator.Generate(100, () => rndNumbers.Next() * 100)
  				let y = rndNumbers.Next() * 100
 				select new { x, y }).TakeWhile(point => point.x < 75);

  var scaled = from e in sequence
  			select new { x = p.x * 2, y = p.y * 2 };
}

익명 타입은 private 중첩 클래스로 새로운 타입을 만들어내는 것과 같아서 제한된 범위에서 사용가능하다.

 

모든 익명 타입은 Object.Equals를 재정의해서 사용하는 모든 필드의 값을 비교하도록 하기 때문에 참조 형식이지만 값 타입의 의미 비교를 제공한다. (필드의 순서와 이름이 같아 같은 익명 타입으로 인식된 경우에 한해서)

public static void Main(string[] args)
{
  var p1 = new { x = 10, y = 10 };
  var p2 = new { x = 10, y = 10 };

  Console.WriteLine($"{p1.Equals(p2)}"); // true
}

 

아이템 9. 다양한 동일성 개념들 사이의 상관관계를 이해하라

 

사용자 클래스에 대해 Equal 개념을 적용해서 두 인스턴스가 같은 의미를 가지는 지 판별하는 것을 제공하는 것을 고려한다. Object.Equals() 메서드와 int GetHashCode()를 재정의해서 사용하도록 하고 인터페이스 IEquatable를 구현하여 사용하도록 한다.

 

기본 값 타입의 사용자 구조체는 ValueType.Equals 메서드를 이용해 필드 별 비교를 수행한다. ValueType.Equals 메서드를 호출하게되면 실제 어떤 타입이 해당 메서드를 호출한 것인지 모르기 때문에 리플렉션을 이용하여 타입 정보를 사용하게 되고 성능이 좋지 않다. 따라서 값 타입을 만들 때는 항상 ValueType.Equals 메서드를 재정의하는 것이 좋다.

 

참조 타입의 Object.Equals 재정의 (GetHashCode()도 같이 재정의해야한다.)

public class MyType
{
    private int idx;
    private int category

    public override bool Equals(object other)
    {
    	if (Object.ReferenceEquals(other, null)
        	return false;
    	
        if (Object.ReferenceEquals(this, other)
        	return true;
            
        if (this.GetType() != other.GetType())
        	return false;
        
        return idx == other.idx && category == other.category;
    }
    
    public override int GetHashCode()
    {
    	return idx.GetHashCode() ^ category.GetHashCode();
    }
}
  • Object.ReferenceEquals 메서드는 두 참조가 같은지 판별한다. 먼저 해당 참조가 null 인지 판별하고 그 다음 같은 참조를 비교하는 지 판별한다.
  • Equals는 같은 클래스 인스턴스에 대해서만 올바르게 동작하도록 해야한다. MyType의 파생 클래스도 MyType이라고 볼 수 있지만 Equals는 같은 클래스 타입의 인스턴스에만 적용하여 각 클래스 별로 항상 재정의해서 사용하는 것을 원칙으로한다.
  • 마지막으로 사용자가 정의할 동등한 의미의 판별 기준에 따라 코드를 작성한다.
  • 참조의 경우 주의할 점은 절대 표준 연산자 ==와 !=에 대해 재정의하지 않는 것이다. 일반적으로 참조타입에 대해 표준 연산자 ==와 != 비교는 의미론적 비교가 아닌 참조 비교를 기대할 것이기 때문이다.

 

더 자세한 내용은 IEquatable 인터페이스와 함께 알 수 있다. https://narakit.tistory.com/21

 

아이템 10. GetHashCode()의 위험성을 이해하라

 

GetHashCode()는 C++에서 포인터의 주소를 map과 같은 컨테이너에서 키로써 해쉬 값을 사용하던 것을 비슷하게, C#에서는 가비지 컬렉터와 CLR에 의해 메모리 주소가 바뀔 수 있으므로 Object의 GetHashCode()를 사용해서 HashSet<T>와 Dictionary<K, V>의 키로써 해쉬값을 구할 때 사용한다.

 

GetHashCode()는 서로 같은 의미를 지니는 Equals에 연장선상에서 같은 의미를 지니는 두 참조 타입 인스턴스는 같은 해쉬값을 가져야한다.

 

프로그램 동작 중에 해쉬의 키 값이 바뀌면 매우 이상한 일이므로 외부에서 접근 가능한 참조 타입을 키로 사용하는 것을 조심해야하며 차라리 변경 불가능한 값 타입을 해쉬 키값으로 사용하는 것이 낫다.

 

GetHashCode()는 int를 반환하는데 이 값의 분포는 고르게 형성되는 것이 좋다. 특정 클래스의 해쉬 값을 고르는 데 있어서 이진 값이나 적은 수의 상태를 가지는 변수로 해쉬값을 만든다면 특정 범위의 값에 치중된 분포를 형성한다. 이는 해쉬를 키로 사용하는 컬렉션에서 해당 키에 맵핑되는 버킷에 대응되는 값을 저장하는데, 키 값이 치중되면 특정 버킷에 값이 많아지고 이는 검색 등의 컬렉션 동작 효율을 떨어뜨린다.

 

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