1) 지역변수를 선언할 때는 var를 사용하는 것이 좋다.
IEnumerable<T>와 IQueryable<T>를 사용할 때, IQueryable<T>를 IEnumerable<T>로 캐스팅하여 사용할 경우 해당 인터페이스의 기능을 사용하지 못하게 된다.
var는 컴파일 시간에 정적으로 타입을 추론하여 결정된다. var를 이용하여 간단히 변수를 선언하고 그 의미는 변수 명에 기입할 수 있다.
var ipAddress = IPEndPoint.Parse("127.0.0.1"); // 반환되는 변수는 IpAddress와 관련됌
var SellingProduct = someObject.DoSomeWork(params); // ""는 Product와 관련됌
대체적으로 내장 타입에 대한 var의 이용은 좋지 않다. ex)float,double,int,long 형 변환시 narrow conversion 문제가 있을 수 있는데, 이를 알기 어렵다.
LINQ 구문을 이용하는 쿼리 식의 반환은 IQueryable<T>를 반환하는데, 이를 IEnumerable<T>로 받게 되면 두번의 쿼리식에서 한번에 수행하는 쿼리가 아니라 각각 처리되는 쿼리 식이된다.
public IEnumerable<string> GetMatchingUsers(string queryStr)
{
IEnumerable<string> q = from c in db.UserInfos select c.UserName;
// 여기 이전에서 쿼리식의 결과가 평가되어 1차적으로 필터링된다.
return q.Where(s => s.Contains(queryStr));
}
public IEnumerable<string> GetMatchingUsers(string queryStr)
{
IQueryable<string> q = from c in db.UserInfos select c.UserName;
return q.Where(s => s.Contains(queryStr)); // 여기서 단일 쿼리로 해석되어 한번에 수행될 수 있다.
}
IQueryable<T>를 이용하여 실제로 순회할 때까지 SQL 쿼리의 수행이 연기되기 때문이다.
2) const보다는 readonly가 좋다
컴파일타임 상수 vs 런타임 상수
// 컴파일 타입 상수
public const int Millennium = 2000;
// 런타임 상수
public static readonly int ThisYear = 2004;
컴파일 타입 상수는 해당 이름을 사용하는 곳에 치환되어 사용된다. (Millennium -> 2000의 의미)
런타임 상수는 값으로 대체되지 않고 상수에 대한 참조로 컴파일 된다.
컴파일 타입 상수의 경우 내자된 숫자형, enum, 문자열, null에 대해서만 사용할 수 있다.
런타입 상수는 생성자에서 초기화 가능하며 그 이후에는 수정될 수 없다.
컴파일 타입 상수를 포함하는 어셈블리를 변경하고 이를 반영한 전체 빌드를 하지 않고 해당 어셈블리만 빌드한채 배포하면 이전에 컴파일 타임 상수에서 전파되어 값으로 치환된 어셈블리에서 Legacy 값을 사용하는 호환성 문제가 발생한다. 한편 런타임 상수는 참조가 형성되기 때문에 그 값이 해당 어셈블리를 타고 런타임에 평가되어 호환성 문제가 발생하지 않는다.
3) 캐스트보다는 is, as가 좋다.
as를 사용하면 기본적으로 안전한 캐스팅을 지원하여 캐스팅이 불가능하면 null을 할당하기 때문이다.
- 일반 캐스팅을 사용하면 위험한 경우
public class SecondType
{
private MyType _value;
public static implicit operator MyType(SecondType t)
{
return t._value;
}
}
static void Main(string[] args)
{
// version 1
// o는 실제로 SecondType이다.
object o = Factory.GetObject();
MyType t = o as MyType;
if(t != null) {}
else {//오류보고}
//version 2
try
{
MyType t2 = (MyType)o;
}
catch(InvalidCastException)
{
//...
}
}
SecondType 인스턴스 o에 대해 서로 다른 방법으로 MyType으로 형변환을 시도하는데, 두 경우 모두 캐스팅을 실패한다.
버전 1) as는 사용자 정의 형 변환 연산자와 관련이 없다. (MyType) 캐스팅을 했을 때만 호출된다.
버전 2) 사용자 정의 형 변환 연산자가 오버로딩될 것 같지만 실제로는 o는 컴파일 시점에서 SecondType인지 알 수가 없기 때문에 캐스팅을 실패한다.
- as는 null로 캐스팅될 수 있는 타입에 사용한다.
만약 내장 숫자 형식과 같은 형식은 null이 될 수 없다. 이 경우 nullable을 사용하여 as를 사용한다.
object o = Factory.GetValue();
var i = o as int ?;
if(i != null)
Console.WriteLine(i.Value);
- IEnumerator를 사용하는 경우 (foreach)
public void UseCollection(IEnumerable theCollection)
{
foreach(MyType t in theCollection)
t.DoStuff();
}
foreach에서 컬렉션의 IEnumerator를 이용하여 MyType의 t를 가져온다. 이때 IEnumerator.Current는 System.Object 타입을 반환하고 다시 MyType(T)로 캐스팅한 원소를 순회할 수 있도록 한다. 컬렉션이 SecondType 클래스를 담는 다면, 사용자 정의 형 변환 연산자(SecondType->MyType)가 오버로딩되어 있더하라도 실제로는 System.Object->MyType의 캐스팅을 하기 때문에 InvalidCastException이 발생할 수 있다.
- Enmerable.Cast<T>() 시퀀스 내의 개별 요소들을 특정 타입으로 형변환시 as 대신 캐스팅을 사용한다.
4) string.Format()을 문자열 보간으로 대체하라
대체적으로 더 간결한 표현이 가능하다.
5) 문화권별로 다른 문자열을 생성하려면 FormattableString을 사용하라
FormattableString second = $"It's the {DateTime.Now.Day} of the {DateTime.Now.Month} month";
FormattableString의 경우 문화권마다 다른 문자열을 생성할 수 있다. (표현 방식이 다른 문자열을 동일하게 보간 방법으로 제공한다.)
6) nameof 연산자를 적극 활용해라
nameof()는 심볼의 이름을 평가하여 타입, 변수, 인터페잇, 네임스페이스에 대하여 사용할 수 있다. nameof() 연산자는 다양하게 활용할 수 있고 항상 로컬 이름을 문자열로 반환하는 역할을 수행한다.
ex) System.Int.MaxValue 타입에 대한 nameof()는 항상 MaxValue를 반환한다.
7) 델리게이트를 이용하여 콜백을 표현하여라
델리게이트는 런 타입에 메서드를 바인드할 수 있으며 여러 메서드를 참조하여 한번에 호출할 수 있다.
- ex) Predicate<T>, Action<>, Func<>
- Predicate<T>는 T인자를 받아 bool을 반환하는 함수에 대한 지칭이다.
LINQ에서 델리게이트가 자주 사용되며 무명 람다 함수를 이용하여 많이 사용한다.
- ex) List<int> numbers = Enumerable.Range(1, 100).ToList();
- var oddNumbers = numbers.Find(n => n % 2 == 1);
- var test = numbers.TrueForAll(n => n < 50);
모든 델리게이트는 기본적으로 멀티캐스트가 가능하지만 (등록된 함수가 모두 호출됌) 두가지 사항을 주의해야한다.
예외에 안전하지 않다. 내부 함수가 예외를 던져도 catch할 길이 없다.
델리게이트의 반환값은 항상 마지막 함수에 의해 결정된다.
8) 이벤트 호출 시에는 null 조건 연산자를 사용하라
public class EventSource
{
private EventHandler<int> Updated;
private int counter;
public void RaiseUpdates()
{
++counter;
Updated(this, counter);
}
}
Updated 이벤트에 메서드가 바인드 되어 있지 않다면 Updated(this, counter)는 NullReferenceException 예외가 발생한다.
만약 그렇다고 다음과 같이 null인지 체크하는 방법도 멀티 스레드 방식에서는 안전하지는 않다.
public void RaiseUpdates()
{
counter++;
if(Updated != null)
Updated(this, counter);
}
Updated가 null이 아닌 것을 확인한 스레드가 다음 라인에서 여전히 바인드를 유지할 지 알 수 없다.
public void RaiseUpdates()
{
++counter;
var handler = Updated;
if(handler != null)
handler(this, counter);
}
지역변수로 참조를 유예한다면 스레드에 안전하나 가독성이 여전히 떨어지고 다소 긴 방법을 사용해야한다.
null? 조건연산자를 이용하면 원자적으로 왼쪽을 평가하여 메서드를 실행할 수 있다.
public void RaiseUpdates()
{
++counter;
Updated?.Invoke(this, counter);
}
Invoke는 모든 델리게이트와 이벤트에 대하여 Invoke() 메서드를 타입 안정적 형태로 생성해주기 때문에 ()를 통해 호출하는 것과 동일하다.
9) 박싱과 언박싱을 최소화하여라
박싱이란 ? 값 타입의 객체를 참조 타입(System.Object) 내부에 포함시켜 참조 형태로 관리할 수 있게한다.
언박싱이란 ? 박싱되어 있는 참조 타입의 객체로부터 값 타입 객체의 복사본을 가져온다.
박싱과 언박싱을 하는 과정에서 System.Object와 같은 임시 객체가 만들어지는데 가끔 버그가 발생할 수 있다.
박싱의 대표적인 예로 값 타입을 보간을 사용하여 인수를 전달할 때 발생한다.
Console.WriteLine($"A few numbers : {firstNumber}, {secondNumber}, {thirdNumber}");
보간 문자열을 만드는 과정에서 인자들은 System.Object[] (배열)로 변환된다. 따라서 3개의 정수 타입의 변수에 대해 모두 참조형식으로 박싱된다.
System.Object로 박싱되는 과정은 다음을 포함한다.
int i = 25;
object o = i; // 박싱
Console.WriteLine(o.ToString());
박싱을 피하고 싶다면 다음과 같이 직접 문자열 인스턴스를 전달하는 것이 좋다.
Console.WriteLine(@"A few numbers : {firstNumber.ToString()}, {secondNumber.ToString()},
{thirdNumber.ToString()}");
제너릭이 아닌 일반 컬렉션을 사용하는 경우 원소를 항상 System.Object로 관리하므로 값 타입의 객체를 추가하는 경우 항상 박싱이 일어난다고 생각해야한다.
값 타입의 제너릭 컬렉션은 박싱이 일어나지 않도록 한다. 항상 원소를 복사하여 추가하고 인덱싱을 통해 지역변수로 받을 때도 복사가 일어나므로 참조 타입이 아닌 것에 주의 해야한다.
List<Person> attendees = new List<Person>();
Person p = new Person { Name = "OldName" };
attendees.Add(p); // 복사하여 컬렉션에 추가한다.
p.Name = "My Name";
// 값 형식을 사용한다.
Person p2 = attendees[0]; // 인덱싱을 통해 얻은 값도 복사하여 얻은 값이다.
p2.Name = "New Name";
Console.WriteLine(attendees[0].Name); // 항상 OldName이 출력된다.
10) 베이스 클래스가 업그레이드된 경우에만 new 한정자를 사용한다.
new 한정자는 베이스 클래스에서 같은 이름으로 정의된 메서드가 있고 파생 클래스에서 같은 이름의 함수를 기반 클래스의 함수와 다르게 동작하는 새로운 함수를 정의할 때 사용한다.
new 한정자는 가상 함수를 만드는 것이 아니라 클래스의 명명 범위에(Naming Scope)에 새로운 메서드를 추가하는 역할을 한다.
public class Base
{
public void MagicMethod()
{
Console.WriteLine("MyClass");
}
}
public class Derived : Base
{
public new void MagicMethod()
{
Console.WriteLine("MyOtherClass");
}
}
static void Main(string[] args)
{
object c = MakeObject();
Base b = c as Base;
b.MagicMethod(); // Base의 메서드를 호출
Derived d = c as Derived;
d.MagicMethod(); // Derived의 메서드를 호출
}
참조 타입에 따라 동일한 함수를 호출하고 있지만 다형성에 상관없이 해당 함수가 기반 클래스의 일반 메서드를 호출하고 있는 지, 가상 함수로 정의된 함수를 호출하고 있는 지 아니면 new로 새로 정의한 함수를 호출한 것인지 모호하다.
대부분의 경우 가상 함수 혹은 일반 메서드로 생각하고 동일하거나 비슷한 기능을 수행할 것이라고 생각하지만, new의 용법은 새로운 메서드를 정의하여 완전히 다른 구현을 하고 싶을 때 사용하면 예상과 다르게 동작할 수 있다.
따라서 new를 사용하는 경우 그냥 가상 함수로 정의하는 것처럼 비가상함수로 취급하되 설계의 변경으로 새로운 함수를 추가하고자 하는 경우(기반클래스에서 비가상함수를 만들고 싶지 않을때) 업그레이드 하는 용도로 사용하는 것이 좋다. 그렇다면 가상 함수로 사용하는 것과 같은 의미를 가져 비슷한 동작을 기대할 수 있다.
public class BaseWidget
{
public void NormalizeValues()
{
// 세부 내용 생략
}
}
public class MyWidget : BaseWidget
{
public void NormalizeValues()
{
base.NormalizeValues();
// 기타 추가하고 싶은 메서드...
}
}
출처 : Effective C# 빌 와그너 저 - 한빛 미디어
'C# > Advanced C#' 카테고리의 다른 글
[Effective C#] 3장 제너릭 활용 (0) | 2021.11.20 |
---|---|
[C#] Task (3) async / await 동작 더 알아보기 (0) | 2021.11.18 |
[Effective C#] 2장 .NET 리소스 관리 (0) | 2021.11.07 |
[C#] Task (2) 비동기 프로그래밍 Asynchronous Programming (0) | 2021.10.23 |
[C#] Task (1) Async / Await overview (0) | 2021.10.23 |