16.1 #nullable 지시자와 nullable 참조 형식
(닷넷 7부터 #nullable enable이 기본설정이다. 그러나 C# 8.0 기준으로 공부하고 있기에
해당 파트에서는 학습을 위해 #nullable 기본값이 disable로 가정하자.)
C# 8.0 nullable 신규문법은 System.NullReferenceException 예외가 없도록 컴파일러가 가능성을 경고해서 개발자가 미리 방지할 수 있도록 도와주는 기능을 갖는다.
var miguel = new Person();
int len = GetLengthOfName(miguel);
Console.WriteLine(len);
int GetLengthOfName(Person person) => person.Name.Length;
public class Person
{
public string Name { get; set; }
public Person() { }
public Person(string name)
{
Name = name;
}
}
#출력
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
at Program.<<Main>$>g__GetLengthOfName|0_0(Person person) in C:\Users\asd57\source\repos\ConsoleApp6\ConsoleApp6\Program.cs:line 6
at Program.<Main>$(String[] args) in C: \Users\asd57\source\repos\ConsoleApp6\ConsoleApp6\Program.cs:line 3
컴파일 타임에 에러를 막아보자.
참조 타입의 사용 유형을 크게 다음의 두가지로 나눠 접근할 수 있다.
1. 해당 인스턴스가 null일 필요가 없는 참조 형식.
이를 가리켜 '널 가능하지 않은 참조 타입(Non-nullable reference type)'이라 한다.
2. 해당 인스턴스가 null일 수 있는 참조 형식.
이를 가리켜 '널 가능 참조 타입(Nullable reference type)'이라 한다.
상기에 대한 C# 컴파일러의 대응은 다음과 같이 나눌 수 있다.
1. 첫번째 경우, C# 컴파일러는 참조 타입을 정의할 때 null 값을 담는 멤버가 없도록 보장
2. 두번째 경우, C# 컴파일러는 참조 타입의 인스턴스를 사용할 때 반드시 null 체크를 하도록 보장
16.1.1 null일 수 없음을 보장
C# 8.0 에서는 #nullable 지시자를 이용해 해당 소스코드 파일에 null 값일 수 있는 타입이 정의되지 않도록 보장한다.
표16.1 #nullable 지시자
| #nullable [옵션] | 설명 |
| enable | null 가능성이 있는 경우 경고 발생 |
| disable | null 가능성 체크하지 않음 |
| restore | 이전에 #nullable enable 또는 #nullable disable을 선언했어도 restore 이후의 코드에 대해 프로젝트 수준에서 설정된 null 가능성 여부 옵션 값을 적용.(16.1.3 '널가능 문맥 제어' 에서 다룸) |
이전 소스코드에서 #nullable enable 지시자를 추가해보자.
#nullable enable
var miguel = new Person();
int len = GetLengthOfName(miguel);
Console.WriteLine(len);
int GetLengthOfName(Person person) => person.Name.Length;
public class Person
{
public string Name { get; set; }
public Person() { } // 컴파일 경고 CS8618
public Person(string name)
{
Name = name;
}
}
컴파일 경고에 따라
1. 기본 생성자 삭제
2. 기본 생성자 코드에 Name 필드를 (null이 아닌 값으로)초기화하는 코드를 추가
둘 중 하나를 선택해 코드를 변경하면 Name 필드가 null이 되지 않으므로 이후 GetLengthOfName 메서드 호출 시 null 참조 예외가 발생하지 않는다.
#nullable enable
var miguel = new Person();
int len = GetLengthOfName(miguel);
Console.WriteLine(len);
int GetLengthOfName(Person person) => person.Name.Length;
public class Person
{
public string Name { get; set; } = ""; //기본 생성자 대신 값 초기화
public Person() { } // 컴파일 경고 CS8618
public Person(string name)
{
Name = name;
}
public void Method()
{
Name = null; //개발자의 실수라도 null로 만드는 것을 방지하기 위한 컴파일 경고 발생. CS8625
}
}
warning CS8625: Null 리터럴을 null을 허용하지 않는 참조 형식으로 변환할 수 없습니다라는 경고 발생.
(닷넷 7부터 #nullable enable이 기본이기에 #nullable enable를 코드 상단에 쓸 필요는 없음)
16.1.2 null일 수 있다면 해당 인스턴스를 null 가능한 타입이라고 명시
필드 값이 null을 허용해야하는 경우도 있다. C# 컴파일러는 이럴 때 해당 인스턴스가 null일 수 있음을 알리는 '널 가능한 참조 타입(nullable reference type)'을 정의하는 방법을 제공한다.
참조_타입?
#설명: 참조_타입에 물음표(?)를 붙여 인스턴스가 null일 수 있음을 명시한다.
#nullable enable
var miguel = new Person();
int len = GetLengthOfName(miguel);
Console.WriteLine(len);
int GetLengthOfName(Person person) => person.Name.Length;
public class Person
{
//참조 형식의 Name 필드가 null일 수 있음을 명시하기 위해 '?' 추가.
public string? Name { get; set; }
public Person() { } // null일 수 있으므로 허용
public Person(string name)
{
Name = name;
}
public void Method()
{
Name = null; // null일 수 있으므로 허용
}
}

C# 2.0부터 추가된 7.6절 'nullable' 형식의 기호를 재사용하지만 이번에는 반드시 참조형식에 적용된다는 차이점이 있다.
널 가능 참조타입 선언 시 컴파일러는 해당 멤버가 사용된 코드를 검사해 널 가능성이 있다면, 컴파일 경고(에러x)를 발생시킨다(CS8602: null 가능 참조에 대한 역참조입니다.)
int GetLengthOfName(Person person) => person.Name.Length; // Name 멤버가 null일 수 있으므로 컴파일 경고
해당 경고를 없애려면 null일 수 있는 인스턴스에 대해 반드시 null 체크 코드를 추가해야 한다.
결과적으로 개발자로 하여금 null 참조 예외를 막을 수 있도록 C# 컴파일러가 도움을 제공한다.
#nullable enable
var miguel = new Person();
int len = GetLengthOfName(miguel);
Console.WriteLine(len);
int GetLengthOfName(Person person)
{
if (person.Name is null) return 0;
return person.Name.Length; // null인 상황이 없어졌기에 경고없이 컴파일.
}
public class Person
{
//참조 형식의 Name 필드가 null일 수 있음을 명시하기 위해 '?' 추가.
public string? Name { get; set; }
public Person() { } // null일 수 있으므로 허용
public Person(string name)
{
Name = name;
}
public void Method()
{
Name = null; // null일 수 있으므로 허용
}
}
person.Name.Length; 에 대해 null인 상황이 없어졌기에 경고없이 컴파일을 한다.
C# 컴파일러의 이러한 null 체크 코드는 특성을 통해 다양한 확장이 가능하다.
개발자가 명시적으로 null 체크를 위한 메서드를 만들었다면, 이 또한 C# 컴파일러에 의해 null 체크에 해당하는 코드라고 인식하도록 NotNullWhen 특성을 적용할 수 있다.
#nullable enable
using System.Diagnostics.CodeAnalysis;
var miguel = new Person();
int len = GetLengthOfName(miguel);
Console.WriteLine(len);
int GetLengthOfName(Person person)
{
//IsNull메서드는 인자가 null인 경우 true를 반환하는 것을 C# 컴파일러가 인지
//따라서 null 체크를 한 것에 해당하므로 컴파일 경고는 없음.
if (IsNull(person.Name))
{
return 0;
}
return person.Name.Length;
}
bool IsNull([NotNullWhen(false)] string? value)
{
if (value is null) return true;
return false;
}
public class Person
{
//참조 형식의 Name 필드가 null일 수 있음을 명시하기 위해 '?' 추가.
public string? Name { get; set; }
public Person() { } // null일 수 있으므로 허용
public Person(string name)
{
Name = name;
}
public void Method()
{
Name = null; // null일 수 있으므로 허용
}
}
bool IsNull([NotNullWhen(false)] string? value)
{
if (value is null) return true;
return false;
}
IsNull 메서드가 if조건문 (if(person.Name is null )과 유사한 null 체크 역할을 수행한 것으로 가정하고 컴파일 경고를 발생시키지 않는다.
상기와 같이 null체크 코드를 추가하기 보단, null 자체인 경우를 받아들여(예외가 발생할 테지만) 부가적인 코드를 사용하지 않을 수 있다. #nullable enable로 인한 경고를 무시하도록 'null 포기 연산자(null-forgiving operator)'를 다음과 같이 사용하면 된다.
int GetLengthOfName(Person person)
{
return person.Name!.Length; // null 경고를 무시하도록 "!."로 접근
}
*C# 6.0 11.4절의 'null 조건 연산자' 가 "?."를 사용한 것과 구문이 유사함.
[정리]
#nullable의 활성화(enable)에 따라 C# 컴파일러는 모든 '참조 형식'을 다음 두가지 형식 중 하나로 취급한다.
- '널 가능하지 않은 참조 타입(Non-nullable reference type)' : 기본적으로 모든 참조 타입을 이것으로 취급하며 해당 타입의 인스턴스에는 null 초기화 및 null 대입을 할 수 없다.
- '널 가능한 참조 타입(Nullable reference type)': 예외적으로 null일 수 있는 인스턴스가 필요하다면 물음표(?)를 붙여 지정한다. 그렇게 되면 null을 대입할 수는 있으나 사용하기 전에 null 체크를 해야하거나 명시적으로 null 접근임을 알고 있다는 표시로 null 포기 연산자(!.)를 사용한다.
두가지 조치에 따라 null 참조 예외를 컴파일 단계에서 개발자가 의도치 않은 경우에 한해 예방 가능하다.
NotNullWhen 이외에도 특정 특성들이 제공되므로 상황에 따라 null 체크 경고를 없애기 위한 보조 용도로 사용 가능하다.
(P 791 ~P795 참고)
16.2 비동기 스트림
*해당 비동기 스트림은 System.IO.Stream 비동기 버전이 아닌, IEnumerable/IEnumerator의 비동기 버전이다.
동기 버전의 IEnumerable/IEnumerator 사용 예
using System.Collections;
class Program
{
static async Task Main(string[] args)
{
ObjectSequence seq = new ObjectSequence(10);
foreach(object obj in seq)
{
Console.WriteLine(obj);
}
}
}
class ObjectSequence : IEnumerable
{
int _count = 0;
public ObjectSequence(int count)
{
_count = count;
}
public IEnumerator GetEnumerator()
{
return new ObjectSequenceEnumerator(_count);
}
}
internal class ObjectSequenceEnumerator : IEnumerator
{
int _i = 0;
private int _count;
public ObjectSequenceEnumerator(int count)
{
_count = count;
}
public object Current
{
get
{
Thread.Sleep(100); // 이것을 Thread.Sleep가 아닌, 대략 100ms가
return _i++; //소요되는 느린 작업이라 가정.
}
}
public bool MoveNext() => _i >= _count ? false : true;
public void Reset() { }
}
#출력
0
1
2
3
4
5
6
7
8
9
상기 코드의 문제는 호출하는 메서드(Main) 측에서는 비동기(async)를 지원하는데 정작 내부의 코드에서 foreach 문을 사용하면 해당 열거에 한해 동기적으로 스레드를 점유함으로써 비동기 처리가 무색해진다.
이러한 문제를 C#8.0 비동기 스트림 지원으로 해결 가능하다.
비동기 스트림으로 전환하기 위해 우선 c# 2.0부터 지원하는 yield 구문을 이용해 다음과 같이 단순화한다.
using System.Collections;
class Program
{
static async Task Main(string[] args)
{
foreach(int value in GenerateSequence(10))
{
Console.WriteLine($"{value} tid: {Thread.CurrentThread.ManagedThreadId}");
}
Console.WriteLine($"Completed (tid: {Thread.CurrentThread.ManagedThreadId})");
}
private static IEnumerable<int> GenerateSequence(int cnt)
{
for(int idx = 0; idx < cnt; ++idx)
{
Thread.Sleep(100);
yield return idx;
}
}
}
class ObjectSequence : IEnumerable
{
int _count = 0;
public ObjectSequence(int count)
{
_count = count;
}
public IEnumerator GetEnumerator()
{
return new ObjectSequenceEnumerator(_count);
}
}
internal class ObjectSequenceEnumerator : IEnumerator
{
int _i = 0;
private int _count;
public ObjectSequenceEnumerator(int count)
{
_count = count;
}
public object Current
{
get
{
Thread.Sleep(100); // 이것을 Thread.Sleep가 아닌, 대략 100ms가
return _i++; //소요되는 느린 작업이라 가정.
}
}
public bool MoveNext() => _i >= _count ? false : true;
public void Reset() { }
}
#출력
0 tid: 1
1 tid: 1
2 tid: 1
3 tid: 1
4 tid: 1
5 tid: 1
6 tid: 1
7 tid: 1
8 tid: 1
9 tid: 1
Completed(tid: 1)
이 상태에서 C# 8.0의 비동기 스트림을 적용하기 위해 3군데의 코드를 변경한다.
using System.Collections;
class Program
{
static async Task Main(string[] args)
{
// 1) foreach에 await를 적용하고
await foreach(int value in GenerateSequence(10))
{
Console.WriteLine($"{value} tid: {Thread.CurrentThread.ManagedThreadId}");
}
Console.WriteLine($"Completed (tid: {Thread.CurrentThread.ManagedThreadId})");
}
// 2) IEnumerable을 async로 바꾸고
private static async IAsyncEnumerable<int> GenerateSequence(int cnt)
{
for(int idx = 0; idx < cnt; ++idx)
{
// 3) 작업을 Task로 변경 후 await 호출
await Task.Run(() => Thread.Sleep(100));
yield return idx;
}
}
}
class ObjectSequence : IEnumerable
{
int _count = 0;
public ObjectSequence(int count)
{
_count = count;
}
public IEnumerator GetEnumerator()
{
return new ObjectSequenceEnumerator(_count);
}
}
internal class ObjectSequenceEnumerator : IEnumerator
{
int _i = 0;
private int _count;
public ObjectSequenceEnumerator(int count)
{
_count = count;
}
public object Current
{
get
{
Thread.Sleep(100); // 이것을 Thread.Sleep가 아닌, 대략 100ms가
return _i++; //소요되는 느린 작업이라 가정.
}
}
public bool MoveNext() => _i >= _count ? false : true;
public void Reset() { }
}
#출력
0 tid: 6
1 tid: 6
2 tid: 6
3 tid: 7
4 tid: 7
5 tid: 8
6 tid: 8
7 tid: 8
8 tid: 8
9 tid: 8
Completed(tid: 8)
실행할 때 마다 출력값 달라질 수 있음.
foreach문 내에서 출력결과 스레드가 달라지는 것을 확인할 수 있다.
await foreach를 사용했는데 기존 방식과 동일하게 while문을 이용하는 것도 가능하다.
//#define ForeachEx
#define WhileEx
using System.Collections;
class Program
{
static async Task Main(string[] args)
{
#if ForeachEx
// 1) foreach에 await를 적용하고
await foreach (int value in GenerateSequence(10))
{
Console.WriteLine($"{value} tid: {Thread.CurrentThread.ManagedThreadId}");
}
Console.WriteLine($"Completed (tid: {Thread.CurrentThread.ManagedThreadId})");
#endif
#if WhileEx
var enumerator = GenerateSequence(10).GetAsyncEnumerator();
try
{
while (await enumerator.MoveNextAsync())
{
int item = enumerator.Current;
Console.WriteLine($"{item} tid: {Thread.CurrentThread.ManagedThreadId}");
}
Console.WriteLine($"Completed (tid: {Thread.CurrentThread.ManagedThreadId})");
}
finally
{
await enumerator.DisposeAsync();
}
#endif
}
// 2) IEnumerable을 async로 바꾸고
private static async IAsyncEnumerable<int> GenerateSequence(int cnt)
{
for (int idx = 0; idx < cnt; ++idx)
{
// 3) 작업을 Task로 변경 후 await 호출
await Task.Run(() => Thread.Sleep(100));
yield return idx;
}
}
}
class ObjectSequence : IEnumerable
{
int _count = 0;
public ObjectSequence(int count)
{
_count = count;
}
public IEnumerator GetEnumerator()
{
return new ObjectSequenceEnumerator(_count);
}
}
internal class ObjectSequenceEnumerator : IEnumerator
{
int _i = 0;
private int _count;
public ObjectSequenceEnumerator(int count)
{
_count = count;
}
public object Current
{
get
{
Thread.Sleep(100); // 이것을 Thread.Sleep가 아닌, 대략 100ms가
return _i++; //소요되는 느린 작업이라 가정.
}
}
public bool MoveNext() => _i >= _count ? false : true;
public void Reset() { }
}
#출력 결과
0 tid: 6
1 tid: 7
2 tid: 7
3 tid: 7
4 tid: 7
5 tid: 7
6 tid: 7
7 tid: 7
8 tid: 6
9 tid: 6
Completed(tid: 6)
threadId가 바뀌는 이유가 뭘까? 비동기 작업 async await과 동시에 task를 통해 task에 작업을 맡기도 해당 비동기 메서드를 호출하는 메서드쪽으로 가서 threadid를 출력하는데, 출력하는 곳이 동일 메서드에 있으니 스레드 아이디는 동일하게 출력되야 하는게 아닌가?
-> await 이후에는 호출자와 같은 스레드에서 실행된다는 보장이 없다. 특히 Task.Run과 같은 ThreadPool 기반 작업을 사용하면 await 이후의 실행 흐름은 다른 스레드에서 계속될 수 있다.
private static async IAsyncEnumerable<int> GenerateSequence(int cnt)
{
for (int idx = 0; idx < cnt; ++idx)
{
// 3) 작업을 Task로 변경 후 await 호출
Thread.Sleep(100);
yield return idx;
}
}
상기처럼 task 문법을 제거하면 스레드 아이디는 동일하게 출력된다.
await Task.Run(() => Thread.Sleep(100));
- Task.Run은 ThreadPool의 새로운 스레드에서 작업을 수행하게 한다.
- 이 작업이 끝나고 나서 await 키워드는 이후 작업을 "동기화 컨텍스트"에 따라 계속 진행하게 만든다.
Task.Run은 ThreadPool의 새로운 스레드에서 작업을 수행하게 한다. 이 작업이 끝나고 나서 await 키워드는 이후 작업을 "동기화 컨텍스트"에 따라 계속 진행하게 만든다.
질문) 이 부분에서 Task을 통해 새로운 스레드가 비동기 작업을 진행하고 현재 스레드는 await이후의 작업을 진행하는거가 아닌가? 해석이 잘못됐다.
await Task.Run(() => Thread.Sleep(100));
Console.WriteLine("이후 작업");
1️⃣ Task.Run(...) 호출
- Task.Run(...)은 ThreadPool의 새로운 스레드에서 Thread.Sleep(100)을 실행함
- 동시에, 현재 스레드는 기다리지 않고 await에 도달함
2️⃣ await의 역할
- await는 비동기 작업(Task)의 완료를 기다리는 시점에서 현재 메서드를 "중단"하고, 제어를 호출자에게 반환함.
- 즉, Main 메서드는 여기서 더 진행하지 않고, 일시 중지됨.
정리하면:
await에 도달하면 이후 코드는 절대 즉시 실행되지 않는다.
해당 작업(Task)이 완료될 때까지 기다렸다가, 그 시점에 다시 이어서 실행된다
- 3️⃣ Task.Run(...) 완료 → 이어서 실행
- Thread.Sleep(100)이 끝나면, Task가 완료됨
- await이 기다리던 작업이 끝났으므로, 이제 중단된 메서드(예: Main)가 재개됨
- 이때 실행되는 "이후 작업"은 원래 스레드에서 이어지는 게 아님
→ Console 앱에서는 SynchronizationContext가 없으므로 ThreadPool의 스레드 중 하나에서 이어짐
[핵심질문] await는 비동기 작업(Task)의 완료를 기다리는 시점에서 현재 메서드를 "중단"하고, 제어를 호출자에게 반환함. 여기서 제어를 호출자에게 반환하니깐, 호출했던 스레드 아이디를 출력해야하는게 아닌가?
[답변]
Console 앱에서는 SynchronizationContext 가 없기에 await 이후 코드가 호출자 스레드에서 실행되지 않을 수 있다.\
await의 실행 흐름은 "SynchronizationContext"에 따라 다름
- await는 비동기 작업이 완료된 후에 어느 스레드에서 이어서 실행할지를 결정할 때,
- SynchronizationContext.Current 또는 TaskScheduler.Current를 사용해 판단함.
Console 앱은 특별한 SynchronizationContext가 없음
- WPF, WinForms 앱: SynchronizationContext가 존재해서, await 이후 원래 UI 스레드로 복귀함
- Console 앱: SynchronizationContext.Current == null
- 따라서 await 이후엔 ThreadPool 스레드 중 하나에서 이어짐
- 원래 호출자(예: Main) 스레드와 다를 수 있음
static async Task Main(string[] args)
{
Console.WriteLine($"[Main 시작] tid: {Thread.CurrentThread.ManagedThreadId}");
await Task.Run(() =>
{
Console.WriteLine($"[Task.Run 내부] tid: {Thread.CurrentThread.ManagedThreadId}");
Thread.Sleep(100);
});
Console.WriteLine($"[await 이후] tid: {Thread.CurrentThread.ManagedThreadId}");
}
[Main 시작] tid: 1
[Task.Run 내부] tid: 5
[await 이후] tid: 6 ← 원래 tid:1이 아님
await가 Task 완료 후 "다시 어디서 실행할지" 결정할 때,
→ SynchronizationContext가 없으면 그냥 ThreadPool 스레드 아무거나 잡아서 실행함
"await 이후의 출력이 왜 호출자 스레드와 다르냐?"
🟡 Console 앱에서는 호출자 스레드로 돌아가지 않기 때문이다.
🟢 WPF나 WinForms처럼 SynchronizationContext가 있는 환경에서는 동일 스레드로 돌아감
비동기 스트림을 위해 추가된 C# 8.0의 예약어는 'await foreach' 하나 뿐이다. 나머지들은 기존 async/await 규칙에 따라 변경한것에 불과함.
16.3 새로운 연산자 - 인덱스, 범위
표 16.5 C# 8.0의 신규 연산자
| 연산자 | 문법 | 의미 | 닷넷 타입 |
| ^ | ^n | 인덱스 연산자로서 뒤에서부터 n 번째 위치를 지정한다. (주의할 점은 일반적인 배열 인덱스가 0부터 시작하는 것과는 달리 인덱스 연산자는 마지막 위치를 1로 지정한다.) |
System.Index |
| .. | n1..n2 | 범위 연산자로서 시작 위치 n1은 포함하고 끝 위치 n2는 포함하지 않는 범위를 지정한다. 수학의 구간 기호로 표현하면 [n1, n2)와 같다. n1 값이 생략되면 기본 값 0 n2 값이 생략되면 기본 값 ^0 |
System.Range |
인덱스 연산자의 경우 끝에서 n번째 떨어진 위치를 지정할 수 있다.
class Program
{
static void Main(string[] args)
{
string txt = "this";
Console.WriteLine(txt[^1]); // 출력 s
Console.WriteLine(txt[^2]); // 출력 i
Console.WriteLine(txt[^3]); // 출력 h
int i = 4;
System.Index firstword = ^i;
Console.WriteLine(txt[firstword]); // 출력 : t
}
}
기호 '^'를 사용하면 끝에서부터 지정하게 되지만, System.Index 생성자를 직접 사용하면 시작 위치부터 지정하는 것이 가능하다.
class Program
{
static void Main(string[] args)
{
string txt = "this";
System.Index firstWord = new Index(0, false); // 두 번째 인자의 의미: fromEnd
Console.WriteLine(txt[firstWord]); // 출력 t
System.Index endWord = new Index(1, true); // 두 번째 인자의 의미: fromStart
Console.WriteLine(txt[endWord]); // 출력 s
}
}
특이하게 마지막 요소의 뒤를 지정하는 '^0'도 있다. 물론 상기 예제 코드에서 'txt[^0]'을 지정하면 인덱스 범위가 벗어나므로 예외가 발생하지만, 이 위치가 범위 연산자와 함께 사용하면 의미가 있다.
class Program
{
static void Main(string[] args)
{
string txt = "this";
System.Range full = 0..^0; // == Range.All()
string copy = txt[full];
Console.WriteLine(copy); // 출력 this
}
}
범위 연산자는 시작 위치로 지정된 0번째 인덱스는 포함하면서, 끝 위치로 지정된 ^0은 포함하지 않으므로 위의 full 변수는 결국 전체 구간을 나타내는 것과 같다.
시작과 끝의 값이 생략되면 각각의 기본값이 적용되어 0과 ^0이 지정된 것과 같다.
class Program
{
static void Main(string[] args)
{
string txt = "this";
string copy = txt[..]; // 기본값 범위 == 0..^0
Console.WriteLine(copy); // 출력 this
Console.WriteLine(txt[..2]); // 출력 th
Console.WriteLine(txt[1..]); // 출력 his
}
}
#출력
this
th
his
새로운 인덱스, 범위 연산자를 활용하면 기존의 Length 속성에 접근할 때 혼란스러울 수 있었던 코드를 간결하게 다듬을 수 있다.
class Program
{
static void Main()
{
string txt = "(this)";
PrintText(txt); // 출력 this
}
static void PrintText(string txt)
{
if (txt.Length >= 2 && txt[0] == '(' && txt[txt.Length - 1] == ')')
{
txt = txt.Substring(1, txt.Length - 2);
}
Console.WriteLine(txt);
}
}
상기 코드를 아래와 같이 바꿀 수 있다.
class Program
{
static void Main()
{
string txt = "(this)";
PrintText(txt); // 출력 this
}
static void PrintText(string txt)
{
if (txt.Length >= 2 && txt[0] == '(' && txt[^1] == ')')
{
txt = txt[1..^1];
}
Console.WriteLine(txt);
}
}
16.4 간결해진 using 선언
IDisposable 인터페이스를 구현한 타입의 Dispose 메서드를 finally 구문에서 호출하도록 변경해 주는 using 문은 사용하기에는 편리하나, 블록이 추가되어 들여쓰기 구간이 발생한다.
이러한 들여쓰기를 원치 않는다면 C# 8.0의 개선된 using 구문을 선택해보자.
기존 using 문 처리
class Program
{
static void Main(string[] args)
{
using(var file = new System.IO.StreamReader("test.txt"))
{
string txt = file.ReadToEnd();
Console.WriteLine(txt);
}
}
}
C# 8.0 using 문 처리
class Program
{
static void Main(string[] args)
{
using var file = new System.IO.StreamReader("test.txt");
string txt = file.ReadToEnd();
Console.WriteLine(txt);
}
}
[using 블록 기준]
- 시작 블록은 using 예약어가 사용된 곳을 기준으로 하리라는 것을 직관적으로 이해할 수 있다.
- 끝 볼륵은 using에 사용된 변수 선언을 기준으로 가장 가까운 바깥 블록이 된다.
따라서 상기 예제는 Main 메서드의 블록이 using 변수 선언을 담고 있으므로 메서드의 끝 부분에서 Dispose가 호출된다.
예제.
class Program
{
static void Main(string[] args)
{
if(args.Length == 0)
{
using var file = new System.IO.StreamReader("test.txt");
string txt = file.ReadToEnd();
Console.WriteLine(txt);
}
}
}
이번에는 using var 감싸는 블록이 if문이므로 다음과 같이 using문을 사용한 것과 동일하게 처리된다.
class Program
{
static void Main(string[] args)
{
if(args.Length == 0)
{
using(var file = new System.IO.StreamReader("test.txt"))
{
string txt = file.ReadToEnd();
Console.WriteLine(txt);
}
}
}
}
16.5 Dispose 호출이 가능한 ref struct
14.4 절 '스택에만 생성할 수 있는 값 타입 지원 - ref struct' 에서 C# 7.2에 추가됐던 ref struct의 특성으로 인해 인터페이스를 구현할 수 없고 이로 인해 using문에 사용할 수 없다고 설명하였다.
따라서 다음과 같은 ref struct 타입의 경우,
using System.Runtime.InteropServices;
class Program
{
static void Main(string[] args)
{
UnmanagedVector v1 = new UnmanagedVector(500.0f, 600.0f);
Console.WriteLine(v1.X);
Console.WriteLine(v1.Y);
v1.Dispose();
}
}
ref struct UnmanagedVector
{
IntPtr _alloc;
public UnmanagedVector(float x, float y)
{
_alloc = Marshal.AllocCoTaskMem(sizeof(float) * 2);
this.X = x;
this.Y = y;
}
public unsafe float X
{
get
{
return *((float*)_alloc.ToPointer());
}
set
{
*((float*)_alloc.ToPointer()) = value;
}
}
public unsafe float Y
{
get
{
return *((float*)_alloc.ToPointer() + 1);
}
set
{
*((float*)_alloc.ToPointer() + 1) = value; // +1을 한 이유는? --> 메모리 위치때문
}
}
public void Dispose()
{
if( _alloc == IntPtr.Zero)
{
return;
}
Marshal.FreeCoTaskMem( _alloc );
_alloc = IntPtr.Zero;
}
}
+1을 하는 이유는 매우 중요하고, 메모리 레이아웃과 관련되어 있어. 간단하게 말해 Y를 X 다음 위치에서 읽고 쓰기 위해서이다.
구조 해석
UnmanagedVector는 float 2개를 unmanaged 힙 영역에 직접 할당해서 사용하는 구조다:
_alloc = Marshal.AllocCoTaskMem(sizeof(float) * 2);
즉, 메모리 블록이 이렇게 구성이 된다.
주소: [ _alloc ][_alloc + 4 bytes]
값: X 값 Y 값
- sizeof(float)는 4바이트
- 따라서 float* p = (float*)_alloc.ToPointer(); 하면 p[0]이 X, p[1]이 Y가 된다
- *((float*)_alloc.ToPointer() + 1) 은 바로 Y 위치를 가리키는 포인터 산술
*((float*)_alloc.ToPointer()) // X 값 (첫 번째 float)
*((float*)_alloc.ToPointer() + 1) // Y 값 (두 번째 float)
+1은 float 단위로 1칸 이동하는 의미 (즉, 4바이트만큼 이동)
값이 제대로 나오는 이유
- Marshal.AllocCoTaskMem(sizeof(float) * 2)를 통해 메모리 8바이트 확보
- X, Y 모두 해당 영역을 정확히 나눠서 쓰고 있기 때문
Dispose가 필요한 작업인데도 IDisposable을 구현할 수 없어 using문에 사용하는 것이 불가능하다. 이러한 문제를 해결하고자
C# 8.0에서는 ref struct 타입에 한해서만 public void Dispose() 메서드를 포함한 경우 using문에서 사용할 수 있도록 허용한다.
using System.Runtime.InteropServices;
class Program
{
static void Main(string[] args)
{
using (UnmanagedVector v1 = new UnmanagedVector(500.0f, 600.0f))
{
Console.WriteLine(v1.X);
Console.WriteLine(v1.Y);
}
}
}
ref struct UnmanagedVector
{
IntPtr _alloc;
public UnmanagedVector(float x, float y)
{
_alloc = Marshal.AllocCoTaskMem(sizeof(float) * 2);
this.X = x;
this.Y = y;
}
public unsafe float X
{
get
{
return *((float*)_alloc.ToPointer());
}
set
{
*((float*)_alloc.ToPointer()) = value;
}
}
public unsafe float Y
{
get
{
return *((float*)_alloc.ToPointer() + 1);
}
set
{
*((float*)_alloc.ToPointer() + 1) = value; // +1을 한 이유는? --> 메모리 위치때문
}
}
public void Dispose()
{
if( _alloc == IntPtr.Zero)
{
return;
}
Marshal.FreeCoTaskMem( _alloc );
_alloc = IntPtr.Zero;
}
}
16.6 정적 로컬 함수
12.6절 '로컬함수'는 static함수를 구현할 수 없었다. 그러나 C# 8.0부터 허용이된다.
기존의 로컬함수가 가진 특징은 기본적으로 그것을 포함한 메서드의 지역 변수나 매개변수를 그대로 가져다 사용할 수 있었다는 점이다.
즉, C#에서는 로컬 함수 내부에서 외부 스코프와 같은 이름의 지역 변수를 선언하는 것이 가능하다. 다만, 그렇게 하면 외부 변수는 로컬 함수 내부에서 "숨겨진다"(shadowing).
class Program
{
static void Main(string[] args)
{
Program pg = new();
pg.WriteLog("test");
}
private void WriteLog(string txt)
{
int length = txt.Length;
WriteConsole();
void WriteConsole()
{
//WriteConsole 함수 안에서 int length = 5;를 선언하면, 바깥의 length는 이 지역 스코프 안에서 무시된다.
// ✅ 정상적으로 컴파일됨 (다만 외부 length를 숨김)
//int length = 5;
// 로컬 함수에서 외부 변수(txt, length)에 자유롭게 접근 가능
Console.WriteLine($"# of chars('{txt}'): {length}");
}
}
}
반면, 이를 '정적 로컬 함수'로 정의하게 되면 내부에서 사용할 외부 변수를 명시적으로 인자를 통해 받는 것으로 처리해야 한다.
class Program
{
static void Main(string[] args)
{
Program pg = new();
pg.WriteLog("test");
}
private void WriteLog(string txt)
{
int length = txt.Length;
WriteConsole(txt, length);
static void WriteConsole(string txt, int length)
{
Console.WriteLine($"# of chars('{txt}'): {length}");
}
}
}
16.7 패턴 매칭 개선
C# 7.0에서 패턴매칭이 처음 도입되었으며 7.1에서는 제네릭 인스턴스에 대한 지원을 추가했다.
c#8.0에서는 switch식, 속성 패턴, 튜플 패턴, 위치 패턴과 함께 재귀 패턴까지 지원한다. 이를 통해 장황한 표현들을 간결하게 바꿀 수 있게 됐다.
16.7.1 switch 식
(인스턴스) switch
{
패턴_매칭_식1 => 식1,
패턴_매칭_식2 => 식2,
패턴_매칭_식n => 식n,
_ => 식,
};
설명: 실행 시 결정되는 인스턴스의 값과 패턴_매칭_식 결괏값이 일치하는 경우 해당 식을 실행한다.
나열된 패턴_매칭_식에 일치하는 값이 없다면, '_'에 지정한 식을 실행한다.
('switch 문'이 아닌 'switch 식' 이라는 점을 기억하자)
따라서, 기존에도 이미 switch 문으로 수행할 수 있었던 작업을 식으로 변환함으로써 문(statement)이 허용되지 않는 코드에도 사용 가능하다.
public static bool Event(int n)
{
switch(n)
{
case int i when (i % 2) == 0: return true;
default: return false;
}
}
상기 switch 문을 switch 식으로 재작성해보자
public static bool Event(int n)
{
return n switch
{
var x when (x % 2) == 1 => false,
_ => true
};
}
식이기에 11.2절 '표현식을 이용한 메서드, 속성 및 인덱서 정의'에 따라 다음과 같이 더 축약 가능하다.
public static bool Even(int n) =>
n switch
{
var x when (x % 2) == 1 => false,
_ => true
};
#또는
public static bool Even(int n) =>
(n % 2) switch
{
n1 => false,
_ => true
};
16.7.2 속성 패턴
기존에는 패턴 대상이 되는 인스턴스의 값을 비교하고자 when 조건을 추가했다.
다음은 기존 방식을 사용해 Point 인스턴스의 값 중에서 0이 있는지 확인하는 메서드를 구현한다.
class Point
{
public int X;
public int Y;
public override string ToString() => $"({X}, {Y})";
}
class Program
{
static void Main(string[] args)
{
// 12.10.2 'switch/case 문의 패턴 매칭'에서 소개한 F# 예제의 C# 버전
Func<Point, bool> detectZeroOR = (pt) =>
{
switch (pt)
{
case var pt1 when pt1.X == 0:
case var pt2 when pt2.Y == 0:
return true;
}
return false;
};
Point pt = new Point { X = 10, Y = 20 };
Console.WriteLine(detectZeroOR(pt));
}
}
#출력
false
상기 코드를 C# 8.0의 속성 패턴을 이용하면 다음과 같이 간결하게 바꿀 수 있다.
class Point
{
public int X;
public int Y;
public override string ToString() => $"({X}, {Y})";
}
class Program
{
static void Main(string[] args)
{
// 12.10.2 'switch/case 문의 패턴 매칭'에서 소개한 F# 예제의 C# 버전
Func<Point, bool> detectZeroOR = (pt) =>
{
switch (pt)
{
case { X: 0, Y: 2 }:
case { X: 1 }:
case { Y: 3 }:
return true;
}
return false;
};
/* 또는,
* Func<Point, bool> detectZeroOR = (pt) =>
* pt switch
* {
* // {X: 0, Y: 0} => true,
* {X: 0} => true,
* {Y: 0} => true,
* _ => false,
* };
*
*/
Point pt = new Point { X = 10, Y = 20 };
Console.WriteLine(detectZeroOR(pt));
}
}
switch 문의 when 조건이 is 연산자의 패턴 매칭에서는 허용되지 않았다. 반면, 속성 패턴은 is 연산자도에도 적용된다.
이로인해 is 연산자에서 불가능했던 패턴 매칭이 가능해진다.
class Point
{
public int X;
public int Y;
public override string ToString() => $"({X}, {Y})";
}
class Program
{
static void Main(string[] args)
{
Point pt = new Point { X = 500, Y = 20 };
// is 연산자에서 when 조건을 지원하지 않으므로 컴파일 에러 발생
//if( pt is Point when pt.X == 500)
//{
// Console.WriteLine(pt);
//}
// 속성 패턴을 이용하면 다음과 같이 구현 가능
if(pt is { X: 500})
{
Console.WriteLine(pt.X + " == 500");
}
if(pt is { X : 50, Y : 0 })
{
Console.WriteLine(pt.X + " == 50 ");
}
}
}
16.7.3 튜플 패턴
튜플 패턴: switch, is, switch expression 등의 구문에서 여러 개의 값을 동시에 조건 검사할 수 있게 해주는 패턴 매칭 기능이다.
Func<(int, int), bool> detectZeroOR = (arg) =>
arg switch
{
{ Item1: 0 } => true,
{ Item2: 1 } => true,
_ => false
};
bool result = detectZeroOR((0, 2)); // true
Console.WriteLine(result);
C# 8.0에서 상기 코드를 더욱 간편하게 만드는 패턴 매칭을 지원하여 다음과 같이 간결하게 바꿀 수 있다.
Func<(int, int), bool> detectZeroOR = (arg) =>
arg switch
{
//(0, 0) => true,
(0, _) => true,
(_, 0) => true,
_ => false
};
bool result = detectZeroOR((0, 2)); // true
Console.WriteLine(result);
Func<(int, int), bool> detectZeroOR = (arg) =>
arg switch
{
// (var X, var Y) when (X == 0 && Y == 0) || X == 0 || Y == 0 => true,
(var X, var Y) when X == 0 || Y == 0 =>true,
_ => false,
};
bool result = detectZeroOR((0, 2)); // true
Console.WriteLine(result);
튜플패턴 역시 when 조건을 제외하고는 속성 패턴과 마찬가지로 is 연산자에서 사용 가능하다.
Func<(int, int), bool> detectZeroOR = (arg) =>
/*(arg is (0, 0) || */ arg is (0, _) || arg is (_, 0);
bool result = detectZeroOR((2, 2)); // true
Console.WriteLine(result);
#출력
False
16.7.4 위치 패턴
속성 패턴과 튜플패턴 중 튜플 패턴이 더욱 편리하다. 튜플이 아닌 타입도 원하는 속성으로 튜플을 임시로 구성하면 튜플 패턴으로 쉽게 구현 가능하다.
using System.Drawing;
Func<Point, bool> detectZeroOR = (pt) =>
(pt.X, pt.Y) switch
{
// (0, 0) => true,
(0, _) => true,
(_, 0) => true,
_ => false
};
bool rt = detectZeroOR(new Point(10, 2));
Console.WriteLine(rt);
그러나 위치 패턴을 사용하면 상기와 같이 즉석에서 튜플을 생성하지 않고 직접 다룰 수 있다.
이를 위한 선행 작업은 C# 7.0에 추가된 'Deconstruct 메서드' (12.4절 참고)를 구현하는 것이다.
Func<Point, bool> detectZeroOR = (pt) =>
pt switch
{
// (0, 0) => true,
(0, _) => true,
(_, 0) => true,
_ => false,
};
var rt = detectZeroOR(new Point(3, 0));
Console.WriteLine(rt);
class Point
{
public int X;
public int Y;
public Point(int x, int y)
{
X = x;
Y = y;
}
public override string ToString() => $"({X}, {Y})";
public void Deconstruct(out int x, out int y) => (x, y) = (X, Y);
}
Deconstruct 접근 시점
(0, _) 같은 패턴은 튜플이 아니라 객체(pt)를 "분해"해서 비교하려는 시도이다.
이때 C#은 컴파일 타임에 pt 객체에 대해 다음과 같이 처리:
- pt 타입이 클래스 또는 구조체인지 확인
- pt에 Deconstruct(out T1, ..., out Tn) 메서드가 있는지 확인
- 있다면 컴파일러가 자동으로 Deconstruct 호출을 삽입해서 내부 필드를 비교
▶ 즉, 위치 패턴이 컴파일될 때 자동으로 Deconstruct를 찾고 사용함
위치패턴도 is 연산자에서 사용 가능
using System;
class Program
{
static void Main()
{
//Func<Point, bool> detectZeroOR = (pt) =>
// pt is (0, _) || pt is (_, 0); // 위치 패턴을 활용한 패턴 매칭
Func<Point, bool> detectZeroOR = (pt) =>
pt is (0, 0); // 위치 패턴을 활용한 패턴 매칭
// pt is (0, 0) : 두 인자 모두 0, 0 이어야 True
// pt is (0, _) || pt is (_, 0); 둘 중 하나 0이면 True
var rt = detectZeroOR(new Point(0, 0));
Console.WriteLine(rt); // false
}
}
class Point
{
public int X;
public int Y;
public Point(int x, int y)
{
X = x;
Y = y;
}
public override string ToString() => $"({X}, {Y})";
public void Deconstruct(out int x, out int y) => (x, y) = (X, Y);
}
16.7.5 재귀 패턴
사용자 정의 타입을 포함한 경우 속성/튜플/위치 패턴 매칭에 대해 재귀적으로 구성하는것이 가능하다.
readonly struct Vector
{
readonly public int X;
readonly public int Y;
public Vector(int x, int y)
{
X = x; Y = y;
}
}
struct Matrix2x2
{
public Vector V1;
public Vector V2;
}
enum MatrixType
{
Any, Zero, Identity, Row1Zero,
}
상기와 같은 타입을 구성한 경우 Matrix2x2 타입을 패턴매칭으로 값을 비교하려면 다음과 같은 복잡한 코드가 나온다.
static MatrixType GetMatrixType(Matrix2x2 mat)
{
switch (mat)
{
case Matrix2x2 m when m.V1.X == 0
&& m.V1.Y == 0
&& m.V2.X == 0
&& m.V2.Y == 0: return MatrixType.Zero;
case Matrix2x2 m when m.V1.X == 0 && m.V1.Y == 0:
return MatrixType.Row1Zero;
default: return MatrixType.Any;
}
}
readonly struct Vector
{
readonly public int X;
readonly public int Y;
public Vector(int x, int y)
{
X = x; Y = y;
}
}
struct Matrix2x2
{
public Vector V1;
public Vector V2;
}
enum MatrixType
{
Any, Zero, Identity, Row1Zero,
}
그러나 속성 패턴을 재귀적으로 적용하면 다음과 같이 바꿀 수 있다.
C#에서 "속성 패턴을 재귀적으로 적용한다"는 말은 다음과 같이 객체의 속성 안에 또 다른 속성이 있을 때, 그 속성들까지 계속해서 안으로 들어가며 패턴 매칭하는 걸 의미한다.
static MatrixType GetMatrixType(Matrix2x2 mat)
{
switch (mat)
{
case { V1: { X: 0, Y: 0 }, V2: { X: 0, Y: 0 } }:
return MatrixType.Zero;
case { V1: { X: 0, Y: 0 }, V2: _ }:
return MatrixType.Row1Zero;
default: return MatrixType.Any;
}
}
readonly struct Vector
{
readonly public int X;
readonly public int Y;
public Vector(int x, int y)
{
X = x; Y = y;
}
}
struct Matrix2x2
{
public Vector V1;
public Vector V2;
}
enum MatrixType
{
Any, Zero, Identity, Row1Zero,
}
각 타입이 Deconstruct를 구현하고 있다면..
using System.Security.Cryptography.X509Certificates;
static MatrixType GetMatrixType(Matrix2x2 mat)
{
switch (mat)
{
case ((0, 0), (0, 0)):
return MatrixType.Zero;
case ((1, 0), (0, 1)):
return MatrixType.Identity;
case ((0, 0), _):
return MatrixType.Row1Zero;
default: return MatrixType.Any;
}
}
// 또는
static MatrixType GetMatrixType2(Matrix2x2 mat) =>
mat switch
{
((0, 0), (0, 0)) => MatrixType.Zero,
((1, 0), (0, 1)) => MatrixType.Identity,
((0, 0), _) => MatrixType.Row1Zero,
_ => MatrixType.Any
};
readonly struct Vector
{
readonly public int X;
readonly public int Y;
public Vector(int x, int y)
{
X = x; Y = y;
}
public void Deconstruct(out int x, out int y) => (x, y) = (X, Y);
}
struct Matrix2x2
{
public Vector V1;
public Vector V2;
public void Deconstruct(out Vector v1, out Vector v2) => (v1, v2) = (V1, V2);
}
enum MatrixType
{
Any, Zero, Identity, Row1Zero,
}
재귀 패턴 역시 is 연산자에 동일하게 적용 가능하다.
Matrix2x2 mat = new Matrix2x2 { V1 = new Vector(0, 0), V2 = new Vector(0, 0) };
if( mat is ((0, 0), (0, 0)))
{
Console.WriteLine("Zero");
}
readonly struct Vector
{
readonly public int X;
readonly public int Y;
public Vector(int x, int y)
{
X = x; Y = y;
}
public void Deconstruct(out int x, out int y) => (x, y) = (X, Y);
}
struct Matrix2x2
{
public Vector V1;
public Vector V2;
public void Deconstruct(out Vector v1, out Vector v2) => (v1, v2) = (V1, V2);
}
16.8 기본 인터페이스 메서드
c# 8.0 부터 인터페이스의 메서드에 구현 코드 추가가 가능하다.
public interface ILog
{
void Log(string txt) => WriteConsole(txt);
void WriteConsole(string txt)
{
Console.WriteLine(txt);
}
void WriteFile(string txt)
{
File.WriteAllText(LogFilePath, txt);
}
string LogFilePath
{
get => Path.Combine(DefaultPath, DefaultFileName);
}
static string DefaultPath = @"C:\temp";
static string DefaultFileName = "app.log";
}
프로퍼티, 인덱서, 이벤트의 get/set도 모두 내부적으로 메서드의 구현이므로 당연히 인터페이스에 정의 가능하고, 또한 정적 멤버의 경우 메서드와 필드까지 포함할 수 있다.
이렇게 구현을 포함한 메서드는 그것의 인터페이스를 구현한 하위 클래스 측에서는 구현하지 않아도 된다. (abstract된 화 느낌이네)
//상속한 ILog는 모든 메서드를 구현하고 있으므로
using System.Numerics;
public interface ILog
{
void Log(string txt) => WriteConsole(txt);
void WriteConsole(string txt)
{
Console.WriteLine(txt);
}
void WriteFile(string txt)
{
File.WriteAllText(LogFilePath, txt);
}
string LogFilePath
{
get => Path.Combine(DefaultPath, DefaultFileName);
}
static string DefaultPath = @"C:\temp";
static string DefaultFileName = "app.log";
}
//상속한 ILog는 모든 메서드를 구현하고 있기에 해당 클래스에 ILog의 메서드를 구현할 필요는 없다.
public class ConsoleLogger : ILog
{
}
// ILog는 인터페이스이므로 일부 메서드를 재정의(오버라이딩)하는 것도 가능
public class FileLogger : ILog
{
string _filePath;
public string LogFilePath
{
get => _filePath;
}
public FileLogger(string filePath)
{
_filePath = filePath;
}
public void Log(string txt)
{
(this as ILog).WriteFile(txt);
}
}
유의 사항은 인터페이스의 멤버이기 때문에 상속받은 클래스에서 기본 인터페이스 메서드를 구현하지 않았다면, 그 메서드는 반드시 인터페이스로 형 변환해 호출해야만 한다.
//상속한 ILog는 모든 메서드를 구현하고 있으므로
using System.Numerics;
ConsoleLogger x = new();
//x.Log("test"); //Log 메서드가 정의에 포함되지 않았다는 컴파일에러
//ConsoleLogger 클래스는 Log 메서드를 구현하지 않았기에
//ILog 인터페이스로 형 변환해 호출
(x as ILog).Log("test");
public interface ILog
{
void Log(string txt) => WriteConsole(txt);
void WriteConsole(string txt)
{
Console.WriteLine(txt);
}
void WriteFile(string txt)
{
File.WriteAllText(LogFilePath, txt);
}
string LogFilePath
{
get => Path.Combine(DefaultPath, DefaultFileName);
}
static string DefaultPath = @"C:\temp";
static string DefaultFileName = "app.log";
}
//상속한 ILog는 모든 메서드를 구현하고 있기에 해당 클래스에 ILog의 메서드를 구현할 필요는 없다.
public class ConsoleLogger : ILog
{
}
// ILog는 인터페이스이므로 일부 메서드를 재정의(오버라이딩)하는 것도 가능
public class FileLogger : ILog
{
string _filePath;
public string LogFilePath
{
get => _filePath;
}
public FileLogger(string filePath)
{
_filePath = filePath;
}
public void Log(string txt)
{
(this as ILog).WriteFile(txt);
}
}
ConsoleLogger x = new();
//x.Log("test"); //Log 메서드가 정의에 포함되지 않았다는 컴파일에러
//ConsoleLogger 클래스는 Log 메서드를 구현하지 않았기에
//ILog 인터페이스로 형 변환해 호출
(x as ILog).Log("test");
이 과정이 번거로우면 인터페이스 타입의 변수로 선언해 사용 가능하다.
ILog x = new ConsoleLogger();
x.Log("test");
전체 코드
//상속한 ILog는 모든 메서드를 구현하고 있으므로
using System.Numerics;
ILog x = new ConsoleLogger();
x.Log("test");
public interface ILog
{
void Log(string txt) => WriteConsole(txt);
void WriteConsole(string txt)
{
Console.WriteLine(txt);
}
void WriteFile(string txt)
{
File.WriteAllText(LogFilePath, txt);
}
string LogFilePath
{
get => Path.Combine(DefaultPath, DefaultFileName);
}
static string DefaultPath = @"C:\temp";
static string DefaultFileName = "app.log";
}
//상속한 ILog는 모든 메서드를 구현하고 있기에 해당 클래스에 ILog의 메서드를 구현할 필요는 없다.
public class ConsoleLogger : ILog
{
}
// ILog는 인터페이스이므로 일부 메서드를 재정의(오버라이딩)하는 것도 가능
public class FileLogger : ILog
{
string _filePath;
public string LogFilePath
{
get => _filePath;
}
public FileLogger(string filePath)
{
_filePath = filePath;
}
public void Log(string txt)
{
(this as ILog).WriteFile(txt);
}
}
인터페이스를 구현한 클래스라면 직접 그 메서드를 호출 할 수 있다.
###################################################################################
var x = new FileLogger(@"c:\tmp\my.log");
// FileLogger 클래스는 Log메서드를 구현했음
x.Log("test");
###################################################################################
#전체 코드
//상속한 ILog는 모든 메서드를 구현하고 있으므로
using System.Numerics;
var x = new FileLogger(@"c:\tmp\my.log");
// FileLogger 클래스는 Log메서드를 구현했음
x.Log("test");
public interface ILog
{
void Log(string txt) => WriteConsole(txt);
void WriteConsole(string txt)
{
Console.WriteLine(txt);
}
void WriteFile(string txt)
{
File.WriteAllText(LogFilePath, txt);
}
string LogFilePath
{
get => Path.Combine(DefaultPath, DefaultFileName);
}
static string DefaultPath = @"C:\temp";
static string DefaultFileName = "app.log";
}
//상속한 ILog는 모든 메서드를 구현하고 있기에 해당 클래스에 ILog의 메서드를 구현할 필요는 없다.
public class ConsoleLogger : ILog
{
}
// ILog는 인터페이스이므로 일부 메서드를 재정의(오버라이딩)하는 것도 가능
public class FileLogger : ILog
{
string _filePath;
public string LogFilePath
{
get => _filePath;
}
public FileLogger(string filePath)
{
_filePath = filePath;
}
public void Log(string txt)
{
(this as ILog).WriteFile(txt);
}
}
이러한 호출 방식은 다중 상속에서 발생하는 다이아몬드 문제 에 대한 모호한 호출문제를 해결한다.
A
/ \
B C
\ /
D
클래스 B와 C가 공통 클래스 A를 상속
클래스 D가 B, C를 동시에 상속
이때 A의 멤버가 D에 두 번 중복 상속되는 문제가 발생
C c = new();
//c.M(); // 이렇게 호출하는 것은 허용되지 않는다.
// 인터페이스를 명시함으로써 다중 상속으로 인한 모호한 호출 문제 해결
(c as IA).M();
(c as IB1).M();
(c as IB2).M();
interface IA
{
void M() { Console.WriteLine("IA.M"); }
}
interface IB1 : IA
{
new void M() { Console.WriteLine("IB1.M"); }
}
interface IB2 : IA
{
new void M() { Console.WriteLine("IB2.M"); }
}
class C : IA, IB1, IB2
{
}
클래스 C 자체에서 메서드 M 오버라이딩(재구현)시 어느 인터페이스 것의 M메서드인지 구분할 수 있어서 다중 상속에 대한 문제가 없다.
C c = new();
c.M(); // 클래스 C에서 구현했으므로 인터페이스 형 변환 필요 없음
interface IA
{
void M() { Console.WriteLine("IA.M"); }
}
interface IB1 : IA
{
new void M() { Console.WriteLine("IB1.M"); }
}
interface IB2 : IA
{
new void M() { Console.WriteLine("IB2.M"); }
}
class C : IA, IB1, IB2
{
public void M()
{
(this as IB1).M(); // 다중 상속이나 인터페이스를 명시함으로써 모호함 해결.
}
}
C# 8.0부터 인터페이스에 디폴트 메서드 구현(default interface method)이 가능해지면서 interface와 abstract class의 기능 차이가 줄어들었지만, 여전히 역할과 설계 목적에서 명확한 차이가 있다.
아래에 두 개의 핵심 개념과 차이점을 설명할게.
1. 인터페이스 메서드 구현 (C# 8.0부터)
public interface ILogger
{
void Log(string message); // 추상 메서드
void LogInfo(string message) => Console.WriteLine($"INFO: {message}"); // 구현 가능
}
- LogInfo는 인터페이스 내에서 구현된 메서드로, 인터페이스를 구현하는 쪽에서 선택적으로 override할 수 있다.
- Log는 기존처럼 반드시 구현 필요.
2. 추상 클래스 (abstract class)
public abstract class LoggerBase
{
public abstract void Log(string message); // 반드시 override
public virtual void LogInfo(string message)
{
Console.WriteLine($"INFO: {message}");
}
}
- 추상 메서드: 무조건 상속한 클래스에서 구현해야 함.
- virtual 메서드: 기본 구현 제공, 필요 시 override 가능.
- 생성자, 필드, 상태(state) 저장 가능.
차이점 요약
| 항목 | 인터페이스 (C# 8.0+) | 추상 클래스 |
| 다중 상속 | ✅ 가능 | ❌ 불가 |
| 상태 보관 (필드) | ❌ 불가 | ✅ 가능 |
| 생성자 | ❌ 없음 | ✅ 있음 |
| 접근 제한자 | public만 가능 | public, protected, 등 다양 |
| 메서드 구현 | ✅ (default method) | ✅ |
| 기본 구현 재정의 | 선택적 override | virtual/abstract 구분하여 override |
interface IReadable { void Read(); }
interface IWritable { void Write(); }
class FileManager : IReadable, IWritable
{
public void Read() { }
public void Write() { }
}
추상 클래스: 공통 기능 상속 기반 구조
abstract class Device
{
public void PowerOn() => Console.WriteLine("Powering on");
public abstract void Operate();
}
결론
- interface는 다중 역할, abstract class는 공통 베이스로 이해하면 좋다.
- C# 8.0의 default method는 유연성은 높였지만, 인터페이스는 여전히 상태 없는 계약(Contract) 으로 사용하는 게 일반적이다.
🔎 핵심 차이 요약
- 인터페이스는 "무엇을 할 수 있는가" (계약, 역할 중심) → 유연성 중시
- 추상 클래스는 "어떻게 작동하는가" (공통 구현/상태 공유) → 기본 로직 공유
기존에는 계약을 정의하면서 메서드 구현이 필요한 경우 추상 클래스를 만들어야했지만, 이제는 기존 추상 클래스로 정의된 것들을 인터페이스로 바꾸는 것이 가능하다.
표16.6 추상클래스와 구현을 포함한 인터페이스의 차이점
| 다중상속 | 상태 필드 정의 | 메서드 구현 | |
| 추상 클래스 | X | O | O |
| 기본 인터페이스 메서드 | O | X | O |
16.9 ??= (널 병합 할당 연산자)
참조 객체가 null 인 경우 C# 8.0에서는 nullable 제약이 나오면서 이런 유형의 코드를 자주 쓰게 됐다.
이에 대한 코드를 간결하게 유지할 수 있도록 ??= 연산자를 새롭게 제공한다.
변수 ??= 기본값
#설명: 참조 객체인 변수의 값이 null 이면 기본값을 변수에 대입
따라서 널 병합 할당 연산자(Null-coalescing assignment operator)를 사용하면
string txt = null;
if(txt == null)
{
txt = "기본값";
}
# 상기 코드를 아래와 같이 간단히 줄일 수 있다.
txt ?? = "";
널 병합 할당 연산자는 C# 2.0에 추가된 ?? 연산자의 복합 대입 연산자 유형에 불과하다.
+연산자와 그것의 복합 연산자인 += 사용 예와 ??= 연산자의 코드를 보면 쉽게 이해가 가능하다.
{
int i = 5;
i = i + 5;
i += 5;
int k = i += 5;
}
{
string txt = null;
txt = txt ?? "test"; // 단순 대입 연산자와 함께 쓴 ?? 연산자의 사용법과
txt ??= "test"; // ?? 연산자의 복합 대입 사용법과 동작이 같다.
string rt = txt ??= "test";
Console.WriteLine(rt); // 출력: test
}
16.10 문자열 @, $ 접두사 혼합지원
C#에서 @와 $는 각각 다음과 같은 문자열 리터럴을 확장하는 기능.
각각 단독으로도 쓰이고, @$ 또는 $@ 와 같이 함께도 쓸 수 있음.
1. @ : Verbatim 문자열 리터럴 (있는 그대로의 문자열)
- 이스케이프 시퀀스(예: \n, \\)를 무시하고 있는 그대로 문자열을 표현
- 줄바꿈, 경로표시, 백슬래시 포함 문자열 등에 유용
string path = @"C:\Users\MyName\Desktop"; // \\ 대신 \ 한 개만 써도 됨
2. $ : 문자열 보간(string interpolation)
- 문자열 안에 변수를 직접 삽입할 수 있음
- "{변수}" 구문으로 변수 삽입
string name = "Alice";
string msg = $"Hello, {name}!"; // → Hello, Alice!
3. @$ 또는 $@ : Verbatim + 보간 같이 사용
- @로 줄바꿈 및 경로 그대로
- $로 변수 삽입까지 함께 사용 가능
- 순서는 $@ 또는 @$ 모두 가능 (C# 컴파일러는 동일하게 처리함)
string name = "Bob";
string msg = @$"Hello, {name}!
This is a multi-line string.
Path: C:\Users\{name}\Desktop";
위 결과
Hello, Bob!
This is a multi-line string.
Path: C:\Users\Bob\Desktop
주의사항
- @ 안에서는 "를 ""로 써야 함 (따옴표를 escape하기 위해)
- $"{var}" 안에 계산식도 가능 → $"{x + y}"
- @$는 특히 파일 경로나 JSON 문자열을 다룰 때 매우 유용
요약
| 조합 | 의미 |
| @ | 문자열을 있는 그대로 (이스케이프 없음, 줄바꿈 가능) |
| $ | 문자열 안에 변수 삽입 (보간) |
| @$ 또는 $@ | 보간 + Verbatim 문자열 (줄바꿈 + 변수 삽입 동시 사용) |
c# 6.0에 추가된 $접두사는 @접두사와 혼용하는 경우 반드시 $@ 순서로 작성해야했다. 그렇지 않으면 C#7.3 이전까지 컴파일 에러가 났다.
C# 8.0은 @$와 같이 순서와 상관없이 혼용만 하면 정상 작동된다.
16.11 기본식으로 바꾼 stackalloc, 16.12 제네릭 구조체의 unmanaged 지원 (생략)
16.13 구조체의 읽기 전용 메서드 생략
'C#(.Net)' 카테고리의 다른 글
| Obsolete 어트뷰리트를 통한 취소선 만들기 (0) | 2025.08.29 |
|---|---|
| 프록시 개념 (디자인패턴 vs 산업용/네트워크 Proxy 모듈) (0) | 2025.08.29 |
| [시작하세요 C# 12 프로그래밍 ] (#11) C# 6.0 (0) | 2025.05.06 |
| [시작하세요 C# 12 프로그래밍 ] (#10) C# 5.0 (0) | 2025.05.02 |
| [시작하세요 C# 12 프로그래밍 ] (#9) C# 4.0 (0) | 2025.04.30 |