OOP(Object-Oriented Programming)을 사용한 하나의 개체는 다른 새로운 type을 상속하는 기본 개념과 generic을 사용하여 어떻게 code를 안전하게 만들고 성능을 높일 수 있는지, delegate와 event를 통해 type 간 message를 어떻게 교환할 수 있는지를 알아보고 참조와 값 type에 대한 차이점도 확인해 볼 것입니다. 공통기능에 대한 interface를 구현하고 기능을 재사용하기 위해 기반 class로부터 상속받는 파생 class를 만들 것이며 상속된 type member를 재정의하고 다형성(polymorphism)을 사용해 볼 것입니다. 또한 확장 method의 생성과 계층적으로 상속된 class 간 변환에 대한 것들, 그리고 static code analyzer를 통해 어떻게 code를 개선할 수 있을지도 함께 살펴볼 것입니다.
1. Class library와 console application의 설정
이번 예제를 위해서는 2개의 project를 정의하는 것으로 시작해야 합니다. csStudy06 이라는 solution을 만들고 여기에 MyLibrary이름의 Class Library유형의 project와 MyApp이름의 Console App project를 추가합니다. 그리고 MyLibrary file에서 Person.cs file을 아래와 같이 추가합니다.
namespace MyLibrary
{
public class Person
{
// 속성
public string? Name { get; set; }
public DateTime DateOfBirth { get; set; }
// Method
public void WriteToConsole()
{
Console.WriteLine($"{Name} was born on a {DateOfBirth:dddd}.");
}
}
}
MyApp project에서 MyLibrary project를 참조 추가하고 Program.cs에서 기존의 구문을 모두 삭제한 뒤 Person에 대한 instance개체를 생성하고 해당 개체의 정보를 console에 출력하는 문을 아래와 같이 추가합니다.
using MyLibrary;
Person kdh = new()
{
Name = "김동희",
DateOfBirth = new(year: 1980, month: 12, day: 01)
};
kdh.WriteToConsole();
예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
2. Generic을 통해 안전하게 재사용할 수 있는 type만들기
2005년 C# 2.0과 .NET Framework 2.0에서 Microsoft는 type을 좀 더 안전하게 재사용할 수 있으며 더 효율적인 generic이라는 기능을 도입했습니다. 이를 통해 개발자는 개체를 매개변수로 전달하는 방식과 비슷하게 type을 매개변수로 전달할 수 있게 되었습니다.
(1) 비 generic type 사용
우선 generic type을 사용하지 않는 경우에 먼저 살펴볼 것입니다. 이를 통해 취약하게 입력된 매개변수 및 값에서와 같은 문제와 System.Object의 사용으로 인한 성능문제를 해결하기 위해 generic이 어떤 개념으로 설계되었는지를 이해할 수 있을 것입니다.
System.Collections.Hashtable는 나중에 값을 빠르게 찾는 데 사용되는 각각의 고유한 key와 값을 다수로 저장하는 데 사용되며 key와 value모두 System.Object로 선언되었기에 object type이 될 수 있습니다. 비록 int와 같은 type값을 저장할 때 object사용에 대한 유연성을 제공해 주기는 하지만 generic보다 상대적으로 느릴 수밖에 없으며(boxing과 unboxing) item을 추가할 때 type을 확인하지 않으므로 bug가 발생되기 쉽습니다.
Program.cs에서 비 generic collection인 System.Collections.Hashtable에 대한 intance를 생성하고 아래와 같이 4개의 item을 추가합니다.
System.Collections.Hashtable lookupObject = new();
lookupObject.Add(key: 1, value: "one");
lookupObject.Add(key: 2, value: "two");
lookupObject.Add(key: 3, value: "three");
lookupObject.Add(key: kdh, value: "Person");
이어서 2의 값을 가진 key를 정의하고 이를 hashtable에서 값을 찾기 위한 구문을 추가합니다.
int key = 2;
Console.WriteLine(format: "Key {0} has value: {1}", arg0: key, arg1: lookupObject[key]);
또한 kdh개체를 사용해 값을 찾은 구문도 추가합니다.
Console.WriteLine(format: "Key {0} has value: {1}", arg0: kdh, arg1: lookupObject[kdh]);
예제를 실행시키면 아래와 같은 결과를 표시할 것입니다.
비록 code는 잘 작동하긴 했지만 실질적으로 모든 type이 key와 값으로 사용될 수 있기 때문에 잠재적인 문제점을 가지고 있습니다. 다른 개발자가 예제의 lookupObject를 사용하고 모든 item이 int와 같은 특정 type이라고 예상한다면 아마도 해당 type으로 변환을 시도할 것입니다. 하지만 모든 type이 일관된 type은 아니므로 어느 지점에서는 예외를 일으킬 수 있습니다. 또한 lookupObject가 많은 item을 가지면 가질수록 성능은 저하됩니다.
System.Collections namespace의 import를 되도록이면 피해야 합니다. 대신 System.Collections.Generics와 다른 namespace를 대신 import 하여 사용하는 것이 좋습니다.
(2) generic type 사용
System.Collections.Generic.Dictionary<TKey, TValue>도 역시 System.Collections.Hashtable과 마찬가지로 값을 빠르게 검색하는 데 사용되는 각각의 고유한 key를 가진 다수의 값을 저장할 수 있고 이때 key와 값은 어떠한 개체도 될 수 있습니다. 다만 compiler에게 key와 값의 type으로 어떤 것을 사용할지를 collection을 instance화 할 때 명시해야 합니다. 이때 TKey와 TValue의 generic 매개변수를 통해 type을 특정할 수 있습니다.
통상 generic type이 정의가능한 type을 하나 가질 때 이름은 T가 됩니다. 예를 들어 List<T>에서 T는 list에 저장되는 type을 말합니다. generic type이 정의가능한 type을 여러 개 가지는 경우에는 접두사로서의 이름이 T가 되어야 하고 그 뒤에 Dictionary<TKey, TValue>처럼 용도에 맞는 이름이 부여됩니다.
이러한 방식은 충분한 유연성을 제공하며 빠르고, item이 추가될 때 type을 확인하게 되므로 bug 또한 최소화할 수 있습니다. Dictionary< TKey, TValue>를 포함하고 있는 System.Collections.Generic namespace는 기본적으로 전역 import 되므로 별도의 import를 지정할 필요가 없습니다.
예제를 통해 generic을 사용하여 어떻게 문제를 해결할 수 있는지를 확인해 보도록 하겠습니다.
Program.cs에서 generic lookup collection Dictionary<TKey, TValue>에 대한 instance를 생성하고 아래와 같이 4개의 item을 추가합니다.
Dictionary<int, string> lookupIntString = new();
lookupIntString.Add(key: 1, value: "one");
lookupIntString.Add(key: 2, value: "two");
lookupIntString.Add(key: 3, value: "three");
lookupIntString.Add(key: kdh, value: "person");
이렇게 하면 compiler는 kdh에 대한 오류를 다음과 같이 표시할 것입니다.
이 상태에서 kdh를 숫자 4로 바꾸고 key변수도 4로 설정한 뒤 아래와 같이 dictionary에서 값을 검색하는 문을 추가합니다.
lookupIntString.Add(key: 4, value: "person");
Console.WriteLine(format: "Key {0} has value: {1}", arg0: key, arg1: lookupIntString[key]);
예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
3. Event 발생과 제어
Method는 종종 개체가 그 자체로 또는 관련 개체에서 수행할 수 있는 동작으로 설명됩니다. 예를 들어 List<T>는 자체적으로 item을 추가할 수 있고 스스로 item을 비울 수 있으며 File은 filesytem안에서 file을 생성하고 삭제하는 동작을 수행할 수 있습니다.
반면 Event는 개체로 발생되는 동작을 말합니다. 예를 들어 사용자 interface에서 button은 click event를 가지며 click는 button에서 발생하는 것입니다. FileSystemWatcher는 directory나 file에 대한 변화가 발생할 때 작동되는 Created 및 Deleted와 같은 event를 발생시키고 변경사항을 알리기 위해 filesystem을 감시합니다.
Event를 좀 다른 측면으로 보면 두 개체사이에 message를 교환하는 방법을 제공한다고도 할 수 있습니다.
Event는 delegate를 내장하고 있는데 event에 대해 들어가 보려면 우선 이 delegate가 무엇인지 그리고 어떻게 작동하는지를 먼저 알아야 합니다.
(1) Delegate를 사용한 method 호출
지금까지 예제를 통해서 우리는 이미 method를 호출하고 실행하는 가장 일반적인 방식을 사용해 봤습니다. .(점) 연산자를 통해 method의 이름을 사용하여 접근하는 것으로 예를 들어 Console.WriteLine는 Console type에 있는 WriteLine method를 호출한다는 의미가 됩니다.
method를 호출하고 실행하는 다른 방법으로는 delegate를 사용하는 것입니다. 함수 pointer를 지원하는 언어를 사용해 본 적이 있다면 delegate를 type-safe method pointer로 생각할 수 있습니다.
즉, delegate는 해당 delegate와 동일한 특성에 일치하는 method의 memory주소를 포함하고 있으므로 정확한 매개변수의 type을 통해 안전하게 호출할 수 있습니다.
예를 들어, Person class에서 아래와 같이 매개변수에 string type을 전달되어야 하며 반환값의 type이 int인 method가 있다면
public int MyMethod(string input)
{
return input.Length;
}
Person의 p1과 같은 instance를 통해 method를 아래와 같이 호출할 수 있습니다.
Person p1 = new();
int result = p1.MyMethod("Frog");
또는 간접적으로 호출될 method의 특성과 일치하는 delegate를 정의할 수 있는데 이때 delegate는 매개변수의 이름과는 일치할 필요가 없이 단지 매개변의 type과 반환되는 값의 type만 일치하면 됩니다.
delegate int DelegateWithMatchingSignature(string s);
이렇게 하면 호출하고자 하는 method의 point를 부여하고 아래와 같이 delegate의 instance를 생성한 뒤 method를 호출하는 것처럼 delegate를 호출하면 됩니다.
DelegateWithMatchingSignature d = new(p1.MyMethod);
int result = d("Flog");
Method를 호출하기에 다소 복잡한 과정을 거치는 것처럼 보이기 때문에 어쩌면 '이렇게 method를 호출할 필요가 있을까?'라는 질문을 던질 수도 있습니다. 결론부터 얘기하면 이것은 유연성을 제공하기 위한 것입니다.
예를 들어 순서대로 호출되어야 하는 method의 queue를 생성하기 위해 delegates를 사용할 수도 있습니다. 향상된 확장성을 제공하기 위해 수행되어야 하는 queue의 action은 service에서 일반적입니다.
다른 예로는 다수의 action이 병렬적으로 동작되어야 하는 경우를 들 수 있습니다. Delegate는 다른 thread에서 동작하는 비동기 운영을 지원하기 위한 내장된 기능을 갖고 있으며 이로 인해 향상된 응답성을 제공할 수 있습니다.
가장 중요한 것은 delegate는 우리가 서로에 대해서 알지 못하는 다른 개체들 사이에 message를 전달하기 위한 event를 구현할 수 있도록 한다는 것입니다. Event는 서로에 대해 알 필요가 없는 component사이에서 느슨한 결합의 예가 될 수 있는데 이때는 그저 event의 특성만 알고 있으면 될 뿐입니다.
(2) Delegate 정의하고 실행하기
Microsoft는 event로 사용하기 위해 2개의 delegate를 사전 정의하였고 해당 delegate는 모두 아래 2개의 매개변수를 가지고 있습니다.
- object? sender : 해당 매개변수는 event를 발생시킨 혹은 message를 보낸 개체를 참조하며 null이 될 수 있습니다.
- EventArgs e 또는 TEventArgs e : 해당 매개변수는 발생한 event에 대한 추가적인 관련 정보를 포함합니다. 예를 들어 GUI환경에서 MouseMoveEventArgs를 정의할 수 있는데 여기에는 mouse pointer가 위치한 x, y좌표를 나타내는 속성을 가지고 있습니다. 은행 계좌의 경우라면 출금하는 금액의 속성을 가진 WithdrawEventArgs를 가질 수도 있을 것입니다.
이들 delegate의 특성은 간단하지만 유연합니다.
public delegate void EventHandler(object? sender, EventArgs e);
public delegate void EventHandler<TEventArgs>(object? sender, TEventArgs e);
위에서 첫 번째는 추가적인 인수값을 전달할 필요가 없는 method용이며 두 번째는 generic type TEventArgs에 의해 정의된 대로 인수값을 전달해야 하는 method용입니다.
자체 type으로의 event를 정의해야 하는 경우에는 이들 2개의 사전정의된 delegate 중 하나를 사용할 수 있습니다.
이제 delegate와 event를 직접 사용해 보도록 하겠습니다.
Person class에 아래와 같은 구문을 추가하고
public EventHandler? Shout;
public int AngerLevel;
public void Poke()
{
AngerLevel++;
if (AngerLevel >= 3)
{
if (Shout != null)
{
Shout(this, EventArgs.Empty);
}
}
}
다음 사항에 주목합니다.
- 예제에서는 Shout이름의 EventHandler delegate field를 정의하였습니다.
- AngerLevel을 저장하기 위한 int field를 정의하였습니다.
- Poke이름의 method를 정의하였습니다.
- person에서는 Poke method가 실행될 때마다 AngerLevel field를 증가시키고 3의 값에 도달하면 Shout event를 발생시키도록 되어 있지만 단 적어도 단 하나의 event delegate가 code가 아닌 외부에서 정의된 method를 가라키고 있어야 합니다. 즉 null이 되어서는 안 됩니다.
Method를 호출하기 전 개체가 null인지 아닌지를 확인하는 것은 아주 일반적인 것입니다. C# 6 이후부터는 .연산자전에 ?문자를 통해 inline에서 간소화된 null확인이 가능해졌습니다.
Shout?.Invoke(this, EventArgs.Empty);
MyApp project에서는 Program.cs에서 sender 매개변수로 부터 Person개체를 참조하고 이들에 대한 일부 정보를 출력하는 특성과 일치하는 method를 아래와 같이 추가합니다.
static void Man_Shout(object? sender, EventArgs e)
{
if (sender is null) return;
Person? p = sender as Person;
if (p is null) return;
Console.WriteLine($"{p.Name} is this angry: {p.AngerLevel}.");
}
참고로 Event를 처리하기 위한 method의 이름은 Microsoft에서 관례적으로 '개체이름_Event이름'으로 지정하고 있습니다.
그리고 delegate field로 method를 할당하고 네 번에 걸쳐 Poke method를 호출하는 구문을 아래와 같이 추가합니다.
kdh.Shout = Man_Shout;
kdh.Poke();
kdh.Poke();
kdh.Poke();
kdh.Poke();
위 예제를 실행하면 다음과 같은 결과를 표시할 것입니다. Poke method를 호출하는 처음 두 번은 아무런 반응도 없다가 최소 3번의 Poke가 실행되면 Man_Shout event를 발생시킬 것입니다.
(3) Event 정의하고 실행하기
위 과정을 통해 event의 기능적인 면에서 가장 중요한 delegate를 어떻게 구현할 수 있는지를 알아보았습니다. 이를 통해 code의 완전히 다른 조각으로 구현되어야 하는 method의 특성을 정의하고 해당 method와 그 외 delegate field와 연결된 다른 모든 것들을 호출합니다.
Event는 위에서 알아본 것과는 비교적 알아야 할 내용이 많지는 않습니다.
Delegate field로 method를 할당할 때는 이전 예제에서처럼 단순한 할당 연산자를 통해서 할당하는 것은 피해야 합니다. =연산자 대신 +=연산자를 사용함으로써 더 많은 method를 delegate field에 할당할 수 있습니다. Delegate가 호출되면 비록 순서자체를 제어할 수는 없지만 여기에 할당된 모든 method가 호출될 것입니다.
Shout delegate field가 이미 하나 또는 그 이상의 method를 참조하고 있을 때 =연산자를 통해 새로운 method를 할당하게 되면 다른 모든 것들을 대체하게 됩니다. 따라서 Event를 위해 사용되는 delegate에서는 일반적으로 method의 추가와 삭제를 위해 개발자가 +=이나 -=연산자를 사용하도록 강제되곤 합니다.
Person.cs에서 event keyword를 선언된 delegate field에 아래와 같이 추가합니다.
public event EventHandler? Shout;
이렇게 하면 기존에 Program.cs에서 event를 할당하던 구문에는 다음과 같은 compiler error message가 표시됩니다.
event keyword가 하는 역할의 대부분이 바로 이것입니다. 만약 delegate field에 하나이상의 method가 할당되기를 원하지 않는다면 기술적으로는 event keyword가 필요하지 않지만 delegate field에 event가 사용된다는 것을 대부분 예상할 수 있기 때문에 기본적으로 event keyword를 사용하는 것이 좋습니다.
Program.cs에서는 기본 method할당 문을 +=연산자로 변경합니다.
kdh.Shout += Man_Shout;
예제를 실행하면 이전과 동일한 결과를 표시할 것입니다.
Program.cs에서 기존 Man_Shout method를 복사해 method의 이름과 WriteLine method를 아래와 같이 변경해 줍니다.
static void Man_Shout2(object? sender, EventArgs e)
{
if (sender is null) return;
Person? p = sender as Person;
if (p is null) return;
Console.WriteLine($"Stop it!");
}
그리고 기존 Man_Shout method를 할당한 구문 다음에 위 새로운 method를 할당하는 구문을 추가합니다.
kdh.Shout += Man_Shout;
kdh.Shout += Man_Shout2;
예제를 다시 실행하면 다음과 같은 결과를 표시할 것입니다.
4. Interface의 구현
Interface는 표준적인 기능을 구현하고 새로운 것을 생성하기 위해 다른 type을 연결하는 방법을 제공하는데 LEGO brick에서 서로 같이 붙일 수 있는 Studs on top이나 plug, socket의 전기적 표준과 같은 것으로 생각할 수 있습니다.
Interface에 대한 type을 구현하면 특정한 기능을 구현한다는 약속을 만들게 되는는데 이러한 특징 때문에 종종 interface를 계약으로 설명하기도 합니다.
(1) 공통 interface
아래 표는 type에서 구현할 수 있는 공통 interface를 나타낸 것입니다.
Interface | Method | Description |
IComparable | CompareTo(other) | type이 instance를 정렬하기 위한 비교 method를 정의합니다. |
IComparer | Compare(first, second) | second type이 first type의 instance를 정렬하기 위한 비교 method를 정의합니다. |
IDisposable | Dispose() | finalizer를 기다리는 것보다 더 효휼적으로 비관리 resource를 해제하기 위한 소거 method를 정의합니다. |
IFormattable | ToString(format, culture) | 개체의 값을 문자열로 표현할때 이를 형식화 하기 위한 문화권 인식 method를 정의합니다. |
IFormatter | Serialize(stream, object) Deserialize(stream) |
저장 또는 전송을 위해 개체를 byte stream또는 byte strem에서 개체로 변환하는 method를 정의합니다. |
IFormatProvider | GetFormat(type) | 언어와 지역에 기반해 입력을 형식화 하는 method를 정의합니다. |
(2) 정렬 시 개체의 비교
흔히 구현되는 가장 일반적인 interface 중 하나는 IComparable입니다. 해당 interface는 CompareTo이라는 method하나를 정의하고 있는데 2가지 형태로 사용될 수 있습니다. 하나는 null가능한 개체 type을 필요로 하며 다른 하나는 null가능한 generic type T를 필요로 합니다.
namespace System
{
public interface IComparable
{
int CompareTo(object? obj);
}
public interface IComparable<in T>
{
int CompareTo(T? other);
}
}
예를 들어 string type에서는 string이 비교되기 전에 정렬되어야 한다면 -1을, 그 후에 정렬되어야 한다면 1을, 동등하다면 0을 반환하는 IComparable를 구현하고 있습니다. int type에서는 비교되는 int type보다 더 작으면 -1을, 더 크면 1을, 동등하다면 0을 반환하는 IComparable을 구현합니다.
type이 IComparable interface중 하나를 구현한다면 type의 instance를 포함하는 array와 collection은 정렬될 수 있습니다.
IComparable interface와 Person class의 CompareTo method를 구현하기 전에 Name속성에 null값을 가진 일부를 포함하고 있는 Person instance의 array를 정렬할 때 어떠한 동작이 실행되는지를 확인해 보도록 하겠습니다.
MyApp project의 Program.cs에서 title과 함께 매개변수로 전달된 people collection에서 이름을 출력할 method를 추가합니다.
static void OutputPeopleNames(IEnumerable<Person?> people, string title)
{
Console.WriteLine(title);
foreach (Person? p in people)
{
Console.WriteLine(" {0}", p is null ? "<null> Person" : p.Name ?? "<null> Name");
}
}
그리고 Person instance의 array를 생성하고 console에 item을 출력하기 위해 OutputPeopleNames method를 호출하는 문을 추가합니다. 그리고 array를 정렬한 다음 console로 item을 다시 출력합니다.
Person?[] people =
{
null,
new() { Name = "kim" },
new() { Name = "hong" },
new() { Name = "ahn" },
new() { Name = null },
new() { Name = "lee" }
};
OutputPeopleNames(people, "정렬 전 people");
Array.Sort(people);
OutputPeopleNames(people, "Person의 IComparable implementation을 사용해 정렬한 후");
위 예제를 실행하면 다음과 같은 예외를 발생시키게 됩니다. 예외 message에서는 주어진 문제를 해결하기 위해 type이 IComparable를 구현해야 함을 말하고 있습니다.
Person.cs에서 아래와 같이 IComparable<Person?>을 상속하도록 합니다.
public class Person : IComparable<Person?>
그러면 Visual Studio에서는 추가한 code아래에 붉은색 밑줄을 긋고 필요한 method가 아직 구현되지 않았음을 표시하게 됩니다.
이때 왼쪽 전구모양을 click 하고 'Implement interface'를 click 하면 method의 기본 틀을 자동으로 생성할 것입니다.
public int CompareTo(Person? other)
{
throw new NotImplementedException();
}
method안에서는 NotImplementedException throw부분을 제거하고 null포함해 입력값의 다양성을 처리하는 문을 추가한 뒤 Name field에 대한 CompareTo method를 호출합니다. 이를 통해 string type의 CompareTo구현을 사용하여 결과를 반환하게 될 것입니다.
public int CompareTo(Person? other)
{
int position;
if ((this is not null) && (other is not null))
{
if ((Name is not null) && (other.Name is not null))
{
position = Name.CompareTo(other.Name);
}
else if ((Name is not null) && (other.Name is null))
{
position = -1;
}
else if ((Name is null) && (other.Name is not null))
{
position = 1;
}
else
{
position = 0;
}
}
else if ((this is not null) && (other is null))
{
position = -1;
}
else if ((this is null) && (other is not null))
{
position = 1;
}
else
{
position = 0;
}
return position;
}
예제에서는 Name field를 비교하여 두 Person instance를 비교하도록 선택했습니다. 그러므로 Person instance는 자신의 Name에 의해 alphabet순으로 정렬될 것입니다. 이때 null은 collection의 아래로 정렬됩니다. 따라서 계산된 위치를 반환하기 전에 저장하는 것이 debugging시에 유용할 수 있습니다.
예제를 실행하면 이번에는 Name이 alphabet순으로 정렬됨으로써 제대로 작동한다는 것을 알 수 있습니다.
외부에서 자체적으로 생성한 type에 대해 정렬을 허용하고자 한다면 IComparable interface를 구현함으로써 이를 실현할 수 있습니다.
(3) Separate class를 사용한 개체의 비교
어떤 경우에는 type에 대한 source code에 접근할 수 없고 따라서 IComparable interface를 구현할 수 없는 경우가 있을지도 모릅니다. 하지만 type에 대한 정렬을 수행하는 다른 방법이 존재하는데 약간 다른 interface인 IComparer를 구현하는 separate type을 생성하는 것입니다.
MyApp project에서 Comparer.cs이름의 새로운 class file을 추가하고 두 개의 Person instance를 비교할 IComparer interface를 구현하는 class를 아래와 같이 작성합니다.
public class PersonComparer : IComparer<Person?>
{
public int Compare(Person? x, Person? y)
{
int position;
if ((x is not null) && (y is not null))
{
if ((x.Name is not null) && (y.Name is not null))
{
int result = x.Name.Length.CompareTo(y.Name.Length);
if (result == 0)
{
return x.Name.CompareTo(y.Name);
}
else
{
position = result;
}
}
else if ((x.Name is not null) && (y.Name is null))
{
position = -1;
}
else if ((x.Name is null) && (y.Name is not null))
{
position = 1;
}
else
{
position = 0;
}
}
else if ((x is not null) && (y is null))
{
position = -1;
}
else if ((x is null) && (y is not null))
{
position = 1;
}
else
{
position = 0;
}
return position;
}
}
Program.cs에서는 위 예제의 class를 이용해 array를 정렬하는 문을 이전 예제에서 아래와 같이 변경합니다.
Array.Sort(people, new PersonComparer());
OutputPeopleNames(people, "Person의 IComparer implementation을 사용해 정렬한 후");
예제를 실행하고 Name길이와 alphabet순으로 정렬되는 다음의 결과를 확인합니다.
people에 대한 array의 정렬을 수행할 때 예제에서는 PersonComparer type을 정렬 algorithm으로 대신사용할 것을 명시적으로 요청하였으므로 people은 kim과 같이 짧은 이름이 우선순위로 올라오게 되며 가장 긴 이름의 hong이 후순위로 정렬되는 것을 알 수 있습니다. 만약 2개 또는 그 이상으로 이름의 길이가 동등한 경우 이때는 ahn, kim과 같이 alphabet순의 정렬이 이루어지게 됩니다.
(4) 암시적 그리고 명시적인 interface구현
Interface는 암시적으로 그리고 명시적인 방법으로 구현될 수 있습니다. 암시적인 구현은 간단하며 더 일반적이기도 합니다. 명시적인 구현은 type의 method가 같은 이름과 특성을 가진 여러 version을 가지고 있을 때 사용됩니다.
예를 들어 IGamePlayer와 IKeyHolder는 game에서 패할 수 있고 key에서는 key를 잃을 수 있으므로 둘 다 같은 매개변수를 가진 Lose라는 method를 가질 수 있습니다. type안에서 이 둘의 interface를 구현해야 할 때 단지 하나의 Lose구현만 암시적인 method가 될 수 있습니다. 둘의 interface가 같은 구현을 공유할 수 있다면 그렇게 되겠지만 그렇지 않다면 다른 Lose method는 다르게 구현되어야 하며 명시적으로 호출되어야 합니다.
public interface IGamePlayer
{
void Lose();
}
public interface IKeyHolder
{
void Lose();
}
public class Person : IComparable<Person?>, IGamePlayer, IKeyHolder
{
//암시적인 구현
public void Lose()
{
}
//명시적인 구현
void IGamePlayer.Lose()
{
}
Person p = new();
p.Lose(); // 임시적 호출
((IGamePlayer)p).Lose(); // 명시적 호출
//명시적인 호출의 또 다른 방법
IGamePlayer player = p as IGamePlayer;
player.Lose();
(5) 기본 구현을 통한 interface 정의
C# 8에서 부터 interface를 위해 도입된 기능으로 기본 구현(default implementations)이라는 것이 있습니다.
MyLibrary project에서 IPlayable.cs이름의 file을 추가하고 Play와 Pause라는 2개의 method를 가진 public IPlayable interface를 정의합니다.
public interface IPlayable
{
void Play();
void Pause();
}
MyApp project에서는 DvdPlayer.cs이름의 class file을 추가하고 IPlayable interface를 아래와 같이 구현합니다.
public class DvdPlayer : IPlayable
{
public void Pause()
{
Console.WriteLine("DVD player is pausing.");
}
public void Play()
{
Console.WriteLine("DVD player is playing.");
}
}
위와 같은 상황에서 C# 8 이전에 Stop이라는 새로운 method를 추가해야 한다면 적어도 하나의 type이 본래 interface에서 구현될 수 있으므로 이렇게 하는 것은 불가능합니다. interface의 주요 핵심중 하나는 고정된 계약입니다.
C# 8에서 부터는 release이후에도 기본구현이 추가되면 이를 통해 새로운 member의 추가를 허용하게 되었습니다. C#을 사용하는 일부 개발자들은 사실 이러한 생각을 별로 좋아하지는 않습니다만 변경사항으로 인한 부작용이나 전체 interface를 새로 정의해야 하는 현실적인 이유로서 유용할 수 있으며 Java나 Swift 같은 다른 언어의 경우에는 비슷한 기술을 이미 허용하기도 합니다.
기본 interface 구현은 기본 platform을 일부 근본적으로 바꾸어야 하기 때문에 .NET 5.0 또는 그 상위의 C#에서만 지원하고 있습니다. 그외 .NET Core, ..NET Standard, .NET Framework등에서는 지원하지 않습니다.
IPlayable interface에서 아래와 같이 기본 구현을 통해 Stop method를 추가합니다.
public interface IPlayable
{
void Play();
void Pause();
void Stop() // default interface implementation
{
Console.WriteLine("Default implementation of Stop.");
}
}
그리고 MyApp project를 build 하면 DvdPlayer가 Stop method를 구현하지 않았음에도 불구하고 정상적으로 이루어짐을 확인할 수 있습니다. 일단 이렇게 해놓으면 추후에 DvdPlayer에서 Stop에 대한 기본구현을 재정의함으로써 method를 구현할 수 있게 됩니다.
5. 참조와 값 type에서의 memory관리
지금까지는 간단하게만 참조 type에 대해서 언급해 왔는데 이제 이에 대해 좀 더 구체적으로 들어가 보려고 합니다.
Memory의 범주에는 크게 2가지로 나누어 볼 수 있는데 하나는 stack 다른 하나는 heap입니다. 요즘 운영체제에서 stack과 heap은 물리적 혹은 가상 memory 어느 곳이든 될 수 있습니다.
Stack memory는 CPU에 의해 직접적으로 관리되며 후입-선출 체계를 사용하고 L1과 L2 cache에서 data가 다뤄질 가능성이 많기 때문에 성능면에서 더 빠르게 작동합니다. heap memory가 느리긴 하지만 더 풍부한 용량을 가질 수 있는 반면 stack은 크기가 제한적입니다.
ARM64, x86, x64에서 동작하는 Windows에서는 stack의 기본 size가 1MB이며 전형적인 Linux기반 운영체제에서는 8MB입니다. MAC OS의 terminal에서 ulimit -a 명령을 사용하면 stack의 크기가 8,192KB로 제한되어 있고 다른 memory의 경우에는 'unlimited'임을 알 수 있습니다. Stack memory가 가득 채워지고 'stack overflow'를 일으키기가 쉬운 이유가 바로 이러한 stack memory의 size제한 때문입니다.
(1) 참조와 값 type의 정의
C#에서는 개체의 type을 정의하기 위한 3가지 keyword가 존재합니다. class, record 그리고 struct가 그것입니다. 이 모든 것은 field나 method와 같은 member를 가질 수 있지만 다른 차이점은 이들이 어떻게 memory에 할당되느냐 하는 것입니다.
- record나 class를 통해 type을 정의하면 이것은 참조(reference) type이 됩니다. 즉, 개체 자체는 heap memory에 할당되며 해당 개체에 대한 heap의 주소만 stack에 저장됩니다. 이는 미약하지만 아주 약간의 overhead를 일으킬 수 있습니다.
- record struct 혹은 struct를 사용해 type을 정의하면 이것은 값 type이 됩니다. 특, 개체 자체에 대한 memory가 stack에 그대로 할당됩니다.
Struct에서도 struct type이 아닌 field type을 사용하게 되면 해당 field는 heap에 저장될 것입니다. 따라서 이러한 개체의 data는 stack과 heap에 동시에 존재하게 됩니다.
가장 일반적인 struct type은 아래와 같이 분류할 수 있습니다.
- Number type : byte, sbyte, short, ushort, int, uint, long, ulong, float, double, decimal
- System.Drawing type : Color, Point, PointF, Size, SizeF, Rectangle, RectangleF
- 기타 : char, DateTime, DateOnly, TimeOnly, bool
System.String(string)과 System.Object(object)를 포함에 다른 모든 type은 class입니다.
Type의 data가 저장되는 memory의 장소에 대한 용어적인 차이점과는 별개로 가장 주된 차이점은 struct로부터는 상속이 불가능하다는 것입니다.
(2) 참조와 값 type이 memory에 저장되는 방식
예를 들어 아래와 같이 몇 개의 변수를 선언하는 console app이 있다고 가정했을 때
int number1 = 49;
long number2 = 12;
System.Drawing.Point location = new(x: 4, y: 5);
Person kim = new()
{
Name = "kim hee seoung",
DateOfBirth = new(year: 1982, month: 12, day: 13)
};
Person hong;
여기에서 실제 예제의 문이 실행될 때 어떤 것이 stack과 heap에 할당되는지 검토해 보면 아래와 같은 사항을 확인할 수 있습니다.
- number1 변수는 값 type(또는 struct)이므로 stack에 할당되며 32bit integer이므로 4byte의 memory를 사용하게 됩니다. 값은 49인데 이는 변수에 직접적으로 저장됩니다.
- number2 변수 역시 값 type이므로 stack에 할당되며 64bit integer이므로 8byte의 memory를 사용하게 됩니다.
- location 역시 값 type이며 stack에 할당됩니다. 또한 x와 y라는 2개의 32bit-integer로 이루어져 있으므로 8byte의 memory를 사용합니다.
- kim은 참조 type(class)이며 64bit 운영체제의 경우 64bit memory 주소를 위해 stack에 8byte가 할당됩니다. 그리고 Person의 instance를 저장하기 위해 충분한 공간의 heap이 할당됩니다.
- hong은 참조 type이며 stack에 64bit memory 주소를 저장하기 위해 8byte 공간을 사용하게 됩니다. 다만 현재 시점에서는 null이므로 아직 heap에 memory가 할당되지 않았습니다. 만약 예제에서 kim을 hong에 할당하게 된다면 heap에 대한 Person의 memory 주소가 hong으로 복사될 것입니다.
참조 type에 할당된 모든 memory는 heap에 저장됩니다. 만약 Person과 같은 참조 type에서 field가 DateTime과 같은 값 type이라면 DateTime의 값 역시 heap에 저장됩니다.
값 type이 참조 type인 field를 가진 경우라면 값 type의 값은 heap에 저장됩니다. Point는 2개의 field로 이루어진 값 type이며 2개의 field모두 값 type이기 때문에 전체 개체는 stack에 할당될 수 있습니다. 만약 point 값 type이 string과 같이 참조 type인 field를 가지게 된다면 string byte는 heap에 저장될 수 있습니다.
(3) Type의 동일성
일반적으로 2개의 변수를 비교할 때는 ==이나 !=연산자를 사용하는데 이들 2개의 연산자는 값과 type에서 다르게 동작합니다.
2개의 값 type 변수에 대한 동일성을 확인할때 .NET은 실질적으로 stack에 있는 이들 2개 변수에 대한 값을 비교하고 동일한 값이라면 true를 반환합니다.
Program.cs에서 동일한 값을 가진 2개의 integer변수를 선언하고 이들을 비교하는 문을 아래와 같이 추가합니다.
int a = 3;
int b = 3;
Console.WriteLine($"a: {a}, b: {b}");
Console.WriteLine($"a == b: {(a == b)}");
예제를 실행하면 다음과 같은 결과를 확인할 수 있습니다.
2개의 참조 type 변수에 대한 동일성을 확인할때 .NET은 이들 두 변수에 대한 memory 주소를 비교하고 동일하다면 true를 반환합니다.
Program.cs에서 둘의 Person instance를 선언하고 Name을 동일하게 설정한 뒤 이들 변수를 비교하는 문을 아래와 같이 추가합니다.
Person p1 = new() { Name = "Kim" };
Person p2 = new() { Name = "Kim" };
Console.WriteLine($"p1: {p1}, p2: {p2}");
Console.WriteLine($"p1 == p2: {(p1 == p2)}");
예제를 실행하면 다음과 같은 결과를 확인할 수 있습니다.
위와 같은 결과가 나오게 되는 이유는 개체가 다르기 때문입니다. 만약 이 둘의 변수가 실질적으로 heap상에 같은 개체를 가리키고 있다면 이들은 동일하게 판단될 수 있습니다.
Program.cs에서 3번째 Person 개체를 선언하고 p1을 할당합니다.
Person p3 = p1;
Console.WriteLine($"p3: {p3}");
Console.WriteLine($"p1 == p3: {(p1 == p3)}");
예제를 실행하면 이번에는 다음과 같은 결과를 확인할 수 있습니다.
참조 type의 이러한 동작에 대한 한 가지 예외는 string type입니다. string은 참조 type이지만 동일성을 비교하는 연산자에서는 이들이 값 type인 것처럼 동작하도록 재정의되어 있습니다.
Program.cs에서 두 Person instance의 Name속성을 비교하는 문을 아래와 같이 추가하고
Console.WriteLine($"p1.Name: {p1.Name}, p2.Name: {p2.Name}");
Console.WriteLine($"p1.Name == p2.Name: {(p1.Name == p2.Name)}");
예제를 실행하면 다음과 같은 결과를 확인할 수 있습니다.
물론 우리가 임의로 만든 class에 대해서도 서로가 같은 개체(heap상 같은 memory 주소)가 아니라도 해당 개체의 field가 같은 값을 가진 경우라면 비교 연산자가 true를 반환하도록 만들 수도 있습니다. 하지만 이렇게 하기보다는 record class를 사용하여 동일한 동작을 구현하는 것이 좋습니다.
(4) Struct type 정의
Sturct를 통해서는 자체 값 type을 정의할 수 있습니다.
MyLibrary project에서 DisplacementVector.cs이름의 file을 아래와 같이 추가합니다.
public struct DisplacementVector
{
public int X { get; set; }
public int Y { get; set; }
public DisplacementVector(int initialX, int initialY)
{
X = initialX;
Y = initialY;
}
public static DisplacementVector operator +(DisplacementVector vector1, DisplacementVector vector2)
{
return new(vector1.X + vector2.X, vector1.Y + vector2.Y);
}
}
Program.cs에서는 DisplacementVector에 대한 2개의 새로운 instance를 생성하고 이들에 대해 +연산을 수행한 뒤 그 결과를 출력하도록 합니다.
DisplacementVector dv1 = new(3, 5);
DisplacementVector dv2 = new(-2, 7);
DisplacementVector dv3 = dv1 + dv2;
Console.WriteLine($"({dv1.X}, {dv1.Y}) + ({dv2.X}, {dv2.Y}) = ({dv3.X}, { dv3.Y})");
예제를 실행하면 다음과 같은 결과를 확인할 수 있습니다.
값 type에서는 기본 생성자를 명시적으로 정의하지 않는다 하더라도 stack상에 값이 초기화되어야 하므로 기본값을 통한 기본 생성자는 반드시 가지게 됩니다. 만약 이런 경우라면 DisplacementVector에 대한 2개의 integer field는 0으로 초기화될 것입니다.
이어서 DisplacementVector에 대한 또 다른 instance를 하나 더 생성하고 해당 개체의 속성을 출력하도록 합니다.
DisplacementVector dv4 = new();
Console.WriteLine($"({dv4.X}, {dv4.Y})");
예제를 실행한 결과는 다음과 같습니다.
type에 존재하는 모든 field에서 사용하는 최종 memory가 16byte이거나 혹은 그 보다 더 적고 이들 field가 값 type만을 사용하며 해당 type으로부터 파생되는 것이 필요하지 않다면 Microsoft에서는 struct를 사용할 것을 권장하고 있습니다. type에서 stack memory로 16byte 이상을 사용하고 field가 참조 type이거나 상속이 필요하다면 class를 사용해야 합니다.
(5) Record struct type 정의
C# 10에서는 class type에서 뿐만 아니라 struct type에서도 record keyword를 사용할 수 있게 되었습니다.
따라서 DisplacementVector type은 아래와 같이 정의될 수 있습니다.
public record struct DisplacementVector(int X, int Y);
Record struct는 class의 이점을 가진 struct인 record class와 모든 동일한 이점을 가집니다. record struct와 record class사이에 한 가지 차이점이라면 record struct는 readonly keyword를 적용하지 않는 이상은 불변이 아니라는 것입니다. struct는 ==와 !=연산자를 구현하지 않지만 자동적으로 record struct로 구현됩니다.
이러한 변화로 Microsoft는 record class를 정의하고자 할 때 class keyword가 선택 적라 하더라도 명시적으로 class를 지정해 줄 것을 권장하고 있습니다.
public record class ImmutableAnimal(string Name);
(6) 비관리 resource 해제
이전 예제를 통해 우리는 생성자를 통해 field를 초기화할 수 있고 type은 여러 생성자를 가질 수 있음을 알게 되었습니다. 이때 만약 생성자가 OS의 제어하에 있는 file이나 mutex와 같은 .NET에서 관리되지 않는 비관리 resource를 할당할때 .NET은 자동적인 garbage collection기능을 사용해 비관리 resource를 해제할 수 없으므로 수동적인 해제처리가 필요합니다.
각 type은 resource의 해제가 필요할때 .NET runtime에서 호출될 수 있는 단일 finalizer를 가질 수 있습니다. Finalizer는 type의 이름인 생성자와 동일한 이름을 가지지만 앞에 접두사 ~문자를 가집니다.
public class ObjectWithUnmanagedResources
{
public ObjectWithUnmanagedResources() // 생성자
{
// 비관리 resource 할당
}
~ObjectWithUnmanagedResources() // 종료자 (또는 파괴자)
{
// 비관리 resource 해제
}
}
finalizer(또는 destructor)를 Deconstruct method와 혼동해서는 안됩니다. destructor는 resource를 해제하는 것으로 개체를 memory에서 소거하는 것입니다. 하지만 Deconstruct method는 예를 들어 tuple을 사용할 때처럼 각각의 요소로 분해된 개체를 반환하며 C# deconstruction 문법을 사용합니다. 이에 대한 내용은 아래 글을 참고하시기 바랍니다.
[.NET/C#] - [C# 11 과 .NET 7] 5. OOP (Object-Oriented Programming)
위 예제는 비관리 resource에 대한 처리를 할 수 있는 최소한의 구현이지만 finalizer를 제공함으로써 생길 수 있는 문제는. NET garbage collector가 type에 할당된 resource의 해제를 완료하기 위해 2번의 garbage collection 동작이 필요하게 된다는 것입니다.
선택적이기는 하지만 type을 사용하는 개발자가 명시적으로 resource를 해제할 수 있도록 하는 또 다른 method를 제공하는 것을 권장합니다. 이를 통해 garbage collector가 file과 같은 비관리 resource 중 관리되는 부분을 즉시 해제하도록 할 수 있습니다. 즉, garbage collection이 이중적인 실행대신 단일 garbage collection만으로 개체의 관리 memory 부분을 해제하는 것입니다.
이는 아래와 같이 IDisposable interface를 통해 기본적인 mechanism을 구현할 수 있습니다.
public class ObjectWithUnmanagedResources : IDisposable
{
public ObjectWithUnmanagedResources()
{
}
~ObjectWithUnmanagedResources()
{
Dispose(false);
}
bool disposed = false;
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (disposed) return;
if (disposing)
{
}
disposed = true;
}
}
예제에서는 public과 protected로 2개의 Dispose method가 존재하는데 public void Dispose method는 type을 사용하는 개발자에 의해 호출되는 것이며 비관리와 관리 resource를 할당 해제합니다. bool형식의 매개변수를 가진 protected virtual void Dispose method는 내부적으로 resource의 해제를 구현하기 위해 사용됩니다. 다만 여기서는 finalizer thread가 이미 동작중이고 ~ObjectWithUnmanagedResources method를 호출했다면 비관리 resource만이 할당해제가 필요한 상황이 되므로 disposing 매개변수와 disposed를 확인해야 합니다.
GC.SuppressFinalize(this)를 호출하는 것은 garbage collector에게 finalizer의 동작이 더 이상 필요하지 않음을 알려주기 위한 것이며 두 번째 garbage collection의 필요성을 제거합니다.
(7) Dispose의 호출을 보장하기
다른 개발자가 IDisposable이 구현된 type을 사용할 때는 public Dispose method가 아래와 같은 문을 통해 호출되도록 해야 합니다.
using (ObjectWithUnmanagedResources thing = new())
{
// ...
}
위 문은 compiler가 아래와 같이 예외가 발생하는 경우에도 여전히 Dispose method가 호출되도록 code를 변환합니다.
ObjectWithUnmanagedResources thing = new();
try
{
// ...
}
finally
{
if (thing != null) thing.Dispose();
}
IAsyncDisposable을 구현한 type의 경우에는 아래와 같은 문을 통해 public Dispose method가 호출되도록 할 수 있습니다.
await using (ObjectWithUnmanagedResources thing = new())
{
// ...
}
6. null 사용
지금까지 memory에 저장되는 방식에 따라 참조 type과 값 type이 어떻게 다른지와 struct변수에서 숫자와 같은 원시값이 저장되는 방식에 대해 알아보았습니다. 하지만 아직 변수가 값을 가지지 않았다면? 그런 경우라면 또 그것을 어떻게 표시할 수 있을까? C#은 이런 경우에 대해 null라는 개념을 제공하여 변수가 아직 값을 가지지 않았음을 표현하는 데 사용할 수 있도록 하였습니다.
(1) 값 type을 null가능하게 만들기
기본적으로 int나 DateTime과 같은 값 type은 항상 값을 가져야 합니다. 그런데 null이나 빈 상태를 허용하는 database와 같은 곳에서 값을 읽을 때는 값 type에도 null을 허용하는 것이 편리할 수 있습니다. 우리는 이것을 'null가능한 값 type'이라고 합니다.
그리고 이렇게 null을 허용하는 것은 변수를 선언할 때 type에 접미사로 ?문자를 사용함으로써 가능합니다.
csStudy06 solution에 NullHandling이름의 Console App project를 추가하고 Program.cs에서 기존 구문을 모두 삭제한 뒤 int변수를 선언하고 null을 포함해 값을 할당하는 문을 추가합니다. 이때 하나는 ? 접미사를 사용하고 다른 하나는 사용하지 않습니다.
int thisCannotBeNull = 4;
thisCannotBeNull = null;
Console.WriteLine(thisCannotBeNull);
int? thisCouldBeNull = null;
Console.WriteLine(thisCouldBeNull);
Console.WriteLine(thisCouldBeNull.GetValueOrDefault());
thisCouldBeNull = 7;
Console.WriteLine(thisCouldBeNull);
Console.WriteLine(thisCouldBeNull.GetValueOrDefault());
위와 같이 code를 작성하면 Visual Studio에서는 다음과 같은 compile error를 발생시키게 됩니다.
error가 발생한 부분의 code를 주석처리하고 예제를 실행하면 다음과 같은 결과가 표시될 것입니다.
두 번째 부분이 비어있는 이유는 null값이 표시된 것이기 때문입니다.
이어서 아래와 같이 null을 지정하는 문을 추가합니다.
Nullable<int> thisCouldAlsoBeNull = null;
thisCouldAlsoBeNull = 9;
Console.WriteLine(thisCouldAlsoBeNull);
여기서 Nullable<int>부분을 click 하고 F12 key를 누르거나 mouse 오른쪽 button을 눌러 'Go To Definition'을 선택하면 Nullable<T>유형인 generic 값 type은 값 type이라는 struct인 T type이 있어야 하며 HasValue, Value, GetValueOrDefaut와 같은 유용한 member가 존재함을 알 수 있습니다.
struct뒤에 ?를 붙이게 되면 다른 struct로 변경하는 것입니다.
(2) Null 관련 initial
아래 표는 null과 관련하여 일반적으로 사용되는 initial을 표시한 것입니다.
Initial | 의미 | 설명 |
NRT | Nullable reference types | C# 8과 함께 도입된 compiler기능이며 C# 10의 project에서 기본으로 활성화되어 design time에 code를 정적으로 분석하고 참조 type에 대한 null 값의 오용가능성을 경고표시합니다. |
NRE | NullReferenceException | null 값을 역참조하려할때 runtime에서 발생하는 예외로, null값을 가진 변수나 member에 접근하는 경우를 의미합니다. |
ANE | ArgumentNullException | 매개변수가 null이면서 null을 올바른 값으로 취급할 수 없을때 method가 호출됨으로서 runtime에서 발생하는 예외입니다. |
(3) Null 가능 참조 type
Null 값을 다루는 것은 여러 언어에서 아주 일반적인 것이라 많은 개발자들은 null존재에 대해서 별다른 의문을 가지고 있지는 않습니다. 그러나 변수가 null이 되는 것을 허용하지 않는다면 코드를 단순화할 수 있는 많은 상황이 있을 수 있습니다.
C# 8 compiler의 가장 중요한 변화중 하나는 null가능한 것과 null가능하지 않은 참조 type을 확인하고 경고를 표시한다는 것입니다. 그런데 참조 type은 이미 null이 가능한 type인데 왜 null여부를 확인하는 걸까?
C# 8부터 참조 type은 file 혹은 project 수준에서 option설정을 통해 더 이상 참조 type이 null이 가능하지 않도록 할 수 있습니다. 이것은 C#에서는 큰 변화에 해당하므로 Microsoft는 이 기능을 option으로 결정할 수 있게끔 만들어 두게 됩니다.
해당 C# compiler기능은 해당 기능이 적용되기 이전에 만들어진 것으로 예상되는 수천 가지의 library와 app에 적용되기까지는 몇 년의 시간이 걸릴 수도 있습니다. 심지어 Microsoft조차 .NET 6까지 주요 .NET package에 이 새로운 기능을 구현할 시간이 없었을 정도입니다.
이렇게 변화하는 동안 우리는 우리가 만든 project에 위 기능과 관련된 몇 가지 접근 방식을 선택할 수 있습니다.
- Default : .NET 5나 그 이전에 생성된 project에서는 어떠한 변경도 필요하지 않으며 null가능하지 않은 참조 type은 확인되지 않습니다. .NET 6나 이후에 생성된 project에서 null가능성 확인은 기본으로 사용되지만 csproj project file에서 <Nullable>요소를 삭제하거나 disable로 설정함으로써 해당 기능을 비활성으로 돌릴 수 있습니다.
- Opt-in project, opt-out files : project수준에서 해당 기능을 사용하고자 하는 것이며 예전 방식의 호환성으로 남아 있어야 하는 file들은 제외됩니다. 이것은 Microsoft가 새로운 기능을 사용하기 위해 자체 package가 update 될 때까지 내부적으로 사용하는 접근법입니다.
- Opt-in file: 설정한 각각의 file에서만 기능을 사용하도록 합니다.
(4) null가능성 경고 확인 기능 제어
Project 수준에서 null가능한 경고 확인 기능을 사용하려면 csproj(project file)을 아래와 같이 설정해야 합니다. (반대로 기능을 사용하지 않으려면 disable로 맞춰줍니다.)
<PropertyGroup>
...
<Nullable>enable</Nullable>
</PropertyGroup>
File수준에서 기능을 사용하려면 아래와 같이 설정합니다.(반대로 기능을 사용하지 않으려면 disable로 맞춰줍니다.)
#nullable enable
(5) null일 수 없는 변수와 매개변수 선언
null가능한 참조 type을 사용하고 참조 type에 null값을 할당하고자 한다면 null가능한 값 type을 표시하던 것과 같은 문법인 ?문자를 type선언뒤에 붙여주는 문을 사용할 수 있습니다.
그렇다면 null가능한 참조 type은 어떻게 작동하는지 예제를 통해 알아보도록 하겠습니다. 주소에 대한 정보를 저장할 때 Street, City, Region에 대한 입력을 강제하고자 하지만 Building은 공백이 가능하다는 가정하에 NullHandling project에 서 Address.cs file을 추가하고 field를 아래와 같이 추가합니다.
public class Address
{
public string? Building;
public string Street;
public string City;
public string Region;
}
Visual Studio에서 위와 같이 code를 작성하면 Street field에서 처럼 null가능한 부분에 대해서 다음과 같이 경고를 표시할 것입니다.
이 상태에서 non-nullable인 각 field에 대해 empty string을 아래와 같이 할당합니다.
public class Address
{
public string? Building;
public string Street = string.Empty;
public string City = string.Empty;
public string Region = string.Empty;
}
Program.cs에서는 Address의 instance를 생성하고 각 속성에 아래와 같이 값을 할당합니다.
Address address = new()
{
Building = null,
Street = null,
City = "Daegu",
Region = "KR"
};
이렇게 되면 Building이 아닌 Street의 null설정 부분에서 CS8625에 대한 경고를 보게 됩니다.
이제 예제에서 Street의 설정 부분에 있는 null다음에 아래와 같이 null 무효 연산자인 !를 추가해 줍니다.
Street = null!,
이렇게 하면 경고 message는 사라지게 될 것입니다. 다음으로 Building과 Street에 대한 속성을 역참조하는 문을 아래와 같이 추가합니다.
Console.WriteLine(address.Building.Length);
Console.WriteLine(address.Street.Length);
하지만 이번에는 CS8602용 경고가 Street가 아닌 Building에 표시될 것입니다.
runtime에서는 여전히 Street에 대한 예외가 발생할 수 있지만 compiler는 Building에 대한 잠재적인 예외를 계속 경고할 것입니다. 이때 이를 피하기 위해서는 Length에 곧장 접근하는 대신 반환될 수 있는 null에 대해 null 조건 연산자를 아래와 같이 사용할 수 있습니다.
Console.WriteLine(address.Building?.Length);
예제를 실행하면 Building의 Length에 접근하는 문에서 null값(공백으로 표시)이 출력되지만 Street의 Length의 접근 시에는 runtime 예외가 발생할 것입니다.
이를 통해 우리는 NRT가 단지 compiler에게 문제를 일으킬 수 있는 잠재적인 null값에 대하여 경고를 제공하도록 요청하는 것일 뿐, code에 대한 실질적인 동작을 바꾸는 것은 아니며 compile time에서 code의 정적인 분석을 수행할 뿐이라는 것을 알 수 있습니다.
이제까지의 설명은 null가능한 참조 type에 대한 것이며, 일반적인 참조 type은 non-nullable이 될 수 있고 값 type에서 사용했던 것과 같은 문법을 통해 nullable참조 type을 만들 수 있음을 말씀드리고자 합니다.
참조 type에서의 ? 접미사는 type을 바꾸지 않습니다. type을 Nullable<T>로 바꾸는 값 type에서의 ? 접미사와는 다른 것입니다. 참조 type은 처음부터 null값을 가지며 nullable 참조 type에서 수행하는 모든 것은 compiler에게 null이 될 수 있음을 말하는 것으로 이것만으로 compiler는 개발자에게 경고를 해줄 필요가 없습니다. 하지만 code에서 null확인을 수행해야 하는 필요성을 없애는 것이 아닙니다.
계속해서 NRT를 구현하는 것과 더불어 code의 동작을 바꾸는 null값으로 작업하기 위한 언어기능에 대해 계속 알아보도록 하겠습니다.
(6) null 확인
Null가능한 참조 type인지 null가능한 값 type변수가 현재 null값을 가지고 있는지를 확인하는 것은 그렇게 하지 않으면 NullReferenceException 예외가 error로 발생할 수 있기 때문에 중요합니다. null 변수는 아래와 같이 사용하기 전 null값인지를 확인할 수 있습니다.
if (nullableVariable != null) //null 확인
{
int length = nullableVariable.Length; // 위 조건이 없고 null이라면 이 지점에서 예외발생
...
}
C# 7부터는 '!='를 대체할 수 있도록 ! 연산자를 아래와 같이 결합할 수 있습니다.
if (!(nullableVariable is null))
{
}
C# 9부터는 위에서 보다 더 명확하게 null이 아님을 명시할 수 있는 not이 도입되었습니다.
if (nullableVariable is not null)
만약 값이 null일 수 있는 변수를 사용하려는 경우에 있다면 null 조건부 연산자인 ? 를 아래와 같이 사용할 수 있습니다.
string authorName = null;
int x = authorName.Length;
int? y = authorName?.Length;
때로는 변수를 결과에 할당하거나 변수가 null이면 3처럼 대체값을 사용하려 한다면 ?? null 병합 연산자를 아래와 같이 사용할 수 있습니다.
null가능한 참조 type을 사용한다고 하더라도 여전히 null에 대한 non-nullable 매개변수를 확인하고 ArgumentNullException을 표시할 수 있어야 합니다.
(7) Method의 매개변수에서 null인지를 확인
매개변수와 함께 method를 정의할 때 null값을 확인하는 건 좋은 습관입니다.
C#의 이전 version에서는 if문을 통해 매개변수의 null값을 확인하고 null이면 ArgumentNullException예외를 발생시킬 수 있었습니다.
public void personMethod(Person manager, Person employee)
{
if (manager == null)
{
throw new ArgumentNullException(nameof(manager));
}
if (employee == null)
{
throw new ArgumentNullException(nameof(employee));
}
...
}
C# 10에서는 매개변수가 null인 경우 예외를 발생시킬 수 있는 편리한 method가 도입되어 아래와 같이 사용할 수 있습니다.
public void personMethod(Person manager, Person employee)
{
ArgumentNullException.ThrowIfNull(manager);
ArgumentNullException.ThrowIfNull(employee);
...
}
C# 11 preview에서는 새로운 !!연산자를 도입하여 위와 동일한 동작을 수행시킬 수 있었습니다.
public void personMethod(Person manager!!, Person employee!!)
위 if와 예외를 발생시키는 문은 하위의 다른 문을 실행시키기 이전에 먼저 주입되고 실행되므로 특히 !!연산자는 C# 개발자 community에서 논란이 되기도 했는데 일부는 이러한 연산자를 사용하는 대신 매개변수에 attribute를 사용해 적용하는 걸 선호하기도 했고 .NET team은 이 기능을 통해 .NET library의 전체 code에서 10,000줄 이상을 줄일 수 있었다고 언급하기도 했습니다. 이것만으로 이 기능을 사용하기 충분히 좋다고 생각될 수 있지만 유감스럽게도 team은 이 기능을 최종적으로 삭제하기로 하였습니다. 따라서 !!연산자를 제외하고 if문이나 method를 null확인을 위해 사용해야 합니다.
nullable은 위험성에 대한 경고만 줄 뿐 무엇인가를 강제하는 것이 아님을 기억해야 합니다. null과 관련된 compiler의 경고에 관해서는 아래글을 참고하시기 바랍니다.
https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/ compiler-messages/nullable-warnings.
7. Class로부터의 상속
이전에 생성했던 Person type은 System.Object의 별칭인 object로부터 상속된 것입니다. 이제 Person type으로 상속하는 하위(sub) class를 생성해 봄으로서 상속에 관한 자세한 사항을 알아보도록 하겠습니다.
MyLibrary에서 Employee.cs이름의 새로운 file을 추가하고 아래와 같이 Person으로부터 파생되는 Employee class를 생성합니다.
namespace MyLibrary
{
public class Employee : Person
{
}
}
MyApp project의 Program.cs에서는 아래와 같이 Employee class의 instance를 생성하는 문을 추가합니다.
Employee jun = new()
{
Name = "seong jun",
DateOfBirth = new(year: 1985, month: 12, day: 24)
};
jun.WriteToConsole();
해당 예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
여기서 Employee class는 Person의 모든 member가 상속되었음을 알 수 있습니다.
(1) 기능 추가로 class 확장하기
위에서 만들어진 Employee class는 확장을 위해 Employee고유의 member를 추가할 수 있습니다. Employee.cs에서 아래와 같이 직원번호과 고용된 날짜를 위한 2개의 속성을 정의합니다.
public string? EmployeeCode { get; set; }
public DateTime HireDate { get; set; }
그리고 Program.cs에서 jun의 직원번호(EmployeeCode)와 고용날짜(HireDate)를 설정하고 설정된 내용을 출력하는 문을 추가합니다.
jun.EmployeeCode = "JJ001";
jun.HireDate = new(year: 2014, month: 11, day: 23);
Console.WriteLine($"{jun.Name} was hired on {jun.HireDate:yyyy-MM-dd}");
예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
(2) Member 숨김
위 예제에서 WriteToConsole method는 Person으로부터 상속된 것이며 Employee의 Name과 DateOfBirth를 표시하였습니다. 그런데 이 method를 Employee에 맞게 바꾸려 한다면 아마도 아래와 같이 할 수 있을 것입니다.
public DateTime HireDate { get; set; }
public void WriteToConsole()
{
Console.WriteLine(format:"{0} was born on {1:yyyy-MM-dd} and hired on {2:yyyy-MM-dd}", arg0: Name, arg1: DateOfBirth, arg2: HireDate);
}
해당 예제를 실행하면 다음과 같은 응답을 생성하게 됩니다.
실제 Visual Studio에서는 방금 전 추가한 method에서 Person으로부터의 method가 숨겨진다는 경고를 다음과 같이 표시할 것입니다. app을 build 하고 실행하게 되면 'Error List'창에도 해당 오류를 자세히 표시하게 됩니다.
경고 message에서 설명된 것처럼 method에서 인의적으로 Person의 method를 현재의 method로 바꿀 것임을 나타내는 new keyword를 사용하면 이러한 경고 message를 지울 수 있습니다.
public new void WriteToConsole()
(3) Member 재정의
가능하다면 method를 숨기기보다는 override를 사용하는 편이 더 일반적입니다. 단 override는 기반 class에서 overriding을 허용하는 method에 virtual keyword를 적용했을 때만 사용할 수 있습니다.
Program.cs에서 jun의 변숫값을 console에 문자열로 표시하도록 하는 문을 아래와 같이 작성하고
Console.WriteLine(jun.ToString());
예제를 실행하면 다음과 같은 응답을 표시하게 됩니다.
여기서 ToString method는 System.Object로 부터 상속된 것이므로 type의 namespace와 이름을 표시할 것입니다. 이 상태에서 Person.cs에 person의 name과 type의 name을 표시하도록 아래와 같이 ToString method를 추가합니다. 이렇게 되면 ToString method의 동작을 바꿀 수 있게 됩니다.
public override string ToString()
{
return $"{Name} is a {base.ToString()}";
}
이때 base keyword는 파생 class에서 상속하거나 파생되는 해당 기반 class의 member에 접근할 수 있도록 하는 keyword입니다.
예제를 실행하고 결과를 확인해 보시기 바랍니다. ToString method를 호출할 때 기반 class에서 구현된 ToString의 반환내용과 더불어 person의 name을 함께 표시할 것입니다.
실제 Microsoft의 Entity Framework Core나 Castle의 DynamicProxy, CMS의 content model 최적화 같은 많은 실세 API들은 class에서 정의한 속성이 재정의 될 수 있도록 virtual로 표시되어야 합니다. 이때 어떤 method와 속성 member를 virtual로 할지 신중하게 결정해야 합니다.
(4) 추상(abstract) class로 부터의 상속
아래 글에서는
[.NET/C#] - [C# 11 과 .NET 7] 6. Interface와 Class상속
type이 가져야 할 기본 수준의 기능에 대한 일련의 member를 정의할 수 있는 interface에 대해 다뤄봤습니다. 이것만으로도 꽤 유용하긴 하지만 한 가지 큰 제한사항은 C# 8까지는 자체적으로 구현하는 어떤 것도 제공할 수 없다는 것입니다.
이것은 .NET Framework및 .NET Standard 2.1을 지원하지 않는 다른 platform에서 작동하는 class library를 생성할 때 특히 문제가 됩니다.
이러한 상황에서는 순수 interface와 완전 구현 class의 중간 격인 추상 class를 사용할 수 있습니다.
class가 abstract로 수식되면 해당 class는 완전하지 않음을 나타내기 때문에 instance를 생성할 수 없게 됩니다. 즉, instance가 생성되기 전 더 많은 구현이 필요한 것입니다. 예를 들어 System.IO.Stream class는 추상 class로서 모든 stream에서 필요할 수 있는 공통적인 기능을 구현하고 있지만 완전하지는 않아서 new Stream()으로 instance를 생성할 수 없습니다.
예를 들어 아래와 같은 insterface와
public interface INoImplementation // C# 1.0
{
void Alpha(); // 파생된 type에서 구현되어야 함
}
public interface ISomeImplementation // C# 8부터
{
void Alpha(); // 파생된 type에서 구현되어야 함
void Beta()
{
// 기본 구현으로서 재정의될 수 있음
}
}
추상 class가 존재할 때
public abstract class PartiallyImplemented // C# 1.0
{
public abstract void Gamma(); // 파생된 type에서 구현되어야 함
public virtual void Delta() // 재정의될 수 있음
{
// 구현
}
}
위 interface과 추상 class에 대한 파생 class를 생성하면 아래와 같이 만들어질 수 있습니다.
public class FullyImplemented : PartiallyImplemented, ISomeImplementation
{
public void Alpha()
{
// 구현
}
public override void Gamma()
{
// 구현
}
}
위와 같이 하면 정말 구현된 class에서만 instance를 생성할 수 있고 다른 type에 대해서는 compile오류가 발생할 수 있습니다.
FullyImplemented a = new();
//에러
PartiallyImplemented b = new();
(5) 상속 및 재정의 차단하기
class에 sealed keyword를 사용하면 해당 class로부터 다른 개발자가 상속받는 것을 막을 수 있습니다.
public sealed class MyType
{
}
. NET에서 sealed가 사용된 사례로는 string class를 들 수 있습니다. Microsft는 string class에 몇몇 극한의 최적화를 구현하였고 때문에 이를 상속해 사용할 경우 부정적인 영향을 끼칠 수 있어서 string class로부터는 아예 상속을 구현할 수 없도록 하였습니다.
class에 있는 method도 마찬가지로 sealed keyword를 통해 누군가가 해당 method를 재정의하는 걸 막을 수 있습니다.
public class MyType
{
public virtual void MyMethod()
{
}
}
public class MyType2 : MyType
{
public sealed override void MyMethod()
{
}
}
따라서 위 예제의 경우 아무도 MyType2에 있는 MyMethod를 재정의할 수 없습니다. sealed는 override method에서만 사용할 수 있습니다.
(6) 다형성
상속된 method의 동작을 바꾸는 데는 2가지 방법이 있습니다. 하나는 new keyword(non-polymorphic inheritance)를 사용해 기반 class의 method를 숨기는 것과 override(polymorphic inheritance)를 사용해 기반 class의 method를 재정의하는 것입니다.
이 두 가지 방법 모두에서는 base keyword를 사용해 기반(super) class의 member에 접근할 수 있는데 그렇다면 이 둘 간의 차이점은 무엇일까?
이 것은 모두 개체의 참조를 가진 변수의 유형에 따라 달라집니다. 예를 들어 Person type의 변수는 Person class 혹은 Person으로부터 파생된 모든 type을 가질 수 있습니다.
Employee.cs에서 ToString method를 재정의하여 employee에 대한 name과 code를 console로 출력하는 문을 아래와 같이 추가합니다.
public override string ToString()
{
return $"{Name}'s code is {EmployeeCode}";
}
Program.cs에서는 syAhn이라는 이름의 새로운 employee를 생성하고 Person type변수에 저장합니다. 그리고 이 둘의 변수에서 WriteToConsole과 ToString method를 호출합니다.
Employee ahnInEmployee = new()
{
Name = "syAhn",
EmployeeCode = "12345"
};
Person ahnInPerson = ahnInEmployee;
ahnInEmployee.WriteToConsole();
ahnInPerson.WriteToConsole();
Console.WriteLine(ahnInEmployee.ToString());
Console.WriteLine(ahnInPerson.ToString());
예제를 실행하면 다음의 결과를 표시할 것입니다.
Method가 new로 숨겨진 경우에 compiler는 해당 개체가 Employee의 method를 말하는 것인지를 알지 못하므로 Person에 대한 WriteToConsole method를 호출하게 됩니다.
Method가 virtual과 override를 통해 재정의된 경우에는 compiler는 비록 변수가 Person class로 정의되었어도 개체가 Employee class이므로 Employeee에서 구현된 ToString method를 호출합니다.
Member의 한정자와 이에 대한 영향은 아래 표와 같이 요약할 수 있습니다.
Variable type | Member modifier | Method executed | In class |
Person | WriteToConsole | Person | |
Employee | new | WriteToConsole | Employee |
Person | virtual | ToString | Employee |
Employee | override | ToString | Employee |
개인적인 의견으로 new보다는 virtual과 override가 new보다는 많이 사용되는 듯합니다. 솔직히 다형성을 완전히 이해하지 않아도 virtual과 override정도의 개념만 알고 있으면 그것도 충분합니다.
8. 계층적 상속에서 Casting 하기
Type 간 casting은 type 간 converting과는 미묘하게 다릅니다. Casting은 16-bit integer와 32-bit integer 사이 혹은 sub class와 super class사이와 같이 비슷한 type 간에 이루어지는 것을 말하며 Converting은 문자열-숫자사이와 같이 비슷하지 않은 type간에 이루어지는 것을 말합니다.
(1) 암시적인 casting
이전 예제에서는 파생된 type의 instance를 해당 base type의 변수(또는 base의 base type, 기타 등등)에 저장해 보았습니다. 실제 이러한 동작이 이루어질 때 이것을 암시적 casting이라고 합니다.
(2) 명시적 casting
type명에 괄호를 사용하여 원하는 type으로 cast 하는 방법을 명시적 cast라고 합니다.
Program.cs에서 aliceInPerson변수를 새로운 Employee로 할당하는 문을 아래와 같이 추가합니다.
Employee explicitAhn = ahnInPerson;
이렇게 하면 Visual Studio에서는 다음과 같이 compile error를 표시할 것입니다.
이 상태에서 할당된 변수 앞에 문을 변경하여 Employee type으로 cast 하도록 지정해 줍니다.
Employee explicitAhn = (Employee)ahnInPerson;
(3) Casting 예외 피하기
위의 처리로 compiler는 더 이상 error를 표시하지 않지만 ahnInPerson은 Employee가 아닌 Student처럼 파생된 type이 다를 수 있으므로 주의해야 합니다. 더 복잡한 code를 가진 실제 application에서 해당 변수의 현재 값은 Student의 instance로 설정될 수도 있으며 그러면 위의 예제는 InvalidCastException error를 유발할 것입니다.
● Type을 확인하기 위한 is 사용하기
try 문을 사용해 위의 문제를 해결할 수 있지만 이 보다 더 나은 방법은 is keyword를 사용하여 다음과 같이 if문과 함께 개체의 type을 확인하는 것입니다.
if (ahnInEmployee is Employee)
{
Console.WriteLine($"{nameof(ahnInEmployee)} IS an Employee");
Employee explicitAlice = (Employee)ahnInEmployee;
}
예제를 실행하면 다음과 같은 결과를 표시합니다.
위 상태에서 선언적 pattern을 사용하면 code를 더욱 간소화시키고 명시적인 cast의 수행을 생략할 수 있습니다.
if (ahnInEmployee is Employee explicitAhn)
{
Console.WriteLine($"{nameof(ahnInEmployee)} IS an Employee");
explicitAhn.WriteToConsole();
}
만약 ahnInEmployee가 Employee가 아닌 경우에 block의 문을 실행하고자 한다면 이전에는 ! (not) 연산자를 사용하여 아래와 같이 표현했지만
if (!(ahnInEmployee is Employee))
C# 9 부터는 not keyword를 사용하여 아래와 같이 표현할 수 있습니다.
if (ahnInEmployee is not Employee)
● Type을 cast 하기 위한 as 사용하기
is의 대안으로 cast를 위해 as keyword를 사용할 수도 있습니다. as keyword는 type의 cast가 불가능한 경우 예외를 일으키는 대신 null을 반환한다는 차이가 있습니다.
Program.cs에서 as keyword를 통해 ahn을 cast 하고 반환값이 null인지 아닌지를 확인하는 문을 아래와 같이 추가합니다.
Employee? aliceAsEmployee = ahnInEmployee as Employee;
if (aliceAsEmployee is not null)
{
Console.WriteLine($"{nameof(aliceAsEmployee)} AS an Employee");
}
Null 변수 member로의 접근은 NullReferenceException error를 일으킬 수 있으므로 이것을 사용하기 전 항상 null여부를 확인해야 합니다.
위 예제를 실행하면 다음과 같은 결과를 확인할 수 있습니다.
되도록 이면 is와 as keyword를 사용하여 파생된 type사이에 casting을 시도할 때 발생할 수 있는 예외를 피하는 것이 좋습니다. is와 as를 사용하지 않으면 try~catch문을 통해서라도 InvalidCastException예외를 처리해야 합니다.
9. .NET Type의 상속과 확장
.NET에는 사전에 내장된 수천 가지의 type을 포함하고 있습니다. 따라서 필요한 type을 직접 만들기보다는 필요한 동작에 대한 일부 혹은 전체를 상속받고 해당 기능을 재정의하거나 확장하기 위해 Microsoft의 type 중 하나로부터 파생하는 type을 생성함으로써 좀 더 유리한 출발을 할 수 있습니다.
(1) 예외 상속
상속에 대한 하나의 예로 exception으로부터 새로운 type을 파생해 볼 것입니다.
MyLibrary project에서 PersonException.cs이름의 class file을 추가하고 세 개의 생성자를 가진 PersonException이름의 class를 아래와 같이 정의합니다.
public class PersonException : Exception
{
public PersonException() : base() { }
public PersonException(string message) : base(message) { }
public PersonException(string message, Exception innerException) : base(message, innerException) { }
}
일반적인 method와는 달리, 생성자는 상속되지 않습니다. 그러므로 예제에서는 명시적으로 생성자를 선언하고 명시적으로 System.Exception(또는 이것으로부터 파생시킨 exception class)에 구현된 base 생성자를 호출하여 예제의 PersonException에서 이들 생성자를 사용하고자 하는 개발자가 사용할 수 있도록 하고 있습니다.
Person.cs에서는 DateTime매개변수가 person의 DateOfBirth보다 더 이전의 시간인 경우 예외를 발생시키도록 하는 method를 아래와 같이 정의합니다.
public void TimeTravel(DateTime when)
{
if (when <= DateOfBirth)
{
throw new PersonException("DateTime Exception Error");
}
else
{
Console.WriteLine($"Welcome to {when:yyyy}!");
}
}
Program.cs에서는 jun의 TimTravel method를 호출할 때 아래와 같이 날짜를 지정하여 호출하도록 합니다.
try
{
jun.TimeTravel(when: new(1999, 12, 31));
jun.TimeTravel(when: new(1970, 06, 01));
}
catch (PersonException ex)
{
Console.WriteLine(ex.Message);
}
예제를 실행하면 다음과 같은 결과를 확인할 수 있습니다.
자체적으로 예외 type을 정의할 때는 System.Exception에 내장된 것을 명시적으로 호출하는 동일한 세 개의 생성자를 제공해야 합니다. 아마도 상속받고자 하는 다른 exception은 더 많을 수 있습니다.
(2) 상속이 불가능할 때 type 확장하기
이전에 sealed 한정자를 사용하면 상속을 막을 수 있다는 것을 언급한 바 있습니다.
Microsoft는 sealed keyword를 System.String class에 적용하여 누구도 이를 상속받아 string에 대한 동작을 잠재적으로 바꿀 수 없도록 하였습니다.
그럼에도 불구하고 여전히 string으로 새로운 method를 추가하고자 한다면 C# 3.0에서 도입된 extension method를 사용할 수 있습니다. 하지만 잠깐. 확장 method를 제대로 이해하기 위해서는 우선 static method부터 다시 살펴볼 필요가 있습니다.
● 기능 재사용을 위한 static method 사용
C#의 첫 번째 버전부터 문자열이 email주소를 포함하는지에 대한 유효성검증과 같은 기능의 재사용을 위해 정적(static) method를 사용할 수 있었습니다. 이때 이러한 기능의 구현은 추후에 알아볼 정규표현식을 사용할 수 있을 것입니다.
MyLibrary project에서 StringExtensions.cs 이름의 새로운 class file을 아래와 같이 추가합니다.
public class StringExtensions
{
public static bool IsValidEmail(string input)
{
return Regex.IsMatch(input, @"[a-zA-Z0-9\.-_]+@[a-zA-Z0-9\.-_]+");
}
}
예제의 IsValidEmail method는 static이며 @문자 전후로 사용된 문자들이 유효한지를 확인하는 간단한 email pattern과 일치하는지를 확인하기 위해 Regex type을 사용하고 있습니다.
Program.cs에서는 2가지 email 주소에 대한 유효성을 검증하는 문을 아래와 같이 추가합니다.
string email1 = "aaaa@test.com";
string email2 = "bbbb&test.com";
Console.WriteLine("{0} is a valid e-mail address: {1}", arg0: email1, arg1: StringExtensions.IsValidEmail(email1));
Console.WriteLine("{0} is a valid e-mail address: {1}", arg0: email2, arg1: StringExtensions.IsValidEmail(email2));
예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
예제가 잘 작동하기는 하지만 확장 method를 사용하면 위의 예보다 작성해야 할 code를 훨씬 더 많이 줄일 수 있으며 해당 기능의 용법을 단순화할 수 있습니다.
● 기능 재사용을 위한 extension method 사용
extension method안에서 static method를 생성하는 것만으로 extension method를 쉽게 구현할 수 있습니다.
StringExtensions.cs에서 class앞에 static 한정자를 붙이고 type에도 this한정자를 아래와 같이 붙여줍니다.
public static class StringExtensions
{
public static bool IsValidEmail(this string input)
위와 같이 하면 compiler는 string type의 확장기능으로서 method가 처리됨을 알게 됩니다.
Program.cs에서는 email 주소가 유효한지 확인이 필요한 string 값에 extention method를 아래와 같이 추가합니다.
Console.WriteLine("{0} is a valid e-mail address: {1}", arg0: email1, arg1: email1.IsValidEmail());
Console.WriteLine("{0} is a valid e-mail address: {1}", arg0: email2, arg1: email2.IsValidEmail());
예제를 통해 IsValidEmail method를 호출하기 위한 문법이 약간 단순해졌음을 알 수 있습니다. 그러나 이전에 문법을 그대로 사용하고자 한다면 그것도 여전히 유요 합니다.
IsValidEmail 확장 method는 IsNormalized처럼 string type의 실제 모든 instance method처럼 표시될 것입니다. 다만 Visual Studio에서는 해당 method가 확장 method인지를 나타내기 위한 아래화살표가 method의 icon으로 사용된다는 것만 차이가 있을 뿐입니다.
예제를 실행하면 이전과 동일한 결과를 표시할 것입니다.
확장 method는 기존에 존재하던 instance method를 교체하거나 재정의될 수 없습니다. 예컨대 기존 Insert method를 그대로 확장 method로서 정의할 수 없습니다. 확장 method는 기존 동일한 이름과 특징을 가진 method가 존재하는 경우 IntelliSense에 표시되지는 않지만 해당 instance method를 호출하게 되면 확장 method를 대신 호출하게 됩니다.
여기까지만 보면 확장 method가 그다지 유용해 보이지 않을 수 있지만 LINQ를 통해 data를 다루는 과정에서는 꽤 유용하게 사용될 수 있습니다.
10. Code 개선하기
이제 C#에 대한 기본적인 것을 익히고 나면 이제 더 나은 code를 작성하기 위해 어떻게 해야 하는지를 고민할 필요가 있는데, 이를 위한 몇 가지 방법이 존재합니다.
(1) 경고를 오류로 취급하기
더 나은 code를 작성하기 위한 간단하면서도 효과적인 방법은 스스로가 compiler의 경고를 무시하지 않고 최대한 이를 수정하는 것입니다. 기본적으로 경고는 무시해도 application을 build 하는 데는 문제가 없지만 필요하면 compiler에게 이들에 대한 위험성 있는 경고를 무지하지 못하도록 요청할 수 있습니다.
csStudy06 solution에서 WarningsAsErrors이름의 Console App project를 생성합니다.
그리고 Program.cs에서 사용자에게 이름을 입력하도록 요청하고 입력된 이름으로 인사말을 표시하도록 아래와 같이 기존의 문을 수정합니다.
Console.Write("Enter a name: ");
string name = Console.ReadLine();
Console.WriteLine($"Hello, {name} has {name.Length} characters!");
위의 예제를 build 하면 성공적으로 build가 이루어지긴 하지만 2개의 compiler경고 또한 같이 생성되는 걸 알 수 있습니다.
...\Program.cs(3,15,3,33): warning CS8600: Converting null literal or possible null value to non-nullable type. ...\Program.cs(5,40,5,44): warning CS8602: Dereference of a possibly null reference. |
그런데 build를 다시 수행하면 이번에도 정상적으로 build가 이루어지지만 경고는 사라지는 것을 확인할 수 있습니다.
Visual Studio에서는 Error List를 통해 기존의 경고 message를 계속 확인할 수 있습니다. 또한 경고 message가 사라지는 것은 build를 하는 경우로 rebuild를 하면 계속해서 경고 message를 표시합니다.
Visual Studio의 Build menu나 dotnet 명령줄 도구에서 clean을 사용하여 project를 'clean'할 수 있습니다. 그러면 경고 message는 다음 build시도 때 다시 나타나게 될 것입니다.
이제 csproj(project file)에서 compiler에게 경고를 error로서 취급하도록 아래와 같이 요소를 추가하여 요청하도록 합니다.
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
예제 project를 다시 build 하면 이번에는 build에 실패하고 2개의 error가 표시됨을 알 수 있습니다.
이전에는 build를 다시 시도하면 경고 message도 사라졌지만 이번에 다시 build를 실행하면 여전히 build는 실패하게 됩니다. 따라서 해당 error를 수정하지 않으면 예제 project는 build 할 수 없을 것입니다.
해당 error를 수정하기 위해 ? nullable 연산자를 string 변수를 선언하는 부분에 추가하고 null값을 확인하고 변수가 null이면 app을 빠져나갈 수 있도록 아래와 같이 code를 변경합니다.
string? name = Console.ReadLine();
if (name == null)
{
Console.WriteLine("You did not enter a name.");
return;
}
이 상태에서 예제를 build 하면 error 없이 build가 이루어질 것입니다.
위의 예제와 같은 상황에서 ReadLine method는 실제 null을 반환하지 않습니다. 사용자가 어떠한 값도 입력하지 않으면 null이 아닌 공백을 반환합니다. 이 처럼 절대 null일 수 없는 상황이라면 null-forgiving 연산자를 접미사로 붙여 ReadLine을 호출하면 error를 간단하게 수정할 수 있습니다.
string name = Console.ReadLine()!;
되도록이면 경고를 무시하지 말고 경고를 error로 취급하여 이를 수정하는 것이 좋습니다. ReadLine method가 실제 null을 반환하지 않는다는 것을 compiler가 모르는 issue에 대해서는 개별적으로 해당 경고를 끌 수 있습니다.
(2) Warning wave
새롭게 표시될 수 있는 경고와 error는 C# compiler가 release 될 때마다 추가될 수 있습니다.
기존의 code에서 이렇게 추가된 경고 message가 표시된다면 이들 경고는 warning wave라고 하는 opt-in system에서 도입된 것입니다. Opt-in system은 명시적으로 이들 경고 message를 보겠다는 action을 취하지 않으면 기존의 code에서 새로운 경고 message는 볼 수 없다는 것을 의미합니다.
Warning wave는 csproj(project file)에서 AnalysisLevel요소를 사용함으로써 활성화할 수 있습니다. 예를 들어 .NET 7에서 도입된 warning wave warning을 disable하고자 한다면 AnalysisLevel을 6.0으로 아래와 같이 설정하면 됩니다.
<Nullable>enable</Nullable>
<AnalysisLevel>6.0</AnalysisLevel>
이외에 AnalysisLevel에서 설정가능한 값으로는 다음과 같은 것들이 있습니다.
Level | 설명 |
5.0 | Warning wave를 5 warning까지로만 설정합니다. |
6.0 | Warning wave를 6 warning까지로만 설정합니다. |
7.0 | Warning wave를 7 warning까지로만 설정합니다. |
latest (default) | 모든 version의 warning wave를 사용하도록 합니다. |
preview | Preview wave를 포함한 모든 version의 warning wave를 사용하도록 합니다. |
none | warning을 아예 사용하지 않습니다. |
Compiler에게 warning을 error로서 취급하도록 지정했다면 사용되는 warning wave warning은 error를 생성하게 됩니다.
Warning wave 5 진단도구는 C# 9에서 추가된 것으로 다음과 같은 몇몇 예를 포함하고 있습니다.
CS8073 | 표현식의 결과는 항상 false또는 true여야 한다는 것을 의미합니다. ==와 != 연산자는 struct type s의 instance를 null과 비교할때 항상 false나 true만을 반환합니다. if (s == null) { } // CS8073: The result of the expression is always 'false' if (s != null) { } // CS8073: The result of the expression is always 'true'. |
CS8892 | 동기식 주 진입점인 method가 존재하므로 해당 method가 주요 진입점으로 사용되지 않을것임을 의미합니다. 일반적인 Main method와 async인것이 동시에 존재한다면 일반적인것이 우선적으로 처리되므로 compiler는 async인것은 절대 사용되지 않는다라는 것을 경고하게 되는 것입니다. |
Warning wave 6 진단도구는 C# 10에서 추가되었습니다.
CS8826 | Partial method 선언에 다른 특징이 있음을 의미합니다. |
Warning wave 7 진단도구는 C# 11에서 추가되었습니다.
CS8981 | Type의 이름은 ascii 소문자만을 포함해야 함을 말합니다. C# keyword는 모두 ascii 소문자입니다. 이 경고는 모든 type이 향후 C# keyword와 충돌하지 않도록 합니다. 도구, 예를 들어 gRPC service를 위해 .NET proxy를 생성하는 Google의 design 도구와 같은 곳에서 생성된 일부 source code는 해당 경고를 발동하게 됩니다. |
아래 link를 통해서는 warning wave에 어떤 warning이 추가되었는지를 확인할 수 있습니다.
C# Compiler warning waves | Microsoft Learn
(3) 더 나은 code를 작성하기 위한 분석도구 사용하기
.NET analyzer는 잠재적인 문제점을 찾아 이들에 대한 수정방안을 제시하는데 그중에서 StyleCop은 더 나은 C# code를 작성하기 위해 가장 일반적으로 사용되는 분석도구 중 하나입니다.
csStudy06 solution에서 CodeAnalyzing Conole App project를 추가하는데 이때 'Do not use top-level statements'부분을 check 하고 생성합니다.
Project가 생성되면 csproj(project file)에서 아래의 요소를 추가하여 StyleCop.Analyzers에 대한 참조를 추가하도록 합니다.
<ItemGroup>
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-*">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>
runtime; build; native; contentfiles; analyzers;
buildtransitive
</IncludeAssets>
</PackageReference>
</ItemGroup>
Version에서 '1.2.0-*'을 사용하면 현재 시점으로 가장 최신 version인 1.2.0-beta.435가 적용됩니다. 만약 GA로 release 된다면 그때는 version에서 1.2.0만 지정하여 *를 제거할 수 있습니다.
그런 뒤 project에 stylecop.json이름의 file을 추가하고 아래와 같이 StyleCop을 위한 설정을 추가합니다.
{
"$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json",
"settings": {
}
}
예제에서 $schema부분은 code editor에서 stylecop.json을 수정할 때 IntelliSense를 가능하게 합니다. settings영역 안에 내부 설정 부분으로 이동 후 Ctrl + Space key를 누르면 IntelliSense는 설정가능한 정확한 하위요소를 표시하게 됩니다.
다시 CodeAnalyzing project file로 돌아와 application의 배포 시에는 stylecop.json이 포함되지 않도록 하고 AdditionalFiles로 개발 중에서만 처리될 수 있도록 하는 설정요소를 추가합니다.
<ItemGroup>
<None Remove="stylecop.json" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="stylecop.json" />
</ItemGroup>
Program.cs에서는 console대신 debug output window로 message를 출력하도록 하는 문을 아래와 같이 추가합니다.
static void Main(string[] args)
{
Debug.WriteLine("Hello, World!");
}
이 상태에서 project를 build 하면 잘못된 것으로 판단되는 것에 대한 경고 message가 표시될 것입니다.
위에서 message는 using 지시문은 namespace선언 내부에 들어가 있어야 함을 말해주고 있습니다.
warning SA1200: Using directive should appear within a namespace declaration |
● 경고 message 억제하기
Code를 추가하거나 구성설정을 포함하여 경고 message자체를 표시하지 않게 하기 위한 몇 가지 방법이 존재합니다.
우선 assembly수준의 attribute를 아래와 같이 추가하면 관련된 경고 message를 표시하지 않게 됩니다.
[assembly:SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1200:UsingDirectivesMustBePlacedWithinNamespace", Justification = "Reviewed.")]
#pragma 지시자문을 경고 message를 발생시키는 구문주위에 아래와 같이 적용해도 위와 동일한 효과를 가질 수 있습니다.
#pragma warning disable SA1200 // Using directives should be placed correctly
using System.Diagnostics;
#pragma warning restore SA1200 // Using directives should be placed correctly
또는 stylecop.json file에서 namespace외부에서 using문을 사용할 수 있도록 하는 설정을 추가할 수도 있습니다.
"settings": {
"orderingRules": {
"usingDirectivesPlacement": "outsideNamespace"
}
}
project를 build 하면 warning SA1200 관련된 경고 message는 더 이상 표시되지 않을 것입니다.
참고로 stylecop.json에서 usingDirectivesPlacement의 설정값을 preserve로 바꾸면 using문은 namespace내/외부에서 모두 사용할 수 있도록 설정됩니다.
"orderingRules": {
"usingDirectivesPlacement": "preserve"
}
● Code의 수정
이제 다른 경고에 대한 수정을 위해 csproj project file에서 문서화를 위해 자동적으로 XML file을 생성하는 요소를 아래와 같이 추가합니다.
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
그리고 stylecop.json에서는 company name이나 copyright 문자열등과 같이 문서화에 필요한 값을 제공하기 위한 구성 옵션을 추가합니다.
"orderingRules": {
"usingDirectivesPlacement": "preserve"
},
"documentationRules": {
"companyName": "CLIEL",
"copyrightText": "Copyright (c) CLIEL. All rights reserved."
}
Program.cs에서는 company와 copyright등과 함께 file header에 사용될 comment를 추가하고 using System.Diagnostics;을 namespace안으로 옮겨 둡니다. 또한 class와 method에 명시적인 접근 한정자와 XML comment를 설정합니다.
namespace CodeAnalyzing;
using System.Diagnostics;
/// <summary>
/// Main Class.
/// </summary>
public class Program
{
/// <summary>
/// Main Method.
/// </summary>
/// <param name="args">
/// A string array of arguments.
/// </param>
public static void Main(string[] args)
{
Debug.WriteLine("Hello, World!");
}
}
위 예제를 build한뒤 bin/Debug/net7.0 folder를 보면 자동적으로 생성된 CodeAnalyzing.xml이름의 file을 확인할 수 있습니다.
<?xml version="1.0"?>
<doc>
<assembly>
<name>CodeAnalyzing</name>
</assembly>
<members>
<member name="T:CodeAnalyzing.Program">
<summary>
Main Class.
</summary>
</member>
<member name="M:CodeAnalyzing.Program.Main(System.String[])">
<summary>
Main Method.
</summary>
<param name="args">
A string array of arguments.
</param>
</member>
</members>
</doc>
CodeAnalyzing.xml file은 문서화 file로 변환하기 위한 DocFX와 같은 도구를 사용해 처리될 수 있습니다. 자세한 사항은 아래 link를 참고하시기 바랍니다.
Building .NET project docs with DocFX on GitHub Pages - James Croft
● StyleCop의 일반적인 권장사항들
Code file에서는 아래와 같은 여러 항목을 순서대로 정렬해야 합니다.
- External alias directive
- Using directive
- Namespace
- Delegate
- Enum
- Interface
- Struct
- Classe
Class, record, struct 또는 interface안에서는 다음 항목들을 순서대로 정렬해야 합니다.
- Field
- Constructor
- Destructor (finalizer)
- Delegate
- Event
- Enum
- Interface
- Property
- Indexer
- Method
- Struct
- Nested class와 record
StyleCop에 관한 모든 규칙에 관해서는 아래 link를 참고하시기 바랍니다.
StyleCopAnalyzers/DOCUMENTATION.md at master · DotNetAnalyzers/StyleCopAnalyzers · GitHub
'.NET > C#' 카테고리의 다른 글
[C# 11 과 .NET 7] 8. 공용 .NET Type (0) | 2023.07.16 |
---|---|
[C# 11 과 .NET 7] 7. .NET Packaging과 배포 (0) | 2023.07.07 |
[C# 11 과 .NET 7] 5. OOP (Object-Oriented Programming) (0) | 2023.06.16 |
[C# 11 과 .NET 7] 4. Debuging과 Testing (0) | 2023.06.02 |
[C# 11 과 .NET 7] 3. 흐름제어, Type 변환, 예외 처리 (0) | 2023.05.20 |