Item 1 : 지역변수를 선언할 때는 var를 사용하는 것이 낫다
코드를 읽을 때 타입을 명시적으로 드러내야 하는 경우가 아니라면 var를 사용하는 것이 좋다.
var를 사용하면 변수의 타입과 같이 지엽적인 부분보다 변수의 의미 파악에 더 집중할 수 있다.
그리고 타입을 명시적으로 지정할 경우 타입 안정성이 향상될 것이라 생각하지만 이 또한 사실이 아니다.
개발자가 올바르게 타입을 지정하지 않으면 오히려 타입 안정성이 떨어지기 때문이다.
var는 지역 변수에 대한 타입 추론을 사용한다.
이는 동적 타이핑과는 다른 것이다. (C#은 정적 타이핑 언어이다.)
동적 타이핑을 사용하는 언어로는 Python, Javascript 등이 있다.
명시적으로 지정하는게 더 좋은 경우
- 내장 숫자 타입을 사용하는 경우
- 숫자 타입들 간에는 다양한 변환 연산이 자동으로 수행된다.
float -> double 같은 확대 변환은 문제 없지만, long -> int 같은 축소 변환은 큰 문제가 발생할 수 있다.
따라서 var 보다는 명시적으로 타입을 지정해주도록 하자.
- 숫자 타입들 간에는 다양한 변환 연산이 자동으로 수행된다.
- var를 사용했더니 유지보수가 더 어려워 진 경우
- 개발자가 코드를 읽을 때 타입을 짐작하기 어렵다면 명시적으로 타입을 지정하는게 낫다.
Item 2 : const 보다는 readonly가 좋다
C# 에는 컴파일타임 상수와 런타임 상수 두 유형의 상수가 있다.
컴파일타임 상수는 const 키워드를 사용하고, 런타임 상수는 readonly 키워드를 사용한다.
이 둘은 서로 다르게 동작하는데, 이유는 값에 접근하는 방법이 서로 다르기 때문이다.
-> const 는 컴파일타임에 변수가 값으로 대체된다.
const
- 내장된 숫자형, enum, 문자열, null에 대해서만 사용될 수 있다.
내장 자료형이어야만 컴파일타임에 상수를 리터럴로 대체할 수 있기 때문이다.
ex) DateTime 타입은 const 로 선언할 수 없다. - 기본적으로 static 이다.
- 값이 반드시 초기화 어야 한다.
- 정적 변수와 동일하게 동작한다. 즉, 객체를 생성할 필요가 없다.
- 정적 변수와 상수 변수의 유일한 차이점은 정적 변수는 값을 수정할 수 있지만 상수 변수의 값은 수정할 수 없다는 점이다.
readonly
- 읽기 전용 변수로 불리며 값을 초기화 한 이후에 수정할 수 없다.
- 변수를 선언할 때 값 초기화가 필수가 아니다.
생성자에서 readonly 변수를 초기화할 수 있지만, 생성자 외부에서는 값을 수정할 수 없다. - 메서드 내에서는 선언할 수 없다. (const는 가능)
- 비정적 변수와 유사하게 동작한다.
따라서 변수에 접근하기 위해서 객체가 필요하다.
readonly 대신 const 를 사용했을 때 얻는 장점은 빨라지는 성능이다.
하지만 코드의 유연성을 해치기 때문에, const를 쓴다면 어느정도의 성능향상이 이루어질 수 있을지 반드시 성능을 측정해보는것이 좋다.
그리고 attribute, switch/case 문의 레이블, enum 정의 시 사용하는 상수 등은 컴파일 시에 사용돼야 하므로 반드시 const를 통해서 초기화해야한다.
이러한 용도 외에 사용되는 대부분의 상수는 readonly로 사용하자.
Item 3 : 캐스트보다는 is, as가 좋다
C# 에서 형변환을 수행하는 방법에는 as 연산자를 사용하는 방법과 컴파일러의 캐스트 연산자 구문을 사용하는 두 가지 방법이 있다.
더 방어적인 코드를 작성하려는 경우에는 우선 is 연산자로 형변환이 가능한지를 확인한 후에 실제 형변환을 수행하도록 코드를 작성할 수 있다. (Kotlin과 매우 유사한 방식)
만약 as 나 is 연산자를 사용하면 사용자 정의 형변환(implicit, explicit)은 수행되지 않는다.
그리고 형변환 과정에서 새로운 객체가 생성되는 경우는 거의 없다.
예외적으로 as 연산자를 이용하여 박싱된 값 타입의 객체를 nullable 값 타입의 객체로 변환하는 경우 새로운 객체가 생성된다.
cast(캐스트)
- 피연산자 앞에 괄호로 변경할 자료형을 넣어서 형변환을 수행한다.
object o = TaskFactory.GetObject();
// cast 변환
try
{
MyType t;
t = (MyType) o;
}
catch (InvalidCastException e)
{
// error
}
as 연산자
- 형변환을 수행할 수 없거나, null을 대상으로 형변환을 수행하는 경우 null을 반환한다.
object o = TaskFactory.GetObject();
// as 변환
MyType t = o as MyType;
if (t != null) Console.WriteLine(t);
else
{
// error
}
as 연산자가 try/catch 문을 사용하지 않아도 되기 때문에 성능도 캐스트보다 좋다.
- int와 같은 value 타입에 대해서 형변환을 시도할 경우 컴파일에러가 발생한다.
따라서 아래와 같이 as 연산자를 그대로 이용하되 nullable 타입으로 형변환을 수행한 후 그 값이 null 인지를 확인하고 작업을 진행하는 것이 낫다.
object o = Factory.GetObject();
var value = o as int?;
if(value != null) Console.WriteLine(value);
is 연산자
- 형 변환이 가능한지 여부를 확인 후 가능하면 true, 불가능하면 false를 리턴한다.
OOP에서는 가능하면 형변환을 피하는 것이 좋다.
불가피한 경우라면 is와 as를 함께 사용해서 변환의 결과가 예상에서 벗어나지 않도록 코드를 작성하자.
Item 4 : string.Format()을 보간 문자열로 대체하라
보간 문자열의 사용 방법
var pi = System.Math.PI;
string str = $"PI is {pi}";
Console.WriteLine($"{str}");
'$' 를 " 앞에 붙이고, 문자열에 넣을 변수를 중괄호 안에 넣어준다.
var pi = System.Math.PI.ToString();
string str = $"PI is {pi}";
Console.WriteLine($"{str}");
위 코드처럼 미리 ToString() 을 사용해 문자열로 변경하면 값 타입이 박싱되는 것을 피할 수 있다.
Python 에서 사용하던 문자열 보간과 거의 같다.
주의할 점
- 문자열 보간 기능의 결과는 문자열이다. 모든 값이 대체되고 단일의 문자열만이 남을 뿐이다.
=> 따라서 매개변수화된 SQL 쿼리를 만드는데 적합하지 않다.
이처럼 이후에 객체나 데이터로 재해석이 필요한 문자열을 생성할 때 매우 주의해야 한다.
Item 5 : 문화권별로 다른 문자열을 생성하려면 FormattableString을 사용하라
마이크로소프트 언어 설계팀의 목표 : 모든 문화권을 포괄하는 문자열 생성 시스템을 만드는 동시에 단일의 문화권만을 고려할 경우 코드를 가능한 한 단순하게 작성할 수 있는 시스템 개발.
보간 문자열과 FormattableString 타입의 객체를 을 사용해서 문화권과 언어를 지정하여 문자열 생성가능.
FormattableString second = $"{DateTime.Now.Day} : day, {DateTime.Now.Month} : month";
Console.WriteLine(ToGerman(second));
public static string ToGerman(FormattableString src)
{
return string.Format(System.Globalization.CultureInfo.CreateSpecificCulture("de-de"),
src.Format, src.GetArguments());
}
Item 6 : nameof() 연산자를 적극 활용하라
nameof() 연산자는 심볼 그 자체를 해당 심볼에 해당하는 문자열로 대체해준다.
Console.WriteLine(nameof(System.Collections.Generic)); // output: Generic
Console.WriteLine(nameof(List<int>)); // output: List
Console.WriteLine(nameof(List<int>.Count)); // output: Count
Console.WriteLine(nameof(List<int>.Add)); // output: Add
var numbers = new List<int> { 1, 2, 3 };
Console.WriteLine(nameof(numbers)); // output: numbers
Console.WriteLine(nameof(numbers.Count)); // output: Count
Console.WriteLine(nameof(numbers.Add)); // output: Add
nameof() 는 심볼의 이름을 평가하며, 타입, 변수, 인터페이스, 네임스페이스에 대하여 사용할 수 있다.
제네릭 타입의 경우 부분적으로 제약이 있어서 모든 타입 매개변수를 지정한 닫힌 제네릭 타입만을 사용할 수 있다.
그리고 항상 로컬 이름을 문자열로 반환하는 역할을 수행한다.
ex) System.Int.MaxValue 를 nameof() 에 넣으면 항상 MaxValue 를 반환한다.
장점
- 이름이나 문자열 식별자에 의존하는 간단한 라이브러리들이 많이 사용된다.
따라서 nameof()의 활용이 중요해졌다.
단점
- 타입 정보를 손실한다.
따라서 개발 도구의 도움을 더 이상 받지 못하며, 정적 타입 언어의 주요 장점을 상실한다.
일반적인 활용 예
public string Name
{
get{ return name; }
set
{
if (value != name)
{
name = value;
PropertyChanged?.Invoke(this,
new PropertyChangedEventArgs(nameof(Name)));
}
}
}
nameof() 연산자를 사용했기 때문에 속성의 이름을 변경할 경우 이벤트의 인자로 전달해야하는 문자열도 쉽게 변경할 수 있다.
Item 7 : 델리게이트를 이용하여 콜백을 표현하라
콜백은 서버가 클라이언트에게 비동기적으로 피드백을 주기 위해서 주로 사용하는 방법이다.
이를 위해 멀티스레딩 기술도 사용되고, 동기적으로 상태를 갱신하는 기법도 활용된다.
C# 에서는 델리게이트를 이용하여 표현된다.
.Net Framework 에서 자주 사용하는 델리게이트를 정의해두었다.
Java의 내장 Funtional Interface와 느낌이 비슷하다.
- Predicate<T>
- T 타입의 매개변수 하나를 입력받아 bool 타입 결과를 리턴한다.
- 조건을 검사하는데 사용된다.
- Action<T>
- 여러 개의 매개변수를 받아서 void를 반환한다.
- Java의 Consumer<>와 비슷한 개념이다.
- Func<T>
- 여러 개의 매개변수를 받아 단일의 결괏값을 반환한다.
- Java의 Function<>과 비슷하다.
- Func<T, bool> 와 Predicate<T> 는 서로 동일하다.
델리게이트 기본 사용법
Predicate<int> isOdd = n => n % 2 == 1;
Predicate<int> isEven = n => n % 2 == 0;
var numbers = Enumerable.Range(1, 200).ToList();
var oddNumbers = numbers.Find(isOdd);
var test = numbers.TrueForAll(n => n < 50);
numbers.RemoveAll(isEven);
numbers.ForEach(Console.WriteLine);
LINQ는 델리게이트 콜백 개념을 기반으로 만들어졌다.
실제로 List<T>는 콜백을 사용하는 다양한 메서드를 가지고 있다.
위 코드에 나와있는 Find, RemoveAll, ForEach 외에도 다양한 메서드들이 델리게이트로 지정한 동작을 수행하는 방식을 사용한다.
Java의 Stream API와 매우 유사한 듯하다.
멀티캐스트
using System;
// Define a custom delegate that has a string parameter and returns void.
delegate void CustomDel(string s);
class TestClass
{
// Define two methods that have the same signature as CustomDel.
static void Hello(string s)
{
Console.WriteLine($" Hello, {s}!");
}
static void Goodbye(string s)
{
Console.WriteLine($" Goodbye, {s}!");
}
static void Main()
{
// Declare instances of the custom delegate.
CustomDel hiDel, byeDel, multiDel, multiMinusHiDel;
// In this example, you can omit the custom delegate if you
// want to and use Action<string> instead.
//Action<string> hiDel, byeDel, multiDel, multiMinusHiDel;
// Initialize the delegate object hiDel that references the
// method Hello.
hiDel = Hello;
// Initialize the delegate object byeDel that references the
// method Goodbye.
byeDel = Goodbye;
// The two delegates, hiDel and byeDel, are combined to
// form multiDel.
multiDel = hiDel + byeDel;
// Remove hiDel from the multicast delegate, leaving byeDel,
// which calls only the method Goodbye.
multiMinusHiDel = multiDel - hiDel;
Console.WriteLine("Invoking delegate hiDel:");
hiDel("A");
Console.WriteLine("Invoking delegate byeDel:");
byeDel("B");
Console.WriteLine("Invoking delegate multiDel:");
multiDel("C");
Console.WriteLine("Invoking delegate multiMinusHiDel:");
multiMinusHiDel("D");
}
}
/* Output:
Invoking delegate hiDel:
Hello, A!
Invoking delegate byeDel:
Goodbye, B!
Invoking delegate multiDel:
Hello, C!
Goodbye, C!
Invoking delegate multiMinusHiDel:
Goodbye, D!
*/
C# 공식 문서의 델리게이트를 멀티캐스트 실행하는 예제이다.
- 모든 델리게이트는 멀티캐스트가 가능하다.
- +, -, +=, -= 연산자를 사용해서 자유롭게 델리게이트를 추가하거나 뺄 수 있다. (순서대로)
- 멀티캐스트는 동일한 타입의 매개변수와 리턴값을 가지는 델리게이트 끼리만 가능하다.
- 멀티캐스트 델리게이트는 한 번만 호출하면 델리게이트 객체에 추가된 모든 대상 함수가 호출된다.
- 이전 델리게이트의 반환 값이 이후 델리게이트의 입력 값으로 전달되는 파이프라이닝은 X
멀티캐스트 델리게이트 주의해야할 부분
1. 예외에 안전하지 않다.
멀티캐스트 델리게이트의 내부 동작 방식은 대상 함수들을 연속적으로 호출하는 형태로 구현된다.
델리게이트는 어떤 예외도 잡지 않으며, 따라서 예외가 발생하면 함수 호출 과정이 중단된다.
2. 마지막으로 호출된 대상 함수의 반환값이 델리게이트의 반환값으로 간주된다.
Item 8 : 이벤트 호출 시에는 null 조건 연산자를 사용하라
?. : null 조건 연산자
kotlin과 같다.
NullPointerException을 방지하고, 멀티스레드 환경에서 Atomic한 동작을 보장하기 위해서 null 조건 연산자를 사용해야 한다.
public class EventSource
{
private int counter;
private EventHandler<int> Updated;
public void RaiseUpdates()
{
counter++;
Updated?.Invoke(this, counter);
}
}
이 코드는 안전하게 이벤트 핸들러를 호출한다.
'?.' 연산자는 연산자의 왼쪽을 평가하여 이 값이 null이 아닌 경우에만 연산자 오른쪽의 표현식을 실행한다.
만약 연산자 왼쪽이 null이면 아무 작업도 수행하지 않고 다음 단락으로 이동한다.
Updated?.Invoke(this, counter);
위 코드는 원자적으로 동작하기 때문에 Updated 핸들러가 중간에 삭제되어도 null 을 잡아낼 수 있다.
Item 9 : 박싱과 언박싱을 최소화하라
.Net Framework는 모든 타입의 최상위 타입을 참조 타입인 System.Object로 정의한다.
박싱
int와 같은 값 타입의 객체를 타입이 정해지지않은 임의의 참조 타입 내부에 포함시키는 방법이다.
언박싱
반대로 박싱되어있는 참조 타입의 객체로부터 값 타입 객체의 복사본을 가져오는 방법이다.
문제점
- 박싱과 언박싱 과정에서 객체에 대한 복사본을 생성할 수 있고, 이로 인해 예상치 못한 버그가 발생하기도 한다.
- 값 타입을 다형적으로 처리하는 과정에서 성능을 느리게 만든다.
박싱 언박싱 최소화 하기
1.보간 문자열을 만들 때 문자열 인스턴스 전달하기
Console.WriteLine($@"A few numbers : {FirstNumber.ToString()}, {SecondNumber.ToString()}, {ThirdNumber.ToString()}");
보간 문자열을 만드는 작업은 System.Object 객체에 대한 배열을 사용한다.
따라서 WriteLine 메서드에 ToString()을 사용해 문자열 인스턴스를 바로 전달해주면 박싱을 피할 수 있다.
2. 제네릭 컬렉션 사용하기
ArrayList 같은 비 제네릭 컬렉션을 사용하기보다
List<int> 같은 제네릭 컬렉션을 사용해서 객체의 타입을 지정해주자.
비 제네릭 컬렉션은 컬렉션에 값을 추가할 때마다 매번 일어나는 박싱을 피할 수 없다.
Item 10 : 베이스 클래스가 업그레이드된 경우에만 new 한정자를 사용하라
new 한정자란?
베이스 클래스(부모 클래스)에서 virtual로 선언하지 않은 메서드를 재정의하려는 경우에 new 한정자를 사용할 수 있다.
클래스의 naming scope 내에 새로운 메서드를 추가하는 역할을 수행한다.
가상 메서드와 비가상 메서드를 재정의 할 수 있다.
메서드를 재정의할 때 사용하기 때문에 override와 비슷하다고 생각할 수 있다.
하지만 override는 가상 메서드만을 재정의하지만 new는 비가상 메서드도 재정의해준다.
비가상 메서드
정적으로 바인딩되고, 런타임에 파생 클래스에서 새롭게 정의하는 메서드가 있는지 찾지 않는다.
가상 메서드
동적으로 바인딩되고, 런타임에 객체의 타입에 따라 부합하는 메서드를 호출한다.
new vs override
public class Book
{
public virtual void PrintType()
{
Console.WriteLine("책");
}
}
public class Novel : Book
{
public override void PrintType()
{
Console.WriteLine("소설");
}
}
Book book = new Novel(); // 자식 객체 생성
book.PrintType(); // '소설' 출력
가상 메서드를 override하면 원본 객체를 업캐스팅 한 경우에도 원본(자식)의 메서드를 호출한다.
public class Book
{
public void PrintType()
{
Console.WriteLine("책");
}
}
public class Novel : Book
{
public new void PrintType()
{
Console.WriteLine("소설");
}
}
Book book = new Novel(); // 자식 객체 생성
book.PrintType(); // '책' 출력
new 한정자를 사용해 메서드를 재정의하면,
원본 객체가 Novel 타입임에도 불구하고 업캐스팅 시 부모의 메서드가 호출된다.
new 한정자가 필요한 경우
1. 외부 라이브러리에 포함된 BaseWidget 이라는 클래스를 상속하여 MyWidget을 정의했다.
추가로 NormalizeValues 메서드를 정의해서 사용중이다.
public class MyWidget : BaseWidget
{
public void NormalizeValues()
{
// 세부 내용 생략
}
}
2. BaseWidget 라이브러리가 업데이트되면서 NormalizeValues() 라는 똑같은 이름의 메서드가 생겼다.
public class BaseWidget
{
public void NormalizeValues()
{
// 세부 내용 생략
}
}
이 문제를 해결하기 위해서 2가지 방법이 있다.
- MyWidget 클래스에 정의한 NormalizeValues() 메서드의 이름을 변경한다.
- new 한정자를 사용한다.
이때 new 한정자를 사용하는 이유는 기존 MyWidget 개발자들이 메서드 이름의 변경으로 인한 혼동을 방지하기 위해서이다.
하지만 시간이 지남에 따라 BaseWidget의 NormalizeValues() 메서드를 사용하는 경우도 늘어나면, 이름은 같은데 서로 다르게 동작하는 메서드에 여러 사용자들이 혼동을 겪을 수 있다.
따라서 장기적으로 보면 1. 방법을 사용하는 것이 더 나을 수 있다.
new 한정자를 활용해도 좋은 경우
- 베이스 클래스에서 이미 사용하고 있는 메서드를 재정의하여 완전히 새로운 베이스 클래스를 만들어야 하는 경우.
- 널리 사용되고 있는 메서드가 있어서 이를 사용하는 코드를 일일이 수정하기 어려운 경우.
- 외부 어셈블리에서 이 메서드를 사용하고 있어서 코드를 수정할 수 없는 경우.
new 한정자를 사용할 때 주의해야 할 점
- new 한정자는 재정의가 예정되어 있지 않은 멤버를 억지로 재정의하려는 경우이다. 따라서 많은 사람들이 오해를 할 수 있다.
- 코드를 모두 수정할 수 있다면, 장기적으로 봤을 때는 new 를 사용하지 않는게 좋다.
따라서 베이스 클래스가 업그레이드되어 메서드의 이름이 충돌하는 경우는 매우 특별한 경우이기 때문에 new 한정자를 검토해볼 수 있다.
하지만 이 경우에도 new 한정자의 사용은 신중하게 고려해야 한다.
'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 |
[More Effective C#] Chapter 1. 요약 (0) | 2024.03.10 |