이번에는 Code를 작성할 때 필요로 하는 논리적인 흐름에 대해 얘기해 보고자 합니다. 이와 관련하여 변수에 기반한 application의 동작과 의사결정, pattern matching수행, 구문 및 block반복등에 관한 종합적인 내용을 언급할 것이며 다수의 값을 저장하기 위한 배열저장과 하나의 type에 대한 변수나 표현식의 값을 다른 type으로 어떻게 변환할 수 있는지, 그리고 예외가 발생하는 경우에 대한 처리와 함께 숫자형 변수에 대한 overflow를 어떻게 확인할 수 있는지도 알아볼 것입니다.
1. 변수 사용하기
연산자는 변수나 실제값으로 표현된 피연산자를 대상으로 덧셈이나 곱셉과 같은 연산을 수행합니다. 또한 연산을 수행한 뒤 연산의 결괏값을 반환하고 그 값을 다른 변수에 할당하거나 피연산자에게도 영향을 줄 수 있습니다.
1) 이항 연산자
대부분의 연산자가 이항연산자에 해당하는데 이는 2개의 피연산자로 동작한다는 것을 의미하기도 합니다.
[type] 연산결과 = operand1 [연산자] operand2; |
아래 예제는 몇가지 이항연산자의 사용예를 나타내고 있습니다.
int x = 10;
int y = 20;
int result1 = x + y;
int result2 = x * y;
2) 단항연산자
단항 연산자는 피연산자가 하나임을 의미하며 피연산자의 전이나 후에서 사용될 수 있습니다.
[type] 연산결과 = 피연산자 [연산자]; [type] 연산결과 = [연산자] 피연산자; |
아래 예제는 몇가지 단항연산자의 사용예를 나타내고 있습니다.
int x = 5;
int r1 = ++x;
int r2 = x++;
Type intType = typeof(int);
string varibleXname = nameof(x);
int sizeOfInt = sizeof(int);
3) 3항연산자
3항 연산자는 말 그대로 3개의 피연산자를 필요로 합니다.
[type] 연산결과 = 피연산자1 [연산자] 피연산자2 [연산자] 피연산자3; |
3항 연산자를 예로 하기에 가장 좋은 연산자가 조건부 연산자인 '?:'입니다. 조건부 연산자는 마치 if처럼 동작하는 것으로 첫번째 피 연산자의 Boolean 표현식 결과에 따라서 결과가 참이면 두번째 피연산자가 결과값이 되며 거짓이면 세번째 피연산자가 결과값이 됩니다.
int speed = 60;
string result = speed > 60 ? "Overspeed" : "Normal";
만약 위의 3항연산자를 if문으로 바꾼다면 아마도 다음과 같이 변경할 수 있을 것입니다.
int speed = 60;
string result = string.Empty;
if (speed > 60)
{
result = "Overspeed";
}
else
{
result = "Normal";
}
대부분의 C#개발자들도 code를 더 명확하고 간소화할 수 있기에 가능하다면 if문 대신 3항연산자를 많이 사용하곤 합니다.
4) 단항연산자 예제
단항연산자로 가장 많이 사용되는 것 중 2가지가 바로 --(감소연산자)와 ++(증감연산자)인데 실제 이 연산자가 어떻게 동작하는지 예제를 통해 알아보도록 하겠습니다.
예제작성을 위해 Study-03 folder를 만들고 해당 folder에 Operator라는 Console app project를 생성합니다.
dotnet new console -n Operator |
project의 Program.cs file에서 기존의 문을 모두 제거하고 아래와 같은 code를 작성합니다. 해당 code는 2개의 i, j integer변수를 선언하고 i를 j에 할당할 때 ++연산자를 통해 증감연산을 수행하도록 합니다.
int i = 10;
int j = 10;
i = j++;
Console.WriteLine("result : i:{i} / j:{j}");
예제를 실행해 보기 전에 i의 값이 어떤 값을 가지고 있을지 예측해 보시기 바랍니다. 그런 후 예제를 실행하여 미리 예측한 값과 실제값을 비교해 봅니다.
변수 i는 ++증감연산자가 j가 i에 할당된 이후에 실행되었기 때문에 10의 값을 가지게 됩니다. 이를 후행연산이라고 하는데 만약 값이 할당되기 전에 ++연산자 먼저 수행되어야 한다면 이를 선행연산으로 처리해야 합니다.
위에서 작성한 구문을 복사하여 file의 마지막에 다시 붙여 넣고 변수명과 ++연산자를 아래와 같이 변경하여 적용합니다.
int x = 10;
int y = 10;
x = ++y;
Console.WriteLine("result : x:{x} / y:{y}");
예제를 다시 실행하면 아래와 같은 결과를 표시할 것입니다.
변수를 할당할 때 증감/감소연산자를 같이 사용하는 경우 선행과 후행에서 발생할 수 있는 혼란 때문에 Swift programming언어의 version 3에서는 더 이상 해당 연산자를 사용할 수 없게 되었습니다. C#에서는 이러한 구현이 가능하기는 하지만 되도록 이면 ++과 --연산자를 할당연산자(=)와 같이 사용하기보다는 구문을 분리해서 사용하길 권장합니다.
5) 이항연산자 예제
연산자 중에서 가장 많이 사용되는 연산자는 단연코 이항 산술 연산자라고 할 수 있으며 2개 이상의 피연산자를 대상으로 산출적 계산을 수행합니다.
예제는 n과 m이라는 2개의 integer변수를 선언하고 값을 할당한 뒤 이 두 개의 변수에 대해 5가지의 산술연산을 아래와 같이 적용합니다.
int n = 7;
int m = 3;
Console.WriteLine($"current value : n:{n}, m:{m}");
Console.WriteLine($"n + m = {n + m}");
Console.WriteLine($"n - m = {n - m}");
Console.WriteLine($"n * m = {n * m}");
Console.WriteLine($"n / m = {n / m}");
Console.WriteLine($"n % m = {n % m}");
예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
%는 나머지 연산자로 7에서 3을 나누면 나머지는 1이 됨을 의미합니다. 또한 정수에 나눗셈을 수행하면 소수점은 버려지게 됩니다.
다시 아래와 같이 double 변수 d를 선언하고 여기에 값을 할당하여 정수와 실수연산의 차이점을 확인해 보시기 바랍니다.
double d = 7.0;
Console.WriteLine($"d : {d}");
Console.WriteLine($"d / m = {d / m}");
첫 번째 피연산자가 d와 같이 7.0 값을 가진 부동소수점이라면 나눗셈연산은 정수대신 2.3333... 과 같이 부동소주점을 반환하게 됩니다.
6) 할당연산자
지금까지 여러 예제를 통해서 우리는 할당연산자(=)를 이미 사용해 왔습니다.
할당연산자는 다른 연산자와 결합하여 아래 예제와 같이 code를 더욱 간결하게 만들 수 있습니다.
int i = 10;
i += 1; //i = i + 1 과 동일
i -= 2; //i = i - 2 와 동일
i *= 3; //i = i * 3 과 동일
i /= 4; //i = i / 4 와 동일
7) Null 병합 연산자
할당 연산자와 관련된 연산자로 Null 병합 연산자가 존재합니다. Null 병합 연산자를 사용하면 변수를 결과에 할당할 때 변수가 null인 경우 대체값을 대신 할당하도록 처리할 수 있습니다.
null 병합 연산자는 ?? 또는 ??= 을 다음과 같이 사용합니다.
int age = man?.age ?? 10; //man이 null인 경우 10의 값을 대신 할당
string? s = man.Name;
s ??= "kim"; //s 변수가 null이라면 kim 문자열 할당
8) 논리 연산자
논리 연산자는 boolean값에 기반한 것으로 true와 false라는 2가지 값 중 하나만을 반환합니다.
아래 예제는 2개 이상의 boolean 피연산자를 대상으로 하는 2항 논리 연산자를 사용한 것으로 p와 q라는 2개의 boolean연산자를 선언하고 각각 true와 false값을 할당하였습니다. 그리고 서로에 대해 AND, OR, XOR (배타적 OR) 논리 연산자를 적용하여 그 결과를 표시합니다.
bool p = true;
bool q = false;
Console.WriteLine($"AND {p & p} | {p & q}");
Console.WriteLine($"AND {q & p} | {q & q}");
Console.WriteLine();
Console.WriteLine($"OR {p | p} | {p | q}");
Console.WriteLine($"OR {q | p} | {q | q}");
Console.WriteLine();
Console.WriteLine($"XOR {p ^ p} | {p ^ q}");
Console.WriteLine($"XOR {q ^ p} | {q ^ q}");
예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
결과에서 보듯 AND 논리연산에서 결과가 true가 되려면 2개의 피연산자 모두 true가 되어야 합니다. OR 논리연산에서는 둘 중에 하나만 true여도 결과가 true가 되며 XOR 논리연산에서도 둘중에 하나만 true여도 결과가 true가 되지만 둘 다 true여서는 안 됩니다.
9) 조건부 논리 연산자
조건부 논리 연산자는 일반적인 논리 연산자와 비슷하지만 연산의 기호를 '&' 대신 '&&'나 '|' 대신 아닌 '||'처럼 하나가 아닌 2개를 사용한다는 차이점이 있으며 이를 단락 논리 연산자라고도 합니다.
관련한 사항에 대해서는 추후에 더 자세히 알아볼 것이므로 지금은 조건부 논리 연산자가 어떤 것인지만 간단히 짚어보고 가도록 하겠습니다.
예제로 간단한 함수를 정의해 볼 텐데 해당 함수는 실행되고 나면 값을 반환하도록 할 것입니다. 이때 값은 논리연산에서 사용할 수 있는 true/false형식의 boolean값이 됩니다.
Program.cs의 마지막 부분에서 다음과 같은 함수를 정의합니다. 해당 함수는 '함수 실행'이라는 message를 출력한 뒤 결과로 true값을 반환합니다.
static bool TEST()
{
Console.WriteLine("함수 실행");
return true;
}
논리 함수는 Top level program기능을 사용하는 Program.cs에서는 어디든 존재할 수 있지만 가급적 file의 마지막 부분에 위치하는 것이 좋습니다.
이전 예제에서의 Console.WriteLine 함수 위에서 p, q변수와 위 예제의 함수의 결과에 대해 AND(&) 연산을 수행하도록 아래와 같이 예제를 작성합니다.
Console.WriteLine($"p & TEST() = {p & TEST()}");
Console.WriteLine($"q & TEST() = {q & TEST()}");
Console.WriteLine();
예제를 실행하면 아래와 같은 결과를 볼 수 있습니다. 결과에서 보듯 함수가 p와 q에서 한 번씩 두 번 실행되었음을 알 수 있습니다.
위 3줄 구문을 복사해서 바로 밑에 붙여 넣고 아래와 같이 & 연산자를 && 연산자로 변경합니다.
Console.WriteLine($"p && TEST() = {p && TEST()}");
Console.WriteLine($"q && TEST() = {q && TEST()}");
Console.WriteLine();
예제를 실행하면 이번에는 함수가 p 변수와 결합되는 경우에만 실행되었음을 알 수 있습니다. 반면 q와 결합되는 경우에 q변수는 false이므로 결과는 어떻게 해서든 false가 되고 따라서 굳이 함수가 실행될 필요가 없기 때문에 곧장 false로 결과가 표시되었습니다.
예제를 통해 우리는 조건부 논리 연산자가 왜 단락으로서 설명되는지를 알 수 있습니다. 이를 통해 application은 더 효휼적으로 실행될 수 있지만 함수가 항상 실행된다고 여겨지는 경우에는 미묘한 bug를 유발할 수 있습니다. 따라서 함수와 같이 결합되고 때문에 예상치 못한 동작이 발생할 여지가 있을 때는 조건부 논리 연산자의 사용을 피하는 것이 오히려 더 나은 선택일 수 있습니다.
10) bit 및 2진 shift 연산자
bit 단위 연산자는 숫자로 된 binary표현식에서 bit단위의 비교를 수행합니다. 즉, 각각의 bit에서 0과 1의 값인 bit를 동일한 column의 다른 bit와 개별적으로 하나씩 비교하는 것입니다.
2진 shift 연산자는 일반적인 산술계산과 동일한 동작을 수행할 수 있으면서도 해당 산술계산의 연산자보다 훨씬 빠르게 수행될 수 있습니다.
bit와 binary shift 연산자의 용법을 간단히 알아보기 위해 Program.cs에서 x와 y라는 2개의 변수를 선언하고 각각 10과 6의 값을 할당합니다. 그런 다음 AND와 OR, XOR bit 연산자를 적용하고 그 결과를 표시합니다.
int x = 10;
int y = 6;
Console.WriteLine($"x binary : {x:B8}");
Console.WriteLine($"y binary : {y:B8}");
Console.WriteLine($"x & y : {x & y:B8} = {x & y}");
Console.WriteLine($"x | y : {x | y:B8} = {x | y}");
Console.WriteLine($"x ^ y : {x ^ y:B8} = {x ^ y}");
Console.WriteLine();
예제에서 사용된 B8은 8자리의 binary형식으로 표현할 것임을 의미합니다.
예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
결과를 보시면 x & y에서는 2bit column에만 값이 존재하며, x | y 에서는 8, 4, 2 bit column에만, 그리고 x ^ y 에서는 8과 4bit column에만 1 값이 설정되었습니다.
계속해서 Program.cs에서 변수 x의 bit를 왼쪽 3개 column으로 이동시키는 왼쪽 shift 연산자를 적용하고 이어서 x변수에 8을 곱하고, 다시 y변수의 bit를 오른쪽 1 column이동시키는 오른쪽 shift 연산자를 적용한 뒤 그 결과를 표시하도록 아래 예제를 추가합니다.
Console.WriteLine($"x << 3 : {x << 3:B8} = {x << 3}");
Console.WriteLine($"x x 8 : {x * 8:B8} = {x * 8}");
Console.WriteLine($"y >> 1 : {y >> 1:B8} = {y >> 1}");
Console.WriteLine();
예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
첫 번째 결괏값 80은 bit가 왼쪽으로 3칸 이동함으로써 1 값이 64 bit와 16 bit영역으로 채워졌기에 64 + 16으로 80이 되며, 이 결과는 실제 * 8의 연산을 수행한 것과 동일하다는 것을 알 수 있습니다. 일반 산술연산보다 사용하기는 약간 어렵지만 CPU는 shift 연산자를 훨씬 빠르게 처리할 수 있습니다. 마지막 결과 3은 y변수의 1bit가 1 column이동함으로써 2 bti와 1bit 영역에 1이 채워졌기 때문에 3이 표시됩니다.
정리하자면 정수값에 대한 연산을 수행할 때 &와 |는 bit 단위 연산자가 되며 true/false와 같은 boolean값에서 연산을 수행할때 &와 |기호는 논리 연산자가 됩니다.
11) 기타 연산자
nameof와 sizeof는 type에 대해서 유용하게 사용할 수 있는 연산자입니다.
nameof 변수나 type 또는 member에 대한 약식명칭을 문자열로 반환합니다. 약식이라는 것은 namespace를 제외한다는 것을 의미하며 주로 예외 message를 나타낼 때 유용하게 사용할 수 있습니다.
sizeof 단순한 type에 대해 size를 byte단위로 반환하며 data를 저장할 때의 효율성을 결정할 때 유용하게 사용될 수 있습니다. 기술적으로 sizeof 연산자는 비안전 code block을 필요로 하지만 C#의 별칭(int나 double와 같은)에 대한 type의 size값은 compiler에 의해 상수로 hardcoding 됩니다. 따라서 이런 경우에는 비안전 code block을 사용하지 않을 수 있습니다.
아래 예제는 이 2가지 연산자에 대한 간단한 사용예시를 표시하고 있습니다.
int i = 100;
Console.WriteLine($"The {nameof(i)} variable uses {sizeof(int)} bytes.");
이외에 변수에서 member를 참조하기 위해 사용되는 dot(.) 역시 member 접근 연산자라고 하며 함수/method의 이름 끝에서 사용되는 괄호는 발동(호출) 연산자라고 하여 연산자로 취급됩니다.
int i = 100;
string s = i.ToString();
2. 선택문
모든 application은 개발단계에서 여러 흐름을 통해 선택과 분기에 대한 code가 사용됩니다. C#에서는 2가지 이상의 선택적 사항을 처리하기 위해 if 또는 switch문이 사용합니다. 물론 모든 선택적 상황에서 if 만을 사용할 수도 있지만 switch를 사용하면 예컨대 단일 변수가 여러 가지 값을 가질 수 있고 이 값에 따라 동작이 달라져야 하는 경우처럼, 이와 비슷한 상황에서 code를 더 간소하게 만들 수 있습니다.
1) if 문
if문은 boolean 식의 평가에 따라 분기를 결정하는 분기문입니다. 만약 표현식이 true라면 block이 실행되며 그 외 false인 경우 실행하는 것은 선택적으로 적용합니다. 또한 if문은 중첩될 수 있습니다.
if문은 else if와 같은 다른 if문과도 아래와 같이 결합될 수 있습니다.
if (표현식1)
{
//표현식1이 true인 경우 실행
}
else if (표현식2)
{
//표현식1이 false이고 표현식2가 true인 경우 실행
}
else if (표현식3)
{
//표현식1과 표현식2가 false이고 표현식3이 true인 경우 실행
}
else
{
//상기 모든 표현식이 false인 경우 실행
}
각각의 if 구문에서 사용된 boolean 표현식은 swicth와 는 달리 다른 if문과 독립적이며 단일값만의 사용을 필요로 하지 않습니다.
if문을 사용해 보기 위한 예제를 작성하기 위해 Study-03 folder에 ExSelection folder를 생성하고 그 안에서 Console app project를 생성합니다.
Program.cs에서 기존의 문을 모두 삭제하고 비밀번호가 최소 5자리 이상인지를 확인하는 if문을 아래와 같이 추가합니다.
string password = "test123";
if (password.Length < 5)
{
Console.WriteLine("Password is too short.");
}
else
{
Console.WriteLine("Password is good.");
}
위 예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
(1) if 문을 사용할 때 중괄호의 처리
상기 예제처럼 if문 사용 시 if문의 block안에 단일문만 존재하는 경우 괄호를 사용하지 않아도 됩니다.
if (password.Length < 5)
Console.WriteLine("Password is too short.");
else
Console.WriteLine("Password is good.");
하지만 위와 같은 if문 style은 자칫 심각한 bug를 유발할 수 있기 때문에 피하는 것이 좋습니다. 이에 대한 예로 Apple iPhone iOS 운영체제의 gotofail bug를 들 수 있습니다. Apple의 iOS 6가 release 되고 18개월 후 인 2012년 9월에 Secure Sockets Layer(SSL)에서 중괄호를 사용하지 않은 if문 때문에 bug가 발생하였습니다. 사용자가 device의 web browser인 Safari를 실행하고 은행과 같은 보안 website로의 연결을 시도할 때 의도치 않게 중요한 확인처리가 skip 되는 상황이 발생한 것입니다.
개발자가 중괄호를 제외할 수 있다는 것은 그럴 수 있다는 것이지 그렇게 해야 한다는 것은 아닙니다. Code의 효율성이 떨어질 수 있을지언정 더 읽기 어려운 code가 될 수 있으며 관리하기 어렵고 잠재적으로 더 위험한 code가 될 수 있습니다.
(2) if 문에서 pattern maching 사용하기
Pattern matching은 C# 7부터 도입된 기능으로 if문에서는 is keyword를 지연변수와 결합하여 code를 더 안전하게 작성할 수 있습니다. 예를 들어 'o is int i'와 같은 단일 표현식에서는 아래와 같은 2가지 동작을 실행하며 이는 pattern matching을 이해하는데 중요한 부분입니다.
- 변수에 대한 data type을 확인합니다.
- 확인결과가 true라면 변수 i에 값을 할당합니다.
예제에서 사용된 is 연산자에 대해 좀 더 자세히 알아보려면 아래 link를 참고하시기 바랍니다.
https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/is
아래 예제는 o라는 이름의 변수에 저장된 값이 int인 경우 i이름의 지역변수에 값을 할당하도록 합니다. 이때 i변수는 if문안에서 자유롭게 사용될 수 있습니다. 이러한 방법은 i변수가 int type으로서 처리될 수 있음을 확실히 해둘 수 있으므로 o변수를 직접 사용하는 것보다 더 안전하게 처리될 수 있습니다.
object o = "2";
int j = 3;
if (o is int i)
{
Console.WriteLine($"{i} x {j} = {i * j}");
}
else
{
Console.WriteLine("o is not an int...");
}
위 예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
예제의 "2"부분에서 큰따옴표를 지워 2 값이 문자열이 아닌 int type으로 저장될 수 있도록 하고, 다시 예제를 실행하면 이번에는 아래와 같은 결과를 표시할 것입니다.
2) Switch 문
Switch문은 단일 표현식을 case문에서 지정한 다수의 항목과 비교를 수행한다는 점에서 if문과는 다르다고 할 수 있습니다. 모든 case문은 이 단일 표현식과 관련되어 있는 것입니다. 또한 case문은 아래 중 하나로 종료되어야 합니다.
- break keyword (아래 예제에서의 case 1)
- goto case keyword (아래 예제에서의 case 2)
- 실행할 문이 없음 (아래 예제에서의 case 3)
- label이름을 참조하는 goto keyword (아래 예제에서의 case 5)
- 현재 함수를 종료하기 위해 사용되는 return keyword
Switch문을 실제 사용해 보기 위해 아래와 같은 예제를 작성합니다. 예제에서 끝에서 두 번째 문은 jump 되는 label이며 첫 번째 문은 1과 6(code에서 숫자 7은 상한선을 의미하는 것으로 제외입니다.) 사이에 random 한 숫자를 생성하는 부분인데 Switch문의 분기동작은 이 random한 숫자에 기반하여 이루어집니다.
int number = Random.Shared.Next(minValue: 1, maxValue: 7);
Console.WriteLine($"random number : {number}");
switch (number)
{
case 1:
Console.WriteLine("1");
break;
case 2:
Console.WriteLine("2");
goto case 1;
case 3:
case 4:
Console.WriteLine("3 ~ 4");
goto case 1;
case 5:
goto A_label;
default:
Console.WriteLine("Etc");
break;
}
Console.WriteLine("End of switch");
A_label:
Console.WriteLine($"A_label");
goto keyword는 다른 case나 label로 jump 하기 위한 용도로 사용됩니다. 대부분의 개발자들은 이런 goto는 별로 좋아하지 않지만 일부 특별한 경우에는 좋은 해결책이 될 수도 있습니다. 다만 너무 남발하여서는 안됩니다. 아래 link에서는 Microsoft가 .NET base class library에서 어떤 식으로 goto문을 활용하고 있는지를 확인할 수 있습니다. https://github.com/search?q=%22goto%20%22+repo%3Adotnet%2Fruntime+language%3AC%23&type=code&ref=advsearch
GitHub · Build and ship software on a single, collaborative platform
Join the world's most widely adopted, AI-powered developer platform where millions of developers, businesses, and the largest open source community build software that advances humanity.
github.com
예제를 몇 번 반복해 실행하여 random 하게 생성되는 숫자마다 어떤 결과가 발생하는지를 확인합니다.
ramdom 한 숫자를 생성하기 위해 사용한 Random class에서는 Next method를 사용할 수 있으며 이를 통해 포함될 최저값과 제외될 상한선의 최고값을 지정할 수 있습니다. 그리고 이를 통해 해당 범위 안에서 임의의 random 한 값을 생성할 수 있습니다. 또한 Thread에 안전하지 않은 Random class의 새로운 instance를 생성하는 대신 .NET 6부터는 Thread에 안전하며 모든 thared에서 동기적으로 사용될 수 있는 Shared instance를 사용할 수 있습니다.
(1) Switch문에서 pattern matching 사용하기
C# 7부터는 if처럼 switch문도 pattern matching을 지원하게 되었습니다. 때문에 case의 값은 literal 뿐만 아니라 pattern으로도 사용할 수 있게 되었고 이러한 영향으로 인해 class의 하위 type에 기반하여 code를 보다 간결하에 분기할 수 있고, 지역변수를 선언하고 여기에 안전하게 값을 할당할 수 있습니다. 또한 case문에서는 when keyword를 사용하여 더욱 특정한 pattern matching을 수행할 수도 있습니다.
지금 만들어볼 예제는 switch문에서의 pattern matching을 알아보기 위한 것으로 다른 속성을 가진 animal이라는 사용자 정의 class의 계층구조를 사용하고자 합니다.
예제에서 사용된 class의 정의와 enum에 관한 더 자세한 사항은 추후에 별도로 다룰 것입니다.
이를 위해 Project에 Animal.cs라는 이름의 새로운 class file을 project에 추가합니다. 그리고 Animal.cs에서 기본으로 작성된 기존의 모든 구문을 제거하고 아래 3개의 class를 정의합니다. 여기서 Animal은 base class가 되며 Cat과 Dog은 해당 class를 상속한 sub class가 됩니다.
enum Howl
{
Meow,
Bark
}
class Animal
{
public string? Name;
public DateTime Born;
public Howl? Howl;
}
class Cat : Animal
{
public bool IsDomestic = true;
}
class Dog : Animal
{
public bool IsSigorzavzong = false;
}
해당 예제는 단지 pattern matching을 설명하기 위한 것이며 class에 field를 정의하는 일반적인 방법은 아니기에 어떤 방식으로 data type이 선언되는가는 중요하지 않습니다. 예제에서 Name과 Howl속성은 null가능한 type으로 선언되었으며 그 외 다른 1개의 속성은 그렇게 하지 않았습니다. 물론 이와 반대로 선언하는 것도 가능하며 이것은 앞으로 만들 예제에 영향을 주지 않습니다.
Program.cs에서는 nullable animal의 array를 선언하고 type과 각각의 animal이 가진 속성에 기반하여 message를 표시합니다. 그리고 이를 통해 해당 animal이 가진 속성과 type을 확인할 수 있는 구문을 아래와 같이 추가합니다.
var animals = new Animal?[] {
new Cat { Name = "soi", Born = new(year: 2020, month: 5, day: 12), Howl = Howl.Meow, IsDomestic = true },
null,
new Cat { Name = "navi", Born = new(year: 2021, month: 3, day: 5) },
new Dog { Name = "gome", Born = DateTime.Today, IsSigorzavzong = true },
new Dog { Name = "bak", Born = DateTime.Today }
};
foreach (Animal? animal in animals)
{
string message = string.Empty;
switch (animal)
{
case Cat meowCat when meowCat.Howl == Howl.Meow:
message = $"Cat name : {meowCat.Name}, Meow...";
break;
case Cat wildCat when wildCat.IsDomestic == false:
message = $"Cat {wildCat.Name} : Wild Cat.";
break;
case Cat cat:
message = $"Cat : {cat.Name}";
break;
default: // default는 마지막에 평가되도록 해야 합니다.
message = $"{animal.Name} is a {animal.GetType().Name}.";
break;
case Dog dog when dog.IsSigorzavzong == false:
message = $"{dog.Name} is sigolzabzong.";
break;
case null:
message = "animal is null";
break;
}
Console.WriteLine($"result : {message}");
}
switch 구문에서 default는 항상 마지막에 위치해야 합니다. 예제에서 default는 분기문의 중간에 위치하고 있는데 이는 의도적인 것으로 이러한 경우도 compiler가 허용함을 보여주기 위한 것입니다.
또한 아래의 case문은
case Cat meowCat when meowCat.Howl == Howl.Meow:
더 간소한 속성 pattern-matching 문법을 사용하여 아래와 같이 작성될 수 있습니다.
case Cat { Howl: Howl.Meow } meowCat:
예제를 실행하고 아래와 같은 결과를 표시하는지 확인합니다. 예제에서는 animals array가 Animal? type으로 선언되었으므로 Cat과 Dog처럼 Animal에 대한 모든 subtype을 포함하거나 null이 될 수 있습니다. 예제에서는 각기 다른 속성을 가진 다른 animal type의 4개 instance를 생성했고 그중 하나는 null로 처리하였습니다. 따라서 각 animal에 대해 설명하는 5가지 message는 아래와 같이 출력될 것입니다.
(2) switch 표현식을 사용해 switch문 간소화하기
C# 8부터는 switch 표현식을 사용해 switch문을 간소화할 수 있습니다.
switch문을 사용하는 대부분의 경우 그렇게 많은 code의 양을 필요로 하지는 않지만 때로는 typing이 부담스러울 수 있는데 이런 경우를 위해 switch 표현식을 사용할 수 있습니다. switch 표현식은 단일 변수에 값을 설정하기 위해 모든 경우에 값을 반환하는 동일한 의도를 구현하면서 필요한 typing좀 더 줄일 수 있도록 설계되었습니다. switch표현식은 lambda을 사용하며 이를 통해 반환하는 값을 나타냅니다.
swtich문을 사용한 위의 예제를 switch표현식으로 구현해 봄으로서 이 2가지 style을 비교해 볼 수 있습니다.
Program.cs의 foreach문 내부 밑에서 animal이 가진 속성과 type에 기반한 message를 설정하는 switch표현식 구문을 아래와 같이 추가합니다.
message = animal switch
{
Cat meowCat when meowCat.Howl == Howl.Meow
=> $"Cat name : {meowCat.Name}, Meow...",
Cat wildCat when wildCat.IsDomestic == false
=> $"Cat {wildCat.Name} : Wild Cat.",
Cat cat
=> $"Cat : {cat.Name}",
Dog dog when dog.IsSigorzavzong == false
=> $"{dog.Name} is sigolzabzong.",
null
=> "animal is null",
_
=> $"{animal.Name} is a {animal.GetType().Name}."
};
Console.WriteLine($"result : {message}");
예제에서의 중요한 차이점은 기존의 case와 break가 제거되었다는 것입니다. _(underscore문자)는 default 처리를 나타내기 위해 사용되었는데 이를 discard라고 하며 관련된 내용은 아래 link를 통해 좀더 자세히 확인하실 수 있습니다. https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/discards
예제를 실행하고 이전과 결과가 동일한지 확인합니다.
3. 반복문
반복문은 조건이 true(while, for)이거나 collection에서 각 item을 순회할 때 해당 구문의 block에 대한 반복을 수행합니다.
1) while 문
while문은 boolean 식을 평가하고 결과가 true면 순회동작을 계속 진행합니다. 관련 예제를 만들어 보기 위해 Study-03 folder에 ExLoop folder를 만들고 여기에 console app project를 추가합니다.
Program.cs에서 기존의 구문을 모두 삭제하고 integer 변수의 값이 10 미만인 동안 순회를 계속 진행하는 switch문을 아래와 같이 정의합니다.
int i = 0;
while (x < 10)
{
Console.WriteLine(x);
x++;
}
예제를 실행하고 아래와 같이 0부터 9까지의 숫자가 표시되는지 확인합니다.
2) do while 문
do while문은 while문과 비슷하지만 while문의 시작점에서 boolean 표현식을 평가하는 반면 do while문은 while문의 마지막 부분에서 boolean 식을 평가합니다. 이러한 동작방식 때문에 while문은 boolean 표현식이 false가 되면 아예 while block문이 실행되지 않지만 do while문의 block은 일반 한번 실행한 후 boolean 표현식이 평가되므로 최소한 한 번은 실행을 보장하게 됩니다.
아래 예제는 do while문의 일반적인 용법을 나타내고 있습니다.
int number;
do
{
Console.Write("양수를 입력하세요: ");
bool isValid = int.TryParse(Console.ReadLine(), out number);
if (!isValid || number <= 0)
{
Console.WriteLine("잘못된 입력입니다. 다시 입력하세요.");
}
}
while (number <= 0);
예제를 실행하면 application은 아래와 같이 정확한 입력이 있을 때까지 반복적으로 입력을 요청할 것입니다.
3) for 문
for문 역시 while문처럼 반복문에 해당하지만 다음과 같은 3가지 표현식을 가지고 여기에 기반해 동작한다는 차이점이 있습니다.
- 초기화 구문 : for문이 최초로 실행될 때 한 번만 실행되는 부분입니다. 필요에 따라 생략할 수 있습니다.
- 조건부 구문 : for문의 다음 block이 실행되기 전 for문의 반복이 계속 가능한지를 확인하는 부분입니다. 필요에 따라 생략할 수 있습니다.
- 반복 구문 : for문의 block이 실행되고 난 후 다음 block이 실행되기 전에 실행되는 부분입니다. 대게는 counter 변수를 증가하는 용도로 사용됩니다. 필요에 따라 생략할 수 있습니다.
for문의 위와 같은 구문에서 사용되는 변수는 대게 int형이며 다음과 같이 구현할 수 있습니다.
for (int i = 1; i <= 10; i++)
{
Console.WriteLine(i);
}
위 예제를 실행하고 결과를 확인해 보면 1부터 10까지 숫자가 열거됨을 알 수 있습니다.
아래 예제는 0부터 10까지 반복문이 실행되지만 i값이 3씩 증가합니다.
for (int i = 0; i <= 10; i += 3)
{
Console.WriteLine(i);
}
위 예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
추가적으로 초기화, 조건부, 반복 부분을 하나씩 변경해 보면 for문에 대해 좀 더 명확히 이해할 수 있을 것입니다.
4) foreach 문
foreach문은 array나 collection과 같은 배열에서 개별적인 item 꺼내와 이를 가지고 block문을 수행하는 데 사용되는 것으로 위에서 언급한 반복문과는 약간의 차이가 있습니다.
또한 배열에서 회수된 각각의 item은 읽기 전용이며 열거하는 동안 item이 추가되거나 제거됨으로써 대상 배열의 구조가 바뀌게 되면 예외를 발생시키게 되므로 배열의 변경을 시도해서는 안됩니다.
아래 예제는 문자열변수에 대한 array를 생성하고 각각의 문자열길이를 표시하도록 하는 예제입니다.
string[] fruits = { "orange", "apple", "banana" };
foreach (string fruit in fruits)
{
Console.WriteLine($"{fruit} has {fruit.Length} characters.");
}
예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
(1) foreach의 내부동작
array나 collection과 같이 다수의 item을 다루는 type을 정의하는 개발자는 다른 개발자가 foreach문을 사용해 type의 item을 열거할 수 있게 만들어둘 필요가 있습니다.
이에 대해 foreach문은 아래 규칙을 따르는 모든 type에서 실행될 수 있습니다.
- type은 object를 반환하는 GetEnumerator라는 이름의 method를 가지고 있어야 합니다.
- 반환된 개체(object)는 Current라는 이름의 속성과 MoveNext라는 이름의 method를 가지고 있어야 합니다.
- MoveNext method는 Current속성의 값을 변경하고 열거할 item이 존재하는 경우 true를 그렇지 않으면 false를 반환해야 합니다.
.NET에는 공시적으로 위의 규칙을 정의하는 IEnumerable와 IEnumerable<T> interface가 존재하므로 이들 interface를 구현하면 되지만 compiler가 모든 배열 type에서 이들 interface의 구현을 강제하지는 않습니다.
compiler는 위 예제에서의 foreach문을 아래의 code와 같이(혹은 비슷하게) 변환하게 되는데
IEnumerator e = fruits.GetEnumerator();
while (e.MoveNext())
{
string fruit = (string)e.Current; //read-only
Console.WriteLine($"{fruit} has {fruit.Length} characters.");
}
위와 같이 iterator와 read-only Current속성을 사용하기 때문에 foreach문 내부에서 선언된 변수는 Current item의 값을 변경하는 데 사용될 수 없습니다.
4. Array
동일한 type의 여러 값을 저장할 필요가 있다면 array를 사용할 수 있습니다. 예를 들어 여러 과일명을 저장한다면 string type의 array를 사용할 수 있을 것입니다.
1) 1차원 Array
곧 작성해 볼 예제에서는 4개의 문자열값을 저장하는 array를 memory에 할당한 뒤 문자열값을 0부터 3까지 index위치값으로 저장하는 것입니다. 일반적으로 array는 0부터 index가 시작되므로 마지막 item의 index값은 array의 크기에서 1만큼 작은 값을 가지게 됩니다.
따라서 array를 index로 시작화 하면 다음과 같이 표현할 수 있습니다.
0 | 1 | 2 | 3 |
Orange | Apple | Banana | Mango |
대부분의 array의 index는 0부터 시작하지만 그렇다고 해서 항상 0부터 시작할 것이라고 가정해서는 안됩니다. 가장 일반적인 .NET에서의 array는 szArray로 이것을 '1 차원 0 index array(a single-dimensional zero-indexed array)'라고 합니다. 하지만 .NET은 mdArray(a multi-dimensional array)도 사용할 수 있는데 이 경우 zero(0)로 시작하는 index를 사용하지 않습니다. 물론 흔하게 사용되는 경우는 아니지만 이런 것이 있다는 것을 알아둘 필요가 있습니다.
array를 사용해 보기 위한 예제를 위해 Study-03 folder에서 Array라는 folder를 생성한 뒤 여기에 신규 Console App project를 생성합니다.
그리고 Program.cs에서 기존의 문을 모두 제거하고 아래와 같이 문자열 값의 array를 생성하고 사용하는 문을 작성합니다.
string[] fruit_names;
fruit_names = new string[4]; //Array 초기화 -> 이 시점에 memory에 할당이 이루어짐
fruit_names[0] = "Apple";
fruit_names[1] = "Banana";
fruit_names[2] = "Mango";
fruit_names[3] = "Watermelon";
for (int i = 0; i < fruit_names.Length; i++)
{
Console.WriteLine($"{fruit_names[i]} is at position {i}.");
}
예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
Array는 memory에 할당되는 시점에 크기게 고정됩니다. 따라서 예제와 같이 초기화하기 전 얼마나 많은 item을 다룰지 결정해야 합니다.
위 예제에서 처럼 array를 초기화하는 대신 다음과 같은 방법으로 array를 초기화하는 것도 가능합니다. 이 방법은 선언과 동시에 memory에 할당이 이루어지며 array의 값을 초기화하게 됩니다.
string[] fruit_names = { "Apple", "Banana", "Mango", "Watermelon" };
for (int i = 0; i < fruit_names.Length; i++)
{
Console.WriteLine($"{fruit_names[i]} is at position {i}.");
}
위 예제를 실행하면 이전의 예제와 동일한 결과를 표시할 것입니다.
2) 다차원 Array
일련의 문자열 값(또는 다른 type의 값이라도)을 지장 하기 위해 1차원 배열을 사용하는 대신 값을 격자형태로 저장하고자 한다면 다차원배열을 사용할 수 있습니다. 다차원은 2부터 시작하는 모든 array를 말합니다.
예를 들어 문자열값을 저장하고 있는 2차원 배열을 시각화하면 아래와 같이 표현할 수 있을 것입니다.
0 | 1 | 2 | 3 | |
0 | Apple | Banana | Mango | Watermelon |
1 | Car | Airplane | Bicycle | Autobicycle |
2 | Thor | Ironman | Blackwidow | Spiderman |
다차원 배열을 사용해 보기 위해 Program.cs에서 문자열값을 가진 2차원 배열을 선언하고 초기화하는 문을 아래와 같이 추가합니다.
string[,] grid = {
{ "Apple", "Banana", "Mango", "Watermelon" },
{ "Car", "Airplane", "Bicycle", "Autobicycle" },
{ "Thor", "Ironman", "Blackwidow", "Spiderman" }
};
위 예제에서는 아래의 method를 통해 각 array에 대한 하한값과 상한 값을 확인할 수 있습니다.
Console.WriteLine($"1 dimension, lower bound: {grid.GetLowerBound(0)}");
Console.WriteLine($"1 dimension, upper bound: {grid.GetUpperBound(0)}");
Console.WriteLine($"2 dimension, lower bound: {grid.GetLowerBound(1)}");
Console.WriteLine($"2 dimension, upper bound: {grid.GetUpperBound(1)}");
위 예제가지 작성 후 예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
또한 array의 값은 아래와 같이 중첩된 loop문을 통해 확인할 수 있습니다.
for (int row = 0; row <= grid.GetUpperBound(0); row++)
{
for (int col = 0; col <= grid.GetUpperBound(1); col++)
{
Console.WriteLine($"Row {row}, Column {col}: {grid[row, col]}");
}
}
상기 예제의 실행결과는 다음과 같습니다.
다차원배열 역시 초기화단계에서 모든 행과열에 값을 제공해야 하며 그렇지 않으면 compile 오류를 일으키게 됩니다. 만약 값이 누락되어야 하는 부분이 있다면 예제에서와 같이 문자열값을 다루는 array인 경우 string.Empty를 사용하며, string?[] 처럼 null가능한 string type이라면 null keyword를 사용할 수도 있습니다.
만약 file이나 database에서 값을 가져오는 경우처럼 미리 값이 정해져 않은 경우 array 초기화 구문을 사용할 수 없는데 이럴 때는 array를 선언하는 것과 값을 배정하여 memory에 할당되는 부분을 다음과 같이 따로 분리할 수 있습니다.
string[,] grid2 = new string[3, 4];
grid2[0, 0] = "Apple"; // 값 할당
grid2[0, 1] = "Banana";
.
.
.
grid2[2, 3] = "Spiderman";
array의 차원의 크기를 선언하는 경우에는 상한 값이 아닌 array의 크기값을 지정해야 합니다. 따라서 예제의 'new string [3, 4]'의 뜻은 array가 첫 번째 차원에서 상한선 2까지 3개의 item을 가질 수 있으며 두 번째 차원에서 상한선 3까지 4개의 item을 가질 수 있다는 것을 의미합니다.
3) 가변배열
다차원 array를 사용할 때는 각 차원별로 저장되는 item의 수가 다를 수 있습니다. 이런 경우에는 array를 2중으로 정의하는 방법으로 해결해야 하며 이러한 array의 형태를 가변배열(jagged array)이라고 합니다.
가변 array를 시각적으로 표현하면 다음과 같이 나타낼 수 있습니다.
가변 array를 사용해 보기 위해 Program.cs에서 아래와 같은 예제를 작성합니다. 예제는 문자열값을 저장하는 2중 array를 선언하고 지정한 값으로 초기화합니다.
string[][] jaggedArray = {
new[] { "Apple", "Banana", "Mango" },
new[] { "Car", "Airplane", "Bicycle", "Autobicycle" },
new[] { "Thor", "Ironman" }
};
array 내부의 array와 해당 array의 하한값 및 상한 값은 아래와 같은 방법으로 확인할 수 있습니다.
Console.WriteLine("jaggedArray Upper bound : {0}", jaggedArray.GetUpperBound(0));
for (int array = 0; array <= jagged.GetUpperBound(0); array++)
{
Console.WriteLine("array {0} Upper bound : {1}", arg0: array, arg1: jagged[array].GetUpperBound(0));
}
위 예제를 실행하면 다음과 같은 결과를 확인할 수 있습니다.
또한 각 array의 값은 아래와 같이 중첩된 loop문을 통해 확인할 수 있습니다.
for (int row = 0; row <= jaggedArray.GetUpperBound(0); row++)
{
for (int col = 0; col <= jaggedArray[row].GetUpperBound(0); col++)
{
Console.WriteLine($"Row {row}, Column {col}: {jaggedArray[row][col]}");
}
}
예제를 실행하면 다음과 같은 결과를 확인할 수 있습니다.
4) List pattern matching
이전 예제(분기문)에서는 잠깐, 개체가 자신의 type과 속성에 대해 어떤 방식으로 pattern matching을 사용할 수 있는지 살펴보았습니다. 이러한 방식의 pattern matching은 array나 collection에서도 적용할 수 있습니다.
C# 11에서 소개된 list pattern matching은 public 속성인 Length 또는 Count 속성과 int 또는 System.Index 매개변수를 사용하는 indexer속성을 가진 모든 개체에서 적용할 수 있습니다. 참고로 indexer에 관해서는 추후에 자세히 알아볼 것입니다.
동일한 switch문안에서 다수의 list pattern을 정의할 때는 더 구체적인 조건이 가장 먼저올 수 있도록 해야 합니다. 그렇게 하지 않으면 구체적으로 일치하는 조건에 도달하기도 전에 먼저 위치한 일반적인 조건에 pattern이 일치하여 처리될 수 있기 때문입니다. 이러한 사항은 실제 예제를 작성해 봄으로서 쉽게 이해할 수 있을 것입니다.
우선은 아래 표를 참고하여 list pattern matching의 여러 예를 확인해 보시기 바랍니다. 표의 에제는 int 값의 list로 대상을 가정하고 있습니다.
예 | 설명 |
[] | 비어있는 array 또는 collection과 일치합니다. |
[..] | 비어있는 array 또는 collection을 포함하여 item을 가진 모든 array와 collection에 일치합니다. 따라서 만약 [] pattern과 [..] pattern 둘다 switch문에서 사용되어야 한다면 [..] pattern이 [] pattern 이후에 와야 합니다. |
[_] | 단일 item을 가진 모든 list와 일치합니다. |
[int item1] 또는 [var item1] | 단일 item을 가진 모든 list와 일치하며 반환 표현식에서 item1을 참조함으로서 값을 가져올 수 있습니다. |
[7, 2] | 지정한 2개의 값을 순서대로 정확히 저장하고 있는 list와 일치합니다. |
[_, _] | 2개의 item을 가진 모든 list와 일치합니다. |
[var item1, var item2] | 2개의 item을 가진 모든 list와 일치하며 반환 표현식에서 item1과 item2를 참조함으로서 값을 가져올 수 있습니다. |
[_, _, _] | 3개의 item을 가진 모든 list와 일치합니다. |
[var item1, ..] | 하나 또는 그 이상의 item을 가진 모든 list와 일치합니다. 또한 반환 표현식에서 item1을 참조함으로서 첫번째 item의 값을 가져올 수 있습니다. |
[var firstItem, .., var lastItem] | 2개 또는 그 이상의 item을 가진 모든 list와 일치합니다. 또한 반환 표현식에서 firstItem과 lastItem을 참조함으로서 첫번째와 마지막 item의 값을 가져올 수 있습니다. |
[.., var lastItem] | 하나 또는 그 이상의 item을 가진 모든 list와 일치합니다. 또한 반환 표현식에서 lastItem을 참조함으로서 마지막 item의 값을 가져올 수 있습니다. |
위 표를 참고하여 Program.cs에서는 int값을 가진 동일한 array를 정의하고 가장 적합한 pattern에 따라 응답 message를 생성하는 method를 작성합니다. 그리고 그 method로 해당 array를 전달하는 예제를 아래와 같이 작성합니다.
int[] exampleNumbers1 = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
int[] exampleNumbers2 = { 1, 2 };
int[] exampleNumbers3 = { 1, 2, 10 };
int[] exampleNumbers4 = { 1, 2, 3, 10 };
int[] exampleNumbers5 = { 2, 3, 5, 7, 11, 13, 17, 19, 23, 29 };
int[] exampleNumbers6 = { 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89 };
int[] exampleNumbers7 = { }; // { } 대신 Array.Empty<int>()를 대신 사용할 수 있음
int[] exampleNumbers8 = { 9, 7, 5 };
int[] exampleNumbers9 = { 9, 7, 5, 4, 2, 10 };
Console.WriteLine($"exampleNumbers1 : {CheckNumbers(exampleNumbers1)}");
Console.WriteLine($"exampleNumbers2 : {CheckNumbers(exampleNumbers2)}");
Console.WriteLine($"exampleNumbers3 : {CheckNumbers(exampleNumbers3)}");
Console.WriteLine($"exampleNumbers4 : {CheckNumbers(exampleNumbers4)}");
Console.WriteLine($"exampleNumbers5 : {CheckNumbers(exampleNumbers5)}");
Console.WriteLine($"exampleNumbers6 : {CheckNumbers(exampleNumbers6)}");
Console.WriteLine($"exampleNumbers7 : {CheckNumbers(exampleNumbers7)}");
Console.WriteLine($"exampleNumbers8 : {CheckNumbers(exampleNumbers8)}");
Console.WriteLine($"exampleNumbers9 : {CheckNumbers(exampleNumbers9)}");
static string CheckNumbers(int[] values) => values switch
{
[] => "빈 array",
[1, 2, _, 10] => "1, 2, 모든정수, 10.",
[1, 2, .., 10] => "1, 2, 공백을 포함한 모든 값, 10.",
[1, 2] => "1, 2",
[int item1, int item2, int item3] => $"{item1}, {item2}, {item3}.",
[0, _] => "0 으로 시작, 그 외 다른 수 하나",
[0, ..] => "0 으로 시작, 그 외 다른 수",
[2, .. int[] others] => $"2로 시작, 그 외 {others.Length} 갯수 만큼의 수",
[..] => "그 외 모든 item"
};
C# 6에서 Microsoft는 본문 member 함수를 사용할 수 있는 기능을 추가하였고 예제의 CheckNumbers 함수는 이 기능을 사용하여 구현되었습니다. 또한 C#에서 lambda는 => 문자를 함수의 반환값을 나타내는 데 사용합니다. 이들에 관한 더 자세한 사항은 추후에 살펴볼 것입니다.
예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
list pattern matching에 관한 더 자세한 사항은 아래 link를 통해서도 확인하실 수 있습니다.
https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/patterns#list-patterns
Patterns - Pattern matching using the is and switch expressions. - C# reference
Learn about the patterns supported by the `is` and `switch` expressions. Combine multiple patterns using the `and`, `or`, and `not` operators.
learn.microsoft.com
5) 후행 comma
위 예제에서는 생략하였지만 switch 문에서 마지막 item에 comma를 붙일 수도 있는데
[..] => "그 외 모든 item",
이런 경우 comma를 후행 comma(trailing commas)라고 합니다. 후행 comma는 선택적으로 사용할 수 있으며 compiler는 여기에 대해 아무런 오류를 발생시키지 않습니다.
C#을 포함한 대부분의 언어에서 후행 comma사용을 허용하고 있으며 다수의 item을 comma로 분리할 때(예를 들어 익명 개체, array, collection 초기자, 열거형, switch문 등을 선언할 때) C#은 마지막 item에도 comma를 붙여줄 수 있는데 이러한 방식은 후에 comma를 추가하거나 삭제하지 않고도 item의 순서를 쉽게 바꿀 수 있습니다.
switch문에서의 후행 comma의 사용해 대한 논의는 아래 link를 통해 찾아볼 수 있습니다.
https://github.com/dotnet/csharplang/issues/2098
후행 comma가 흔하게 사용됨에 따라 JSON 직렬화에도 선택적으로 이를 허용하는 option을 사용할 수 있습니다. 자세한 사항은 아래 link를 참고하시기 바랍니다.
https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonserializeroptions.allowtrailingcommas?view=net-9.0
6) Inline array
Inline array는 C# 12 version에서 도입된 것으로 .NET Runtime team에서 성능향상을 위해 사용된 고급 기능입니다. 일반적으로 사용되는 방법은 아니며 아래 lknk를 통해 좀 더 자세한 내용을 확인해 보실 수 있습니다.
https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-12.0/inline-arrays
7) Array 요약
Array는 사용하고자 하는 종류에 따라 이를 선언하는 방법에 약간씩 차이가 존재합니다. 아래 표는 이러한 차이점을 간단한 정리하고 있습니다.
종류 | 선언방법 |
1 차원 | datatype[] (예: string[]) |
2 차원 | datatype[,] |
3 차원 | datatype[,,] |
10 차원 | datatype[,,,,,,,,,] |
2차원 가변 배열 | datatype[][] |
3차원 가변 배열 | datatype[][][] |
Array는 다수의 item을 임시로 저장할 때 유용하게 사용할 수 있지만 collection을 사용하면 item을 동적으로 추가하거나 삭제할 때 더 유연성 있게 활용할 수 있습니다. Collection에 관해서는 추후에 자세히 알아볼 것입니다.
또한 모든 순차적 item은 ToArray확장 method를 통해 array로 변환할 수 있습니다. 관련된 내용 또한 추후에 자세히 다룰 것입니다.
만약 item에 대한 동적인 추가나 삭제가 필요 없는 경우라면 List<T>와 같은 collection대신 array를 사용하는 것이 좋습니다. Array는 연속적으로 저장된 item과 여기에 사용된 memory안에서 더 효휼적으로 동작하며 이는 성능향상이 더 유리하게 작용합니다.
5. Type 간 형변환
서로 다른 type의 변수 간에 값을 형변환하는 건 흔한 일입니다. 예를 들어 console에서 data의 입력은 내부적으로 문자열 type의 변수에 저장되므로 문자열로 이루어지지만 입력된 data자체를 저장하고 처리하는 방식에 따라 '날짜/시간'이나 '숫자'등 기타 다른 type으로 변환해야 하는 경우가 종종 발생합니다.
또한 같은 number type이라 하더라도 계산처리를 수행하기 전 정수와 소수 간에 변환이 필요한 경우도 있습니다.
형변환을 Converting 또는 Casting이라고 하며 변환하는 방식에 따라 암시적인 방법과 명시적인 방법 2가지가 존재합니다. 암시적인 방법은 자동으로 수행되며 변환과정에서 data가 바뀌거나 손실되지 않기 때문에 안전하게 처리됩니다.
하지만 명시적인 방법은 수동적으로 수행되는 것으로 경우에 따라 숫자의 정밀도처럼 일부 data의 손실이 발행할 수도 있습니다. 따라서 이런 경우 compiler에게 이러한 위험을 감수하고 형변환을 수행하겠다는 것을 명시적으로 알려주는 것입니다.
1) 숫자의 형변환
int 형의 변수를 double 변수로 암시적인 형변환을 하는 것은 어떠한 data의 손실도 발생되지 않기 때문에 안전하게 수행됩니다.
이와 관련된 예제를 만들어 보기 위해 CastingAndConverting이라는 이름의 console app project를 생성하고 project의 Program.cs에서 기존의 구문을 모두 제거한 뒤 아래 예제를 작성합니다. 예제는 int 변수와 double 변수를 선언하고 int 변수에 값을 할당한 뒤 int 변수의 값을 다시 double 변수에 할당함으로써 암시적 형변환을 수행하도록 합니다.
int i = 10;
double d = i;
Console.WriteLine($"i : {i}, d : {d}");
위 예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
이번에는 double 변수를 선언한 뒤 값을 할당하고 이를 int 변수에 다시 할당하여 암시적인 형변환을 수행하도록 하는 아래 예제를 작성합니다.
double d = 9.9;
int i = d;
Console.WriteLine($"i : {i}, d : {d}");
예제를 실행하면 이전과 다르게 아래 결과를 표시할 것입니다.
상기 오류는 Visual Studio라면 Error List에, VSCode라면 PROBLEMS window에 표시될 것입니다.
위와 같은 오류로 인해 우리는 double변수를 int변수로 형변환할 수 없음을 알 수 있습니다. 이는 소수점 이후의 값처럼 일부 data에 대한 손실이 발생할 수 있으며 때문에 잠재적으로 안전하지 않기 때문입니다. 이런 경우에는 괄호를 사용해 형변환하고자 하는 type을 명시적으로 지정해 주는 명시적인 형변환을 수행해야 합니다. 이때 변환하고자 하는 type은 괄호를 사용해 지정해 주는데 이 괄호를 'cast 연산자'라고 합니다. 명시적 형변환을 수행한다는 것은 개발자가 data손실에 따른 위험을 감수한다는 의미가 되므로 위 예제의 경우 소수점 이후의 부분은 사전 경고 없이 제거될 것입니다.
명시적 형변환을 사용하기 위해 위 예제에서 변수 d를 할당하는 부분을 변경하여 int로 명시적 형변환을 수행하로고 합니다.
int i = (int)d;
예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
이와 같은 명시적형변환방식은 큰 값과 작은 값사이를 형변환하는 경우에도 비슷하게 적용됩니다. 물론 이런 경우에도 큰 값이 작은 값으로 bit가 복사되는 과정에서 예상치 못한 결과로 바뀔 수 있음에 주의해야 합니다.
아래 예제는 long(64-bit) 변수를 선언하고 값을 할당한 뒤 이를 integer(32-bit) 변수로 할당합니다. 이때 작은 값인 경우 둘 다 잘 작동하겠지만 값이 너무 크다면 문제가 될 수 있습니다.
long l = 10;
int i = (int)l;
Console.WriteLine($"l : {l}, i : {i}");
l = long.MaxValue;
i = (int)l;
Console.WriteLine($"l : {l}, i : {i}");
위 예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
다시 값을 변경하여 이번에는 변수 l에 50억의 값을 할당하고
l = 5_000_000_000;
다시 예제를 실행합니다.
50억이라는 값은 32bit인 int에 들어갈 수 없기 때문에 대략 7억 5백만 정도의 값으로 overflow 됩니다. 이것은 정수의 binary표현과 관련된 것으로 integer의 overflow와 이에 대한 처리 방법은 잠시 후에 다시 다뤄볼 것입니다.
(2) Binary에서 음수를 표현하는 방식
이전 예제에서는 long.MaxValue값을 int변수에 할당한 결과 -1이 되었음을 볼 수 있었습니다. 음수 즉, 부호화된 숫자는 첫 번째 bit를 음수로 표현하는 데 사용합니다. 따라서 bit가 0이면 양수, 1이면 음수가 됩니다.
아래 예제는 이러한 상황을 나타낸 것으로 10진수와 2진수로 int의 최댓값을 표시하고 같은 format으로 8부터 -8까지 1씩 감소하면서 표시한 뒤 마지막으로 int에 대한 최솟값을 표시합니다.
Console.WriteLine("{0, 12} {0, 34:B32}", int.MaxValue);
for (int i = 8; i >= -8; i--)
{
Console.WriteLine("{0,12} {0,34:B32}", i);
}
Console.WriteLine("{0,12} {0,34:B32}", int.MinValue);
예제에서 사용한 12와 32는 오른쪽 정렬을 위한 column의 넓이를, B32는 32 column의 넓이로 0이 채워진 2진수를 의미합니다.
예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
예제를 보면 모든 양수값은 bit가 0으로 시작하며 모든 음수값은 bit가 1로 시작됨을 알 수 있습니다. 여기서 10 진수값 -1은 모든 bit가 1이 되었음을 볼 수 있는데 이것은 이전 예제에서 32bit int변수에 너무 큰 값을 할당할 때 -1이 되는 이유이기도 합니다. 하지만 항상 형변환의 결과가 잘못되었을 때 -1이 되는 것은 아닙니다. 좀 더 넓은 범위의 integer data type을 더 좁은 integer data type으로 변환할 때 남게 되는 대부분의 bit는 그대로 소거됩니다. 예를 들어 32bit integer를 16bit integer로 변환하면 전체 32bit 중 16bit가 소멸될 것이고 나머지 bit를 사용해 변환된 결과가 표현됩니다. 따라서 16bit integer를 16bit integer로 변환하는 것처럼 동등한 수준의 변환이라면 전체 bit가 결과를 나타내는 데 사용됩니다.
아래 예제는 long변수를 int변수로 형변환을 시도하는 것으로 이때 일부 bit가 소멸되면서 나머지 bit를 통해 어떻게 결과가 표시되는지를 알 수 있습니다.
long l = 0b_101000101010001100100111010100101010;
int i = (int)l;
Console.WriteLine($"{l,38:B38} = {l}");
Console.WriteLine($"{i,38:B32} = {i}");
예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
부호화된 숫자가 computer에서 어떻게 다뤄지는지 더 자세히 알아보고자 한다면 아래 link를 참고하시기 바랍니다.
https://en.wikipedia.org/wiki/Signed_number_representations
(3) System.Convert을 사용한 변환
형변환은 byte, int를 long으로, 또는 class와 이를 상속한 하위 class사이를 변환하는 것처럼 비슷한 type으로만 가능합니다. long을 string이나 byte 혹은 DateTime으로는 단순한 형변환을 통해서는 가능하지 않습니다.
비슷하지 않은 type 간 형변환은 System.Convert type을 사용하는 것입니다. System.Convert type은 숫자 type은 물론, boolean, string, DateTime 등 거의 모든 type에 대한 형변환을 수행할 수 있습니다.
System.Convert을 사용해 보기 위해 Program.cs에서 기존이 구문을 모두 삭제하고 System.Convert class를 아래와 같이 import 합니다.
using static System.Convert;
csproj project file에서 <Using Include="System.Convert" Static="true" /> 요소를 추가하면 위와 같은 import문을 사용하지 않아도 됩니다.
그런 다음 double변수를 선언하고 값을 할당한 뒤 이를 integer로 변환하고 두 변수의 값을 표시하는 아래의 예제를 작성합니다.
double d = 8.7;
int i = ToInt32(d);
Console.WriteLine($"d is {d}, i is {i}");
변환에서 casting와 converting이라는 2가지 용어가 사용되는데 converting은 double값 8.7에서 소수점 이하 부분을 버리는 대신 9로 올림을 시도하며, casting은 변환과정에서 overflow를 허용하지만 converting은 예외를 발생시킨다는 차이가 있습니다.
2) 반올림 및 기본 반올림 규칙
이전 예제를 통해 우리는 System.Convert method가 실수의 소수점이하 자리를 제거함으로써 반올림 내지는 반내림을 하게 되는 결과를 확인했었습니다. 근사치를 허용하는 경우 이것은 아무런 문제가 되지 않지만 정밀한 조정을 필요로 한다면 반내림과 반올림이 적용되는 규칙을 알아둘 필요가 있습니다.
일반적으로 배워온 이 규칙을 상기해 보자면 소수점이하가 .5 이상이라면 반올림을, 그보다 더 적다면 반내림을 할 수 있다고 배웠습니다. 하지만 이러한 규칙은 초등학교 저학년에서 양수만 다루는 경우에 해당되는 것으로 음수를 포함하게 되면 이러한 규칙은 버려야 합니다. 이와 관련해 .NET에서는 결과를 명확하게 하기 위해 AwayFromZero, ToZero, ToEven, ToPositiveInfinity, ToNegativeInfinity와 같은 열거값을 사용하고 있습니다.
아래 예제는 double 값의 array를 선언하고 값을 할당한 뒤 각각의 값을 다시 integer로 변환하고 그 결과를 표시하도록 하고 있습니다. 이 예제를 통해 간단하게나마 반올림/내림 규칙이 어떻게 적용되는지 확인해 볼 수 있습니다.
double[] values = { 1.5, 2.3, 2.8, 9.5, 10.5, -1.5, -2.3, -2.8, -10.5, -10.5 };
Console.WriteLine("원본 값 | Convert.ToInt32");
Console.WriteLine("-------------------------");
foreach (double value in values)
{
int converted = Convert.ToInt32(value);
Console.WriteLine($"{value,6} | {converted,10}");
}
위 예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
C#에서 반올림을 적용하는 규칙은 위에서 언급한 것과는 약간의 차이가 있습니다. 우선 첫 번째로 소수점이 .5보다 작으면 항상 0으로 반올림을 하며 두번째로 소수점이 .5보다 크면 0에서 반올림을 수행합니다. 마지막 세 번째로 소수점이 .5이며 정수 부분이 양수면 0에서 반올림을, 음수면 0으로 반올림을 수행합니다.
이러한 규칙을 은행가의 반올림규칙이라고 하며 일방적으로 0으로 또는 0에서 반올림이 수행되는 상황을 막고 이를 교차되도록 함으로써 한쪽으로 값이 치우치는 상황을 막을 수 있으므로 선호되는 방식이기도 합니다. 하지만 JavaScript와 같은 기타 다른 언어는 이미 언급한 단순한 규칙을 유지하기도 합니다.
(1) 반올림 규칙 조정하기
반올림 규칙은 Math class의 Round method를 사용해 바꿀 수 있습니다.
위 예제에서 항상 반올림되는 규칙을 적용하기 위해 아래와 같이 예제를 변경합니다.
double[] values = { 1.5, 2.3, 2.8, 9.5, 10.5, -1.5, -2.3, -2.8, -10.5, -10.5 };
Console.WriteLine("원본 값 | Math.Round");
Console.WriteLine("-------------------------");
foreach (double value in values)
{
double converted = Math.Round(value: value, digits: 0, mode: MidpointRounding.AwayFromZero);
Console.WriteLine($"{value,6} | {converted,10}");
}
예제를 실행하면 다음과 같은 결과를 표시합니다.
다른 programming언어의 경우에도 반올림을 처리해야 하는 상황이 온다면 반올림 수행규칙이 어떻게 되는지 확인해 볼 필요가 있습니다. 어쩌면 예상과는 다른 결과를 가져올지도 모르기 때문입니다. Math.Round에 관해서는 아래 link를 통해 좀 더 자세히 확인해 볼 수 있습니다.
https://learn.microsoft.com/en-us/dotnet/api/system.math.round?view=net-9.0
3) 임의의 type에서 문자열로의 변환
Programming에서 일반적으로 가장 흔하게 필요한 변환 중에 하나가 어떤 type으로부터 사람이 읽을 수 있도록 하기 위한 문자열로의 변환입니다. 기본적으로 C#의 모든 type은 System.Object class로부터 상속한 ToString이름의 method를 갖고 있으며 이를 통해 자기 자신을 문자열로 표현할 수 있습니다.
ToString method는 자신의 현재값을 문자열로서 표현하고자 하는 것인데 일부 type에서는 자신의 값을 문자열로 표현하는 것이 그리 큰 의미가 없기 때문에 대신 자신의 이름과 namespace를 반환하기도 합니다.
일부 type을 문자열로 변환해 보기 위해 아래 예제를 작성합니다. 예제는 몇 개의 변수를 선언하고 문자열로 변환을 수행한 뒤 이를 console에 표시하도록 합니다.
int i = 12;
Console.WriteLine(i);
bool b = true;
Console.WriteLine(b);
DateTime dt = DateTime.Now;
Console.WriteLine(dt);
object o = new();
Console.WriteLine(o);
위 예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
어떤 type을 WriteLine method로 바로 전달하면 이를 암시적으로 string으로 변환하게 됩니다. 따라서 위 예제의 경우라면 명시적으로 ToString method를 굳이 호출할 필요가 없습니다. 그럼에도 불구하고 예제에서 굳이 ToString method를 사용한 건 문자열로의 변환방법과 boxing이라는 연산과정을 피할 수 있어서 성능에도 도움이 될 수 있다는 것을 설명하기 위해서입니다.
4) binary 개체에서 문자열로의 변환
Image나 동영상과 같은 binary 개체를 저장하거나 다른 곳으로 전송하고자 할 때 원시 bit를 그대로 전송하면 이를 전송하는 network protocol이나 이를 받아서 읽는 운영체제와 같은 것에서 어떻게 bit를 잘못해석할지 알 수 없기 때문에 원시 bit를 그대로 전송하는 것은 좋은 방법이 아닐 수 있습니다.
따라서 이를 실제 수행하기 위한 비교적 안전한 방법은 이를 문자열로 변환하는 것이며 이때 사용되는 방법이 Base64 encoding입니다. Base64는 임의의 byte를 64개라는 일련의 특정한 문자들을 사용해 text로 변환하는 encoding scheme로서 data를 전송하는데 광범위하게 사용되고 있으며 오랫동안 다양한 방법을 통해 지원되어 왔습니다. C#에서도 역시 Base64를 사용할 수 있으며 이를 위해 2개의 짝으로 구성된 ToBase64 String과 FromBase64 String이라는 2개의 method를 제공하고 있습니다.
해당 method를 어떤 방식으로 사용할 수 있을지 알아보기 위해 아래 예제를 작성합니다. 예제는 random 하게 구성된 byte array를 생성하고 각 byte를 Console에 표시합니다. 그리고 동일한 byte를 Base64로 변환하여 이 값을 역시 Console에 표시하도록 합니다.
byte[] byteArray = new byte[128];
Random.Shared.NextBytes(byteArray);
for (int index = 0; index < byteArray.Length; index++)
{
Console.Write($"{byteArray[index]:X2} ");
}
Console.WriteLine();
string encoded = Convert.ToBase64String(byteArray);
Console.WriteLine($"Byte to Base64: {encoded}");
기본적으로 int값은 Base10이라는 10진수 표시법을 사용합니다. 원하는 방식으로 표시되지 않으면 X2와 같이 format code를 사용하여 16진수 표기법으로 값의 형식을 지정할 수 있습니다.
(1) URL과 Base64
Base64는 유용하긴 하지만 +나 /와 같이 여기서 사용되는 일부 문자의 경우 특정한 경우에 문제를 일으킬 수 있습니다. 가장 대표적인 예가 URL인데 URL에서 query string을 사용하는 경우 이와 같은 문자는 특별한 의미를 갖게 되기 때문입니다.
이런 경우를 위해 Base64 Url을 대신 사용할 수 있습니다. Base64 Url은 Base64와 비슷하지만 약간의 다른 문자를 사용함으로써 URL과 같은 context에 적절하게 사용될 수 있습니다.
Base64 Url에 관심이 있다면 아래 link를 통해 더 많은 내용을 참고하실 수 있습니다.
https://base64.guru/standards/base64url
.NET 9에서는 새로운 Base64 Url class를 도입하여 Base64Url scheme를 사용하는 data의 encoding과 decoding을 위한 다양한 최적화 method를 사용할 수 있습니다. 이를 통해 우리는 Base64 Url로 표현된 임의의 byte data를 아래와 같이 변환할 수 있습니다.
ReadOnleySpan<byte> bytes = ...;
string encoded = Base64Url.EncodeToString(bytes);
5) Parse
문자열에서 숫자나 날짜/시간등의 data를 읽어 들이는 경우 또한 변환이 필요한 흔한 경우라 할 수 있습니다.
이때는 ToString과 상반되는 Parse method를 사용할 수 있는데 이 method는 모든 숫자 type과 DateTime type을 포함하여 일부 type에만 존재합니다.
관련된 예제를 작성해 보기 위해 우선 Program.cs에서 문화권을 적용하는 namespace를 아래와 같이 import 합니다.
using System.Globalization;
그런 다음 아래와 같이 string에서 integer와 DateTime값을 읽어 들이고 console에 그 결과를 출력하는 구문을 작성합니다.
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("ko-KR");
int age = int.Parse("38");
DateTime birthday = DateTime.Parse("1980-12-01");
Console.WriteLine($"He is {age} years old.");
Console.WriteLine($"My birthday is {birthday}.");
Console.WriteLine($"My birthday is {birthday:D}.");
위 예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
CultureInfo를 사용하지 않으면 예제를 실행하는 computer의 문화권을 따르게 됩니다.
기본적으로 DateTime값은 짫은 날짜와 시간형식을 표시합니다. 따라서 필요하다면 'D'와 같은 format code를 사용하여 긴 날짜 형식을 사용하는 날짜 부분만을 표시할 수 있습니다.
예제에서의 'D'외에 더 많은 DateTime의 표준화 지정자를 사용하려면 아래 link를 참고하시기 바랍니다.
https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings#table-of-format-specifiers
(1) TryParse
Parse method의 한 가지 문제점은 문자열에서 원하는 type으로 변환이 실패할때 예외를 발생시킬 수 있다는 것입니다.
예를 들어 아래와 같이 문자를 포함하고 있는 문자열을 integer값으로 Parse를 통해 변환을 시도하려는 경우
int age = int.Parse("서른다섯");
이 예제는 다음과 같은 예외를 일으키게 됩니다.
이러한 예외를 적절히 처리하기 위한 한가지 방법은 TryParse를 사용하는 것입니다. TryParse는 문자열의 변환을 시도한 뒤 변환이 가능하면 true를 그렇지 않다면 false를 반환하는데 이러한 결괏값을 통해 경우에 따라 별도의 처리를 진행할 수 있습니다. 예외는 상대적으로 더 많은 처리비용을 필요로 하므로 가능한 한 발생하지 않도록 하자는 것이 이 method의 취지입니다.
해당 method를 사용할 때는 out keyword를 필수로 사용해야 하는데 TryParse method가 변환하는데 아무런 문제가 없는 경우(결과가 true인 경우) 실제 변환작업을 수행하여 그 결과를 out 변수에 담아두기 때문입니다.
아래 예제는 이러한 처리를 나타내는 것으로 사용자에게 자신의 나이를 입력하도록 요구한 뒤 그 결과를 표시하도록 합니다.
Console.Write("당신의 나이는?");
string? input = Console.ReadLine();
if (int.TryParse(input, out int age))
{
Console.WriteLine($"Your age : {age}");
}
else
{
Console.WriteLine("입력값을 정수로 변환할 수 없습니다.");
}
예제를 실행한 뒤 적절한 숫자를 입력하면 아래와 같은 결과를 표시할 것입니다.
이번에는 예제를 다시 실행한 뒤 '서른셋'을 입력해 봅니다. 그러면 이전과 다른 결과를 표시할 것입니다.
TryParse 외에 Ssytem.Convert를 사용하여 문자열을 다른 값으로 변환할 수도 있습니다. 그러나 이것 역시 Parse처럼 변환에 실패하는 경우 예외를 발생시키게 됩니다.
6. 예외처리
위 예제에서는 형변환 시 일부 예외가 발생할 수 있음을 살펴본 적이 있습니다. 어떤 programming언어에서는 무엇인가 잘못 처리되었을 때 error code를 반환하기도 하지만 .NET은 실패의 경우를 되도록 상세하게 보고하기 위해 좀 더 우아하게 설계된 예외(Exception)를 사용하고 있습니다.
다양한 값을 반환하는 다른 system의 경우 여러 용도의 여러 값을 사용할 수도 있는데, 예를 들어 양수값이면 table의 row 수를 나타내는 것일 수 있지만 음수값이라면 오류상황에 대한 오류 code가 될 수 있습니다.
일부 third-party library를 사용하면 성공과 실패를 나타낼 수 있는 result type을 더 쉽게 정의할 수 있기도 하며 많은 .NET 개발자들 역시 예외대신 이와 같은 방법을 더 선호하기도 합니다.
예외가 발생하면 thread는 중지되는데 만약 이러한 상황이 try-catch문안에서 발생했다면 그 안에서(catch에서) 예외가 처리될 수 있습니다. 그런데 만약 현재 method에서 예외가 처리되지 않으면 call stack에 따라 상위의 method에서 처리될 수 있도록 거슬러 올라가게 됩니다.
지금까지 본 것처럼 console app에서 예외가 발생하면 잠재적으로 손상된 상태의 code를 계속 실행하기보다 stack trace를 포함해 예외에 대한 message를 출력하고 code의 실행을 정지하게 되면서 application을 최종 중단시키게 됩니다. 이때 Code에서는 발생한 상황을 이해하고 이를 적절히 수정할 수 있는 예외만 포착하여 처리할 수 있습니다.
가능한 한 if문과 같은 방법으로 사전에 code를 검사하여 예외가 발생할 수 있는 code의 작성을 피하는 것이 좋습니다. 그러나 그럴 수 없는 상황이라면 가급적 code를 호출하는 더 상위의 component에서 예외를 포착하도록 해야 합니다.
.NET 9에서 예외처리는 NativeAOT 예외 처리 model에 기반한 새로운 구현을 사용합니다. Microsoft에 의하면 이는 2~4배 이상의 더 높은 성능을 가지는 것으로 알려져 있습니다.
1) try block
특정 구문이 error를 유발할 가능성이 있을 때, 이 구문을 try block으로 깜 싸놓을 수 있습니다. 예를 들어 문자열로부터 숫자를 parsing 하는 경우 잘못된 문자열로 error가 발생할 수 있으므로 이 부분을 try block으로 묶어두는 것입니다. catch block은 try block안에서 예외(error)가 발생했을 때 실행되는 부분입니다.
예제를 작성하기 위해 Exeptions라는 console app project를 생성합니다. 그리고 Program.cs에서 기존의 문을 모두 삭제한 뒤 아래 예제를 작성합니다. 예제는 사용자에게 나이입력을 요청한 뒤 입력된 내용을 console에 표시하도록 하는 것인데, 참고로 catch block에는 아무것도 작성하지 않았습니다.
Console.Write("당신의 나이는?");
string? input = Console.ReadLine();
try
{
int age = int.Parse(input);
Console.Write(age.ToString());
}
catch
{ }
상기 예제에 compile을 시도하면 다음과 같은 경고 message를 볼 수 있습니다.
.NET 6 이후부터는 기본적으로 null가능한 참조 type을 사용할 수 있게 되었습니다. 따라서 이러한 경고성 message를 다수 볼 수 있는데 실제 배포되는 code라면 null을 확인하고 가능한 한 이를 적절히 처리할 수 있는 code를 아래와 같이 추가해야 합니다.
if (input is null)
{
//null인 경우의 처리
}
하지만 예제는 학습목적이므로 실제 위와 같은 null check code를 사용하지 않을 것입니다. 실제 앞으로의 예제를 compile 하는 과정에서도 위와 동일한 경고 message를 수 없이 보게 될 것이지만 예제에 한하여 무시해도 좋다는 것을 미리 말씀드립니다. null처리에 관한 자세한 사항은 추후에 자세히 알아볼 것입니다.
사실 위 예제의 경우 input이 null 되는 건 불가능합니다. 사용자가 아무런 내용도 입력하지 않고 Enter만 누르게 된다면 ReadLine method는 빈(empty) 문자열값을 반환할 것이기 때문입니다. 따라서 이 경우 굳이 경고 message를 표시할 필요가 없는데 이러한 내용을 compiler에게 알려주기 위해서는 아래와 같이 code를 작성할 수 있습니다.
int age = int.Parse(input!);
input을 input!으로 바꾼 것으로 이렇게 하면 compiler의 경고를 해제할 수 있습니다.
여기에서 사용한 '!'를 null 허용 연산자라고 하며 이와 관련해 compiler가 경고 message를 표시하지 않도록 합니다. null 허용 연산자는 runtime에는 영향을 주지 않습니다. 그런데 만약 runtime에서 null이 평가된다면 예외가 발생할 것입니다.
예제를 실행하고 30을 입력하면 다음과 같은 결과를 표시할 것입니다.
그런데 다음과 같이 '서른'이라고 입력하면 결과를 달라집니다.
Code가 실행된 뒤 error 예외가 걸리게 되면 일반적으로 표시되는 stack trace가 표시되지 않고 console app은 그대로 실행이 종료하게 됩니다. 조용히 마무리된 것처럼 보여 이전보다 나아진 것 같지만 그래도 발생한 error의 유형을 확인해 볼 수 있는 것이 더 유용할 수 있습니다.
실제 production 환경에서는 절대 빈 catch문을 사용해서는 안됩니다. 눈에만 보이지 않을 뿐이며 잠재적인 문제점을 계속 감추고 있기 때문입니다. 직접적으로 발생할 수 있는 문제를 예측할 수 없다면 사용자에겐 따로 표시하지 않더라도 최소한 log를 남겨둘 수 있도록 조치해 둘 필요가 있습니다. 또는 예외를 상위 수준의 code로 다시 던져 이를 처리할 수 있도록 해둘 수도 있습니다. logging에 관해서는 추후에 자세히 알아볼 것입니다.
2) 모든 예외 잡아내기
발생할 수 있는 모든 type의 예외에 대한 정보를 확인하기 위해 catch block에서 System.Exception type의 변수를 선언할 수 있습니다.
아래 예제는 이전예제에서 catch block에 예외변수 선언을 추가하고 이를 사용해 관련 정보를 console에 표시하도록 변경한 것입니다.
try
{
int age = int.Parse(input);
Console.Write(age.ToString());
}
catch (Exception ex)
{
Console.WriteLine($"Type : {ex.GetType()}, Info : {ex.Message}");
}
예제를 실행하고 이전과 동일하게 '서른'이라고 입력하면 아래와 같은 결과를 표시할 것입니다.
3) 특정 예외만 잡아내기
만약 code안에서 특정 type에 대한 예외가 발생할 수 있다는 것을 예측할 수 있고 해당 예외가 발생했을 경우 필요한 처리를 수행해야 한다면 다음 예제와 같이 해당 type의 catch를 설정하여 처리할 수 있습니다.
catch (FormatException)
{
Console.WriteLine("나이는 정수로만 입력되어야 합니다.");
}
catch (Exception ex)
{
Console.WriteLine($"Type : {ex.GetType()}, Info : {ex.Message}");
}
예제를 실행하고 다시 '서른'을 입력하면 다음과 같은 결과를 나타낼 것입니다.
예제에서 Exception에 대한 catch를 유지하는 이유는 FormatException 외에 다른 예외가 발생했을 경우에 대한 대비입니다. 예를 들어 예제를 실행하고 '9999999999'와 같이 입력했을 경우 아래와 같이 다른 결과를 표시할 수 있습니다.
위와 같은 예외는 overflow exception으로서 해당 catch를 아래와 같이 추가할 수 있습니다.
catch (FormatException)
{
Console.WriteLine("나이는 정수로만 입력되어야 합니다.");
}
catch (OverflowException)
{
Console.WriteLine("값이 너무 큽니다.");
}
catch (Exception ex)
{
Console.WriteLine($"Type : {ex.GetType()}, Info : {ex.Message}");
}
예제를 실행하고 이전과 동일한 값을 입력하면 아래와 같은 결과를 표시할 것입니다.
예제와 같이 여러 catch를 사용하는 경우 이에 대한 순서 역시 중요한 부분입니다. 순서는 예외 type에 대한 계층적 상속과 관련되어 있는데 상속에 관해서는 추후에 알아보기로 하고 우선은 순서가 잘못되면 예외가 제대로 잡히지 않을 수 있는데 이 때문에 compiler에 의해 build 오류가 발생할 수 있다는 것을 염두에 두시기 바랍니다.
과도한 catch 사용은 피하는 것이 좋습니다. 가급적이면 예외는 상위의 call stack으로 전파되어 예외가 처리되어야 하는 방식을 변경할 수도 있는 상황에 대해 더 많은 정보가 알려진 수준에서 처리될 수 있도록 하는 것이 좋습니다.
4) Catch에 filter적용하기
catch에서는 when keyword를 사용하여 아래 예제와 같이 filter를 추가할 수도 있습니다.
catch (FormatException) when (input.StartsWith('만'))
{
Console.WriteLine("만나이는 입력이 불가능합니다.");
}
catch (FormatException)
{
Console.WriteLine("나이는 정수로만 입력되어야 합니다.");
}
string type에서 StartsWith method는 큰따옴표(")를 통해 전달할 수 있는 string값과 홑따옴표(')를 사용해 전달할 수 있는 char값을 사용할 수 있는 version의 method(overload)를 갖고 있습니다. 예제에서의 '만'과 같이 하나의 문자만을 확인해야 한다면 char overload version을 사용하는 것이 훨씬 효율적입니다.
7. Overflow 확인하기
이전에 우리는 data type의 변한에 대해 알아보면서 number type 간 형변환의 경우도 같이 살펴본바 있습니다. number type간 형변환의 경우는 long변수에서 int변수로의 변환처럼 상황에 따라 일부 정보가 손실될 수도 있는데 반대로 type에 반해 값이 너무 클 수도 있습니다. 그리고 이런 경우를 우리는 값이 overflow 되었다고 표현합니다.
1) checked 문을 사용해 overflow 예외 일으키기
.NET Application이 data를 처리할 때 overflow예외가 발생하면 이를 드러내지 않습니다. 이는 기본적인 동작으로 성능에 관한 이유 때문인데, 만약 overflow가 발생하는 순간 예외를 발생시켜야 한다면 checked문을 사용할 수 있습니다.
아래 예제는 이러한 동작을 확인해 보기 위한 것으로 int 변수의 초기값을 최댓값에서 -1으로 설정하고 그 뒤에 1씩 값을 증가시킨뒤 해당 값을 출력합니다. 이때 값이 최대치에 도달하면 overflow가 발생하게 되고 값이 최소값으로 설정된 후 다시 1씩 증가하는 동작을 수행하도록 합니다.
Program.cs에서 int변수를 선언한 뒤 최대값에서 1 작은 값을 할당합니다. 그리고 이 값을 1씩 증가시킨 뒤 console에 해당값을 표시하는데 이를 3번에 걸쳐 수행하도록 합니다.
int i = int.MaxValue - 1;
Console.WriteLine($"Current value: {i}");
i++;
Console.WriteLine($"{i}");
i++;
Console.WriteLine($"{i}");
i++;
Console.WriteLine($"{i}");
위 예제를 실행하면 다음과 같은 결과를 표시할 것입니다. 예제에서 보듯 overflow는 별다른 예외를 일으키지 않고 내부적으로 조용히 처리되며 최솟값(음수로 표현할 수 있는 가장 작은 값)으로 바뀌게 됩니다.
이제 checked문으로 예제를 아래와 같이 예제를 변경합니다.
checked
{
int i = int.MaxValue - 1;
Console.WriteLine($"Current value: {i}");
i++;
Console.WriteLine($"{i}");
i++;
Console.WriteLine($"{i}");
i++;
Console.WriteLine($"{i}");
}
다시 예제를 실행하고 결과를 보면 overflow 예외가 발생하였음을 알 수 있습니다.
이런 경우에도 다른 예외처럼 해당 예제에 try문을 적용할 수 있으며 위와 같은 상황보다 더 유연한 처리를 적용할 수 있습니다.
try
{
checked
{
int i = int.MaxValue - 1;
Console.WriteLine($"Current value: {i}");
i++;
Console.WriteLine($"{i}");
i++;
Console.WriteLine($"{i}");
i++;
Console.WriteLine($"{i}");
}
}
catch (OverflowException ex)
{
Console.WriteLine(ex + "\n값이 초과되었습니다.");
}
예제를 실행하면 다음과 같이 결과를 표시할 것입니다.
2) unchecked 문을 통해 compiler overflow 확인 무력화 하기
위에서 알아본 내용은 runtime에서 기본적인 overflow동작을 확인하고 이러한 동작을 바꾸기 위해 어떻게 checked문을 사용하는지에 관한 내용이었습니다. 반면 지금 알아볼 내용은 compile-time overflow 동작에 관한 것이며 해당 동작을 바꾸기 위해 어떻게 unchecked문을 사용할 수 있는지에 관한 것입니다.
이에 관련된 unchecked keyword는 code block안에서 compiler에 의해 수행되는 overflow 확인을 하지 않도록 하기 위한 것입니다.
ovflow check 동작을 확인해 보기 위해 우선 아래 예제를 작성합니다. 그리고 해당 예제에 compile을 시도하면 compiler는 해당 문이 overflow가 발생됨을 알 수 있기 때문에 예제를 compile 하지 않을 것입니다.
이러한 확인과정을 생략하기 위해 Console에 결괏값을 표시한 뒤 다시 1씩 감소하여 표시하고 해당 구문전체에서 unchecked를 적용합니다.
unchecked
{
int i = int.MaxValue + 1;
Console.WriteLine($"Current value: {i}");
i--;
Console.WriteLine($"{i}");
i--;
Console.WriteLine($"{i}");
}
예제를 실행하면 다음고 같은 결과를 표시할 것입니다.
물론 명시적으로 unchecked를 사용해 확인절차를 생략하게 하는 건 반대로 고의적으로 overflow를 허용하는 것이기 때문에 흔하지 않은 경우이긴 하지만 적어도 overflow가 내부적으로 어떻게 처리되는지 그리고 필요한 경우 이에 대한 대응방안을 어떻게 만들지 생각해 볼 수 있는 기회가 될 수 있을 것입니다.
'.NET > C#' 카테고리의 다른 글
[C# 13과 .NET 9] 2. C# (0) | 2025.05.13 |
---|---|
[C# 13과 .NET 9] 1. .NET 개요 (0) | 2025.03.04 |
[C# 12와 .NET 8] 11. LINQ (0) | 2024.03.11 |
[C# 12와 .NET 8] 10. Entity Framework Core (1) | 2024.03.07 |
[C# 12와 .NET 8] 9. File, Streams, Serialization (0) | 2024.02.28 |