시프트 연산자
시프트(shift) 연산자는 비트 단위로 데이터를 제어할 때 사용한다.
데이터가 표현되는 2진수를 생각하면 된다. 정수형으로 숫자 38은 2진수로 100110에 해당한다.
10진수 38
- 32비트 2진수 00000000 00000000 00000000 00100110
시프트 연산자는 2진수 상태의 값을 좌/우로 비트를 밀어내는 연산자다.
숫자 38: 2진수 00000000 00000000 00000000 00100110
좌측으로 비트를 2번 시프트 : 38 << 2
00000000 00000000 00000000 10011000: 결과값 152
우측으로 비트를 2번 시프트 : 38 >> 2
00000000 00000000 00000000 00001001 결과값 9
고급 언어에서는 비트 연산이 그다지 자주 사용되는 편은 아니지만, 일부 상황에서 유용한 때가 있다.
- 좌측 시프트를 한 번 할 때마다 2를 곱하는 효과가 있다. 따라서 숫자 38을 좌측으로 2번 시프트하면 4를 곱하는 것과 같으므로 38 * 4 == 38 << 2 == 152가 된다.
- 우측 시프트를 한 번 할 때마다 2로 나누는 효과가 있다. 따라서 숫자 38을 우측으로 2번 시프트하면 4로 나누는 것과 같으므로 38 / 4 == 38 >> 2 == 9.5가 되지만 정수로는 9가 된다.
- 시프트는 하위 바이트의 숫자를 잘라내는 역할도 한다. ex) 4 바이트 정수에서 상위 2바이트의 값을 알아내기 위해 다음과 같이 16번의 우측 시프트 연산을 할 수 있다. int n = 1207967792; int high2BytesResult = n >> 16;
우측 시프트 연산에서 주의할 점 : 최상위 비트(MSB: most significant bit)가 부호의 유무에 따라 처리 방식이 달라진다.
ex) -38이 부호 있는 32비트 정수 타입에 담겨 있을 때를 살펴본다.
숫자 -38 : 2진수 11111111 11111111 11111111 11011010
부호 있는 32비트 정수의 최상위 비트는 부호를 나타내므로 시프트 연산을 할 때 보존되어야 한다.
-38 >> 2 결과 : 11111111 11111111 11111111 11110110
반면 숫자 2281709616을 부호 없는 32비트 정수 타입에 담아 시프트 연산을 하면 처리 결과가 달라진다.
숫자 2281709616 : 2진수(1000100 00000000 00100000 00110000)
부호 없는 32비트 정수의 최상위 비트는 데이터의 일부이므로 시프트 연산에서 보존되지 않는다.
2281709616 >> 2 결과 : 00100010 00000000 00001000 00001100
좌측 시프트 연산은 이를 고려할 필요가 없지만, 우측 시프트 연산을 할 때는 반드시 대상의 부호 유무를 고려해야 의도한 결과를 올바르게 얻을 수 있다.
비트 논리 연산자
- 조건 논리 연산자(&&, ||, ^)는 피연산자가 bool 타입이고 연산의 결과도 bool 타입이다.
- 비트논리 연산자는 피연산자와 연산 결과가 숫자형이다. 즉, 피연산자의 숫자값을 2진수 상태로 비트 연산을 수행한다.
조건 논리 연산자에 대응하는 비트 논리 연산자는 다음과 같다.
조건 논리 연산자 | 비트 논리 연산자 | 의미 |
&& | & | 논리곱 |
|| | | | 논리합 |
^ | ^ | 논리 XOR(연산자가 동일) |
! | ~ | 비트 보수 연산자 |
비트 논리 연산자가 사용되는 대표적인 경우는 각 비트의 값을 특정 상태를 나타내는 의미로 사용할 때 이다.
ex) 8비트 부호 없는 정수(byte) 타입은 각 비트에 On/Off로 나타낼 수 있으므로 총 8개의 상태를 지정할 수 있다.
- C#에서는 위와 같은 상태값 처리를 굳이 비트 연산으로 하기보다는 [Flags] 특성이 지정된 enum 타입으로 대체하는 것이
일반적이다.
연산자 우선순위
수학에서도 곱하기 연산자가 더하기 연산자보다 우선 순위가 높게 매겨진 것처럼 C# 언어에서 사용하는 연산자 간에도 우선 순위가 책정되어 있다.
if( true || flase && false)
{
Console.WriteLine("evaluated.");
}
||연산자가 && 연산자보다 우선순위가 높다면 ((true || false) && false)의 단계로 실행되므로 결과는 false가 된다.
하지만 C#에서는 && 연산자의 우선순위가 더 높으므로 식의 평가 순서는 (true || (flase && false))가 되어 true가 된다.
따라서 연산자를 한 줄에 연속으로 쓰는 경우에는 우선순위를 따져보는 것이 좋지만, 그보다는 개발자의 의도를 확실히 전달하기 위해 괄호 연산자를 사용해 명시적으로 연산 우선순위를 지정해 주는 것이 가독성을 위해서도 바람직하다.
예약어
- 연산 범위 확인: checked, unchecked
정수 계열 타입의 산술 연산을 하거나 서로 다른 정수 타입 간의 형 변환을 하게 되면
표현 가능한 숫자의 범위를 넘어서는 경우가 발생한다.
ex) 2바이트 정수인 short 타입은 -32,768 ~ +32,767 사이의 값을 표현할 수 있는데, 그 수를 넘어서는 상황이 발생할 경우
반대의 경우도 동일한 현상이 발생한다.
short c = -32768;
c--;
Console.WriteLine(c); //출력 결과: 32767
int n = -32769;
c = (short)n;
Console.WriteLine(c); //출력 결과: 32767
- 이런 식으로 데이터가 상한 값을 넘어 하한 값으로, 반대로 하한 값에서 상한 값으로 넘는 것을 오버플로(overflow)라고 한다.
- 부동 소수점 연산에서 0에 가깝지만 정밀도의 한계로 표현할 수 없을 때 아에 0으로 만들어 버리는 것을 언더플로(underflow)라고 한다.
- .NET에서는 언더플로에 대한 예외 처리는 제공하지 않는다.
정수형과 부동소수점에서의 overflow / underflow는 의미가 조금 다르다.
정수형 (int, long 같은 정수 타입)
- Overflow (오버플로)
- 값이 상한값(최댓값)을 초과하거나 하한값(최솟값)을 벗어나는 경우
- checked 영역에서는 OverflowException 발생,
unchecked 영역에서는 그냥 값이 wrap-around(순환)됨.
int x = int.MaxValue; // 2147483647
int y = x + 1; // -2147483648 (오버플로 발생)
- Underflow (언더플로)
- 정수형에서는 보통 최솟값에서 더 내려가는 경우도 오버플로라고 같이 표현한다.
- 즉, **정수형에서는 상·하 경계를 넘는 걸 통틀어 "overflow"**라고 한다.
int x = int.MinValue; // -2147483648
int y = x - 1; // 2147483647 (wrap-around, 즉 오버플로)
부동소수점형 (float, double 같은 실수 타입)
- Overflow (오버플로)
- 값이 너무 커서 표현 불가능 → Infinity(무한대)로 변환됨.
double d = double.MaxValue * 2; // 결과: Infinity
- Underflow (언더플로)
- 값이 너무 작아(0에 가까워서) 표현 불가능할 때 → 0으로 처리됨.
double d = 1e-400; // 결과: 0 (double이 표현할 수 있는 최소보다 작음)
정리
- 정수형 → 범위 벗어나면 무조건 "overflow" (상한·하한 둘 다 포함).
- 부동소수점 → 너무 크면 "overflow"(Infinity), 너무 작으면 "underflow"(0).
오버플로우가 개발자가 의도한 동작인가?
산술 연산은 경우에 따라 결과값이 매우 민감하게 받아들여질 수 있다. 화폐 연산에서 이런 식의 연산 결과가 나왔는데 이 사실을 모르고 계속 진행하면 문제가 될 수 있다.
이 때문에 개발자는 연산식에서 오버플로가 발생한 경우 C#으로 하여금 오류를 발생시키라고 명시할 수 있는데,
이 때 checked 예약어가 사용된다.
- 산술 연산 코드에 checked를 적용하면 타입이 지정한 범위를 넘어서는 연산을 시도하는 경우 System.OverflowException 오류가 발생하면서 프로그램 실행이 멈춘다.
- 경우에 따라 checked 예약어의 명시적인 사용은 개발자로 하여금 실수할 수 있는 여지를 남긴다.
- 이 때문에 C#은 컴파일러 수준에서 checked 상황을 소스코드에 걸쳐 강제로 적용할 수 있는 '산술 오버플로 확인(check for arithmetic overflow)' 옵션을 제공한다.
- 프로젝트 속성 창의 빌드 -> 고급에서 산술 오버플로 확인 설정 체크가 가능하다.
- checked를 명시하지 않은 산술 오버플로확인 옵션을 적용해 컴파일하면 실행시에 OverflowException이 발생한다.
이처럼 '산술 오버플로 확인' 옵션과 함께 컴파일된 경우, 반대로 특정 영역의 산술 연산에 대해서는 오버플로가 발생해도 오류를 내지 말라고 개발자가 unchecked 예약어를 지정할 수 있다.
short c = 32767;
unchecked
{
c++; //산술 오버플로 확인 옵션을 적용한 경우에도 오류가 발생하지 않는다.
}
가변 매개변수: Params
메서드를 정의할 때 몇 개의 인자를 받아야 할지 정할 수 없을 때가 있다.
ex) Add 메서드를 2개의 인자만 입력받는 것으로 정의했다고 가정하고, 인자가 더 필요해서 메서드를 추가한다.
static int Add(int a, int b)
{
return a + b;
}
static int Add(int a, int b, int c)
{
return a + b;
}
여기서 끝이 아니라 4, 5, 6개의 인자를 받게 해야 한다면 그에 따라 상응하는 메서드를 정의해야하는가?
→ 이런 상황에서 params 예약어를 사용해 가변 인자를 지정할 수 있다.
Params 매개변수를 정의할 때는 입력받을 인자의 타입에 해당하는 배열을 선언한 다음 parmas 예약어를 붙이면 된다.
예제의 Add 메서드는 정수를 입력받으므로 int의 배열로 선언했고 params 예약어를 추가했다.
입력 타입을 지정할 수 없다면 모든 타입의 부모인 object를 사용할 수 있다.
Win32 API 호출 : extern
- 이 구문은 C/C++ 언어와의 호환성을 위해 제공된다.
- .NET 호환 언어로 만들어진 관리 코드(managed code)에서 C/C++ 같은 언어로 만들어진 비관리 코드(unmanaged code)의 기능을 사용하는 수단으로 플랫폼 호출(p/Invoke: platform invocation)이 있다.
- extern 예약어는 C#에서 PInvoke 호출을 정의하는 구문에 사용된다.
extern 구문을 사용하려면 세 가지 정보가 필요하다.
- 비관리 코드를 제공하는 DLL 이름
- 비관리 코드의 함수 이름
- 비관리 코드의 함수 형식(signature)
ex) 윈도우에서 제공되는 MessageBeep 함수를 C#에서 호출, 필요한 정보는 마이크로소프트의 문서에서 찾을 수 있다.
MessageBeep function(Windows)
https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-messagebeep
함수 이름 및 형식 : BOOL MessageBeep([in] UINT uType);
함수가 제공되는 DLL 이름: User32.dll
이 정보를 토대로 extern 예약어를 사용하는 방법은 일반적인 정적 메서드를 정의하는 방법과 동일하다.
extern 예약어 자체는 메서드에 코드가 없어도 컴파일되게 하는 역할만 한다.
해당 Win32Api와 C# 코드를 연결하는 역할은 DllImport 특성을 적용해야만 이용할 수 있다.
.NET CLR은 DllImport 특성으로 전달된 DLL 파일명에 extern 예약어가 지정된 메서드와 시그니처가 동일한 Win32 API를 연결한다. 이렇게 정의된 extern 정적 메서드를 사용하는 방법은 일반적인 정적 메서드를 사용하는 방법과 동일하다.
현업에서 extern 정적 메서드를 사용하는 것은 그다지 쉽다고 볼 수 없다.
→ C/C++에서 복잡한 자료형을 사용한다거나 포인터 구문을 사용하면 이를 C#의 자료형과 맞춰야 하기 때문이다.
Win32 API에 대한 PInvoke 구문이 필요하다면 www.pinvoke.net 에서 확인해보면 된다.
이곳에서는 Win32 API를 검색하면 그에 대응되는 C# 및 VB.NET 언어로 금방 가져다 쓸 수 있는 extern 구문을 제공한다.
안전하지 않은 컨텍스트 unsafe
- C/C++ 언어와의 호환성을 위해 제공된다.
- 관리 언어인 C#의 독특한 특징 중 하나는 기존 네이티브 C/C++ 언어와의 호환성을 위한 기능이 추가되었다는 점이다.
- Win32 API를 직접 호출할 수 있는 extern 예약어도 그러한 사례중 하나다. C/C++와의 호환성을 높이기 위해 존재하는 또 한가지 사례가 바로 안전하지 않은 컨텍스트(unsafe context)에 대한 지원이다.
- 안전하지 않은 컨텍스트(문맥)란 안전하지 않은 코드를 포함한 영역을 의미하며, 안전하지 않은 코드란 포인터(pointer)를 사용하는 것을 의미한다.
- 즉, C#은 C/C++ 언어의 포인터를 지원하며 unsafe 예약어는 포인터를 쓰는 코드를 포함하는 클래스나 그것의 멤버 또는 블록에 사용한다.
포인터 연산자(*, &)와 unsafe를 사용한 간단한 예제를 살펴본다.
unsafe static void GetAddResult(int* p, int a, int b)
{
*p = a + b;
}
static void Main()
{
int i;
unsafe
{
GetAddResult(&i, 5, 10);
}
Console.WriteLine(i);
}
포인터 연산자(*, &)가 사용된 곳에는 반드시 unsafe 예약어를 지정해야 한다.
- 코드를 보면 GetAddResult메서드는 포인터 형식의 인자를 받고 내부에 포인터 연산자(*)를 사용하는 코드가 있으므로 메서드 자체를 unsafe로 지정했다.
- 반면 Main 메서드에는 GetAddResult를 호출하는 부분에만 포인터 연산자(&)를 사용하므로 블록을 지정해 unsafe를 적용한다.
- unsafe 예약어를 사용한 소스코드는 반드시 컴파일러 옵션으로 AllowUnsafeBlocks를 지정해야 한다.
dotnet build /p:AllowUnsafeBlocks=true
또는, 비주얼 스튜디오 환경이라면 프로젝트 속성 창의 빌드 → 일반 항목의 안전하지 않은 코드 옵션을 설정하면 csproj 프로젝트 파일에 그에 대한 속성이 정의된다.
<Project Sdk = "Microsoft.NET.Sdk">
<PropertyGroup>
<!-- 생략 -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>
참조 형식의 멤버에 대한 포인터 fixed
- 이 구문은 C/C++ 언어와의 호환성을 위해 제공된다.
- unsafe 문맥에서 포인터는 스택에 데이터가 저장된 변수에 한해 사용할 수 있다. 즉, 지역 변수나 메서드의 매개변수 타입이 값 형식이 경우에만 포인터 연산자(*, &)를 사용할 수 있다.
- 반면 참조 형식의 데이터는 직접적인 포인터 연산을 지원할 수 없다.
- 참조 형식의 인스턴스는 힙에 할당되고 그 데이터는 가비지 수집기가 동작할 때마다 위치가 바뀔 수 있기 때문이다.
이로 인해 포인터를 이용해 그 위치를 가리키면 가비지 수집 이후 엉뚱한 메모리를 가리킬 수 있다는 위험이 따른다.
→ 이 문제를 해결하기 위해 C#에는 fixed라는 예약어를 도입했다.
fixed 예약어가 하는 정확한 역할은 힙에 할당된 참조 형식의 인스턴스를 가비지 수집기가 옴직이지 못하도록 고정시킴으로써 포인터가 가리키는 메모리를 유효하게 만드는 것이다.
class Managed
{
public int Count;
public string Name;
}
class Program
{
unsafe static void Main(string[] args)
{
Managed inst = new Managed();
inst.Count = 5;
inst.Name = "text";
fixed(int* pValue = &inst.Count)
{
*pValue = 6;
}
fixed(char* pChar = inst.Name.ToCharArray())
{
for(int i = 0; i < inst.Name.Length; i++)
{
Console.WriteLine(*(pChar + i));
}
}
}
}
Managed 타입의 객체인 inst 변수에 대해 직접 포인터를 가져오지 않았다는 점에 유의해야 한다.
C#은 객체 인스턴스의 포인터를 가져오는 것을 허용하지 않는다.
- 대신 해당 객체가 가진 멤버 데이터가 값 형식이거나 값 형식의 배열인 경우에만 포인터 연산을 할 수 있다.
- 하지만, fixed되는 대상은 해당 필드를 포함한 객체가 된다. 따라서 프로그램 실행이 fixed 블록의 끝에 다다를 때까지는 가비지 수집기가 해당 객체를 이동시킬 수 없다.
보통 fixed된 포인터는 관리 프로그램의 힙에 할당된 데이터를 관리되지 않은 프로그램에 넘기는 용도로 사용된다.
고정 크기 버퍼 fixed
- 이 구문운 C/C++ 언어와의 호환성을 위해 제공된다.
- 이번 절의 fixed는 이전 절의 예약어와 이름은 같지만, 용도가 다르다.
이 구문을 이해하기 위해서는 C/C++ 지식이 요구된다. (다음과 같은 C++ 구조체가 있다고 가정)
//C/C++로 정의한 구조체
struct CppStructType
{
public:
int fields[2];
__int64 dummy[3];
};
이것을 인자로 받아들이는 C++ DLL 함수가 있다고 가정
//C/C++로 정의한 함수
__declspec(dllexport) void __stdcall ProcessItem(CppStructType *value)
{
for(int i = 0; i < 2; i++)
{
value->fields[i] = (i + 1) * 2;
}
for(int i = 0; i < 3; i++)
{
value->dummy[i] = (i + 1) * 20;
}
}
위의 C++ 함수를 extern 예약어를 통해 C#에서 호출하려면 우선 CppStructType에 맞는 구조체를 정의해야 한다.
이 구조체를 다음과 같이 정의가능한가?
class Program
{
//C#의 struct
struct CSharpStructType
{
public int[] fields;
public long[] dummy;
}
static void Main(string[] args)
{
CSharpStructType item = new CSharpStructType();
item.fields = new int[2];
item.dummy = new long[3];
}
}
CppStructType과 CSharpStructType이 어떻게 메모리에 할당되는지 확인해보면 알 수 있다.
CooStructType의 메모리 할당은 연속적인 반면, C# 의 경우 필드마다 배열이 별도의 메모리를 할당 받는다. CSharpStructType의 필드는 타입 내의 메모리 공간에 배열 공간을 품지 못하고 별도로 할당된 배열 공간에 대한 참조 주소를 갖는 메모리 배치가 이뤄진다.
따라서 C++의 ProcessItem함수를 다음과 같이 호출
C++ DLL 측에서 참조 오류 예외가 발생하면서 프로그램이 비정상 종료하게 된다. 이런 문제를 해결하기 위해 특별히 메모리 배열을 타입에 담을 수 있도록 지원하는 구문이 fixed 배열이다.
//fixed 배열 필드는 unsafe 문맥에서 정의
unsafe struct CSharpStructType
{
public fixed int fields[2];
public fixed long dummy[3];
}
이렇게 정의한 CSharpStructType은 CppStructType과 동일한 메모리 구조로 인스턴스가 생성되므로 C++ 측의 함수에 인자로 전달할 수 있다.
스택을 이용한 값 형식 배열 stackalloc
- C/C++ 언어와의 호환성을 위해 제공한다.
- 값 형식은 스택에 할당되고 참조 형식은 힙에 할당된다. 값 형식임에도 그것이 배열로 선언되면 힙에 할당된다.
- stackalloc 예약어는 값 형식의 배열을 힙이 아닌 스택에 할당하게 만든다.
int* pArray = stackalloc int[1024]; //int 4byte * 1024 == 4KB 용량을 스택에 할당
포인터 연산을 사용하기 때문에 stackalloc도 unsafe 문맥에서 사용해야 한다.
왜 스택에 배열을 만들고 싶은가?
→ 힙을 사용하지 않으므로 가비지 수집기의 부하가 없다는 장점 때문이다. 이는 게임 프로그램을 만드는데 유용할 수 있는데, 끊임없이 호출되는 메서드 내에서 힙에 메모리를 할당하면 가비지 수집기로 인해 끊김 현상이 발생할 수 있다. 이럴 때 stackalloc을 사용하면 가비지 수집기의 호출 빈도를 조금이라도 낮출 수 있어 좀 더 원활한 게임 실행이 가능해진다.
반대로 스택에 배열을 만들고 싶지 않은 이유
→ 스택은 스레드마다 할당되는 메모리로 윈도우의 경우 (32 비트 프로세스 기준) 기본 값으로 1MB 규모의 크기를 갖는다.
이 처럼 제한된 자원을 남용하면 자칫 프로그램의 실행에 오류를 발생시킬 수 있으므로 사용할 때는 신중을 기해야 한다.
→ 이 때문에 일부 특수한 용도를 제외하고는 stackalloc 예약어가 사용되는 경우는 거의 없다.
정리
나머지 예약어
- volatile, lock : 스레드와 함께 사용된다.
- internal : 접근 제한자의 하나이다.
- try, catch, throw, finaly : 예외 처리를 위해 사용된다.
- using : 네임스페이스를 선언하는 using이 아닌 IDisposable 인터페이스를 다루는 예약어이다.
Reference
시작하세요! C# 12 프로그래밍 기본 문법부터 실전 예제까지
'C#' 카테고리의 다른 글
C# 1.0 - 예외 (0) | 2025.09.24 |
---|---|
C# 1.0 - 프로젝트 구성 (0) | 2025.09.24 |
C# 1.0 - 문법요소 (0) | 2025.09.21 |
C# 객체지향 문법 [C#의 클래스 확장 - 멤버 유형 확장] (4) | 2025.07.26 |
C# 객체지향 문법 [C#의 클래스 확장 - 열거형] (0) | 2025.07.23 |