5.1.2 연산자
5.1.2.1 시프트 연산자
1) << 좌측 시프트 한 번 할때마다 2를 곱하는 효과가 있으며 >> 우측 시프트 한 번할때마다 2로 나누는 효과가 있다.
eg. 38<<2 = 38*4 = 152
eg. 38 >> 2 = 38/4 = 9
시프트는 하위 바이트의 숫자를 잘라내는 역할도 하며 아래는 4바이트 정수에서 상위 2바이트의 값을 알아내기 위해 다음과 같이 16번의 우측 시프트 연산을 할 수 있다.
0000 0000 0000 0000 0000 0000 0000 0000
int n = 1207967792;
int high2ByteResult = n >> 16;
Console.WriteLine(high2ByteResult);

>> 2 시프트연산을 우측으로 2번 할 시 18432가 나온다,.

우측 시프트 연산에서 주의할 사안은 최상위 비트(MSB: most significant bit)가 부호의 유무에 따라 처리 방식이 달라진다는 점이다. 예를 들어 -38이 부호 있는 32비트 정수 타입에 담겨있을 때를 보자.

부호 있는 32비트 정수의 최상위 비트는 부호를 나타내므로 시프트 연산을 할 때 보존 돼야한다.
-38 >> 2 결과

반면 숫자 2281709616을 부호 없는 32비트 정수타입에 담아 시프트 연산을 하면 처리결과가 달라진다.

부호 없는 32비트 정수의 최상위 비트는 데이터의 일부이므로 시프트 연산에서 보존되지 않아야한다.
2281709616 >> 2 결과는 다음과 같다.

좌측 시프트 연산은 이를 고려할 필요가 없으나 우측 시프트 연산을 할 때는 반드시 대상의 부호 유무를 고려해야 의도한 결과를 얻을 수 있다.
5.1.2.2 비트 논리 연산자
p302
표 5.4 비트논리연산자
조건 논리 연산자 | 비트 논리 연산자 | 의미 |
&& | & | 논리곱 |
|| | | | 논리합 |
^ | ^ | 논리 XOR(연산자 동일) |
! | ~ | 비트 보수 연산자 |
비트논리연산자가 사용되는 대표적인 경우는 각 비트의 값을 특정 상태를 나타내는 의미로 사용할 때다.
using System;
enum EnumCalc
{
plus = 0x001,
minus = 0x001 << 1,
multiply = 0x001 << 2,
divide = 0x001 << 3
}
class Program
{
static void Main()
{
Calc((op: EnumCalc.plus, operand1: 10, operand2: 5)); // 더하기 수행
Calc((op: EnumCalc.minus | EnumCalc.multiply | EnumCalc.divide, operand1: 10, operand2: 5)); // 빼기, 곱하기, 나누기 수행
}
static void Calc((EnumCalc op, int operand1, int operand2) param)
{
if ((param.op & EnumCalc.plus) == EnumCalc.plus)
{
Console.WriteLine($"더하기: {param.operand1 + param.operand2}");
}
if ((param.op & EnumCalc.minus) == EnumCalc.minus)
{
Console.WriteLine($"빼기: {param.operand1 - param.operand2}");
}
if ((param.op & EnumCalc.multiply) == EnumCalc.multiply)
{
Console.WriteLine($"곱하기: {param.operand1 * param.operand2}");
}
if ((param.op & EnumCalc.divide) == EnumCalc.divide)
{
Console.WriteLine($"나누기: {param.operand1 / param.operand2}");
}
}
}
5.1.2.3 연산자 우선순위
BEST 방식: () 연산자를 통해 명시적으로 연산우선 순위를 매겨두자. // 부록 C#12 연산자와 문장 부호 표 참고.
5.1.3 예약어
5.1.3.1 연산 범위 확인: checked, unchecked
short c = 32767; // short 범위 -32768 ~ +32767
c++;
Console.WriteLine(c); // -32768
int n = 32768;
c = (short)n;
Console.WriteLine(c); // -32768
숫자 32767 : 2진수 01111111 11111111
01111111 11111111 + 1 = 10000000 00000000
최상위 비트가 1로 바뀌어서 음수 값이 되며, c#은 음수를 2의 보수(complement)로 표현하기 때문에 값이 -32,768이 된다.
short c = -32768에 -1을 추가 시 32767의 결과를 얻는다.
데이터가 상한값 -> 하한값, 하한값 -> 상한값이 되는 것을 오버플로라 한다.
부동 소수점 연산에서 0에 가깝지만 정밀도의 한계로 표현할 수 없을 때 아예 0으로 만들어 버리는 것을 언더플로(underflow)라 한다. 닷넷에서는 언더플로에 대한 예외처리는 제공하지 않는다.
C#에서는 checked 예약어를 통해 연산식에서 오버플로가 발생한 경우 오류(예외)를 발생시킬 수 있다.
이를 통해 의도치 않은 결과를 방지할 수 있다.
short c = 32767; // short 범위 -32768 ~ +32767
checked
{
c++;
} // 예외발생
Console.WriteLine(c); // -32768

경우에 따라 checked 예약어의 명시적인 사용은 개발자로 하여금 실수할 수 있는 여지를 남긴다. 이 때문에 C#은 컴파일러 수준에서 checked 상황을 전체 소스코드에 걸쳐 강제로 적용할 수 있는 '산술 오버플로 확인(Check for arthmetic overflow)' 옵션을 제공한다.

만일 자동 check를 해제하고 싶으면 unchecked 예약어를 사용하면된다.
short c = 32767; // short 범위 -32768 ~ +32767
unchecked
{
c++;
}
Console.WriteLine(c); // -32768

5.1.3.2 가변 매개변수: params
메서드의 인자 갯수가 정해지지 않을 때 일일이 오버로딩 메서드를 만드는 게 아닌, params 예약어를 통해 가변 인자를 지정할 수 있다.
int Add(params int[] values)
{
int result = 0;
for(int idx = 0; idx < values.Length; ++idx)
{
result += values[idx];
}
return result;
}
Console.WriteLine(Add(1,2,3,4,5));
Console.WriteLine(Add(1, 2, 3, 4, 5, 6, 7, 8, 9, 10));
params 예약어 사용하지 않을 경우 오버로딩하지 않았다는 컴파일에러가 난다.

params 매개변수를 정의 할 때는 입력받을 인자의 타입에 해당하는 배열을 선언한 다음 params 예약어를 붙이면 된다.
입력 타입을 지정할 수 없다면 모든 타입의 부모인 object를 사용할 수 있다.
void PrintAll(params object[] values)
{
foreach(object val in values)
{
Console.WriteLine(val);
}
}
PrintAll(1.05, "result", 3);
#출력
1.05
result
3
5.1.3.3 Win32 API 호출: extern
닷넷 호환언어로 만들어진 관리 코드(manage code)에서 c/c++ 같은 언어로 만들어진 비관리 코드(unmanaged code)의 기능을 사용하는 수단으로 플랫폼 호출(P/Invoke: platform invocation)이 있다. extern 예약어는 C#에서 PInvoke 호출을 정의하는구문에 사용된다.
extern 구문을 작성하려면 세가지 정보가 필요하다.
1. 비관리 코드를 제공하는 DLL 이름
2. 비관리 코드의 함수 이름
3. 비관리 코드의 함수 형식(signature)
https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-messagebeep
MessageBeep function (winuser.h) - Win32 apps
Plays a waveform sound. The waveform sound for each sound type is identified by an entry in the registry.
learn.microsoft.com
위 정보를 토대로 extern 예약어를 다음과 같이 사용 가능하다.
using System.Runtime.InteropServices;
MessageBeep(0x00000030);
[DllImport("user32.dll")]
static extern bool MessageBeep(uint uType);
static bool TestMethod(uint type) // 비교 위한 정적메서드
{
return true;
}
extern 예약어 자체는 메서드에 코드가 없어도 컴파일되게 하는 역할만 한다.
Win32 API와 C# 코드를 연결하는 역할은 DllImport 특성을 적용해야만 이용 할 수 있다. 닷넷 CLR은 DllImport 특성으로 전달된 DLL 파일명에 extern 예약어가 지정된 메서드와 시그니처가 동일한 Win32 API를 연결한다.
이렇게 정의된 extern 정적 메서드를 사용하는 방법은 일반적인 정적 메서드를 사용하는 방법과 동일하다.
현업에서 extern 정적 메서드를 사용하는 것은 쉽지만은 않다. c/c++에서 복잡한 자료형을 쓰거나 포인터구문을 사용하면 이를 C#의 자료형과 맞춰야 하기 때문이다.
www.pinvoke.net 을 통해 WIN 32 API를 검색 시 C# 및 VB.NET 언어로 가져다 쓸 수 있는 extern 구문을 제공 받을 수 있다.
5.1.3.4 안전하지 않은 컨텍스트: unsafe
C#에선 C/C++과의 호환성을 위한 기능이 추가 됐다. Win32 API를 직접 호출 가능한 extern 예약어는 해당 기능들 중 하나이다.
c/c++ 호환성을 높이기 위해 안전하지 않은 컨텍스트(unsafe context)에 대한 지원이 있다. 안전하지 않은 컨텍스트(문맥)이란 안전하지 않은 코드를 포함한 영역을 의미하며 안전하지 않은 코드란 포인터(pointer)를 사용하는 것을 의미한다.
C#은 C/C++ 언어의 포인터를 지원하며 unsafe 예약어는 포인터를 쓰는 코드를 포함하는 클래스나 그것의 멤버 또는 블록에 사용한다.

Unsafe code를 사용하기 위해 위와 같이 체크박스를 체크한다.(체크하지 않을 경우 컴파일 에러 발생)

또는 위와 같이 마크업언어에서 적용 가능하다.
class Program
{
unsafe static void GetAddResult(int* p, int a, int b)
{
*p = a + b;
}
static void Main(string[] args)
{
int i;
unsafe
{
GetAddResult(&i, 5, 10);
}
Console.WriteLine(i);
}
}
#출력
15
포인터 연산자(*, &)가 사용된 곳에는 반드시 unsafe 예약어를 지정해야 한다.
GetAddResult 메서드는 포인터 형식의 인자를 받고 내부에 포인터 연산자(*)를 사용하는 코드가 있기에 메서드 자체를 unsafe로 지정했다. Main메서드의 경우 GetAddResult를 호출하는 부분만 포인터 연산자(&)를 사용하므로 블록을 지정해 unsafe를 적용한다.
5.1.3.5 참조 형식의 멤버에 대한 포인터: fixed
unsafe 문맥에서 포인터는 스택에 데이터가 저장된 변수에 한해 사용 가능하다.
즉, 지역 변수나 메서드의 매개변수 타입이 값 형식인 경우에만 포인터 연산자(*, &)를 사용할 수 있다.
반면, 참조 형식의 데이터는 직접적인 포인터 연산을 지원할 수 없다. 참조형식의 인스턴스는 힙에 할당되고 그 데이터는 가비지 수집기가 동작할때마다 위치가 바뀔 수 있기 때문이다.
이로 인해 포인터를 이용해 그 위치를 가리키면 가비지 수집 이후 엉뚱한 메모리를 가리킬 수 있는 위험이 있다.이러한 문제를 해결하고자 C#에서는 fixed 예약어를 도입했다.
fixed 예약어의 역할은 힙에 할당된 참조 형식의 인스턴스를 가비지 수집기가 움직이지 못하도록 고정시킴으로써 포인터가 가리키는 메모리를 유효하게 만든느 것이다.
class Managed
{
public int Count;
public string Name;
}
class Program
{
unsafe static void Main(string[] args)
{
Managed inst = new();
inst.Count = 5;
inst.Name = "txt";
fixed(int* pValue = &inst.Count)
{
*pValue = 6;
}
fixed(char* pChar = inst.Name.ToCharArray())
{
for(int idx = 0; idx < inst.Name.Length; ++idx)
{
Console.WriteLine(*(pChar + idx));
}
}
}
}
여기서 Managed 타입의 객체인 inst 변수에 대해 직접 포인터를 가져오지 않았다는 점에 유의할 필요가 있다. C#은 객체 인스턴스의 포인터를 가져오는 것을 허용하지 않는다. 대신 해당 객체가 가진 멤버 데이터가 값 형식이거나 값 형식의 배열인 경우 포인터 연산을 할 수 있다. 하지만 fixed 되는 대상은 해당 필드를 포함한 객체가 된다. 따라서 프로그램 실행이 fixed 블록의 끝에 다다를 때까지는 가비지 수집기가 해당 객체를 이동 시킬 수 없다. 보통 fixed 된 포인터는 관리 프로그램의 힙에 할당된ㅇ 데이터를 관리되지 않은 프로그램에 넘기는 용도로 쓰인다.
✅ 핵심 요점부터
C#에서는 관리되는 객체(Managed Object) 전체에 대한 포인터는 가져올 수 없습니다.
하지만 **관리되는 객체의 필드(값 형식)**에 대해서는 fixed 블록 안에서 포인터를 가져올 수 있습니다.
- Managed 클래스는 참조형(Reference Type)이고, inst는 **힙(Heap)**에 저장됩니다.
- 필드 Count는 int니까 **값 형식(Value Type)**이고, Name은 string, 즉 참조형이죠.
1. **"객체 전체"**에 대한 포인터는 안 됨 ❌
Managed inst = new(); Managed* pInst = &inst; // ❌ 컴파일 에러
📌 2.
- 이유: Managed는 **관리되는 참조형(Managed Reference Type)**이고, C# 런타임(GC)이 이 객체를 이동시킬 수 있기 때문입니다.
- GC가 객체 위치를 바꿔버리면, 우리가 가지고 있던 포인터는 더 이상 올바른 주소를 가리키지 않게 되죠.
- 그래서 객체 전체에 대한 포인터는 C#에서 막고 있는 거예요.
객체의 필드에 대해서는 가능
fixed (int* pValue = &inst.Count) // ✅ OK { *pValue = 6; }
3. 문자열은 고정 후 char* 사용 가능
fixed (char* pChar = inst.Name.ToCharArray()) // ✅ OK { ... }
문자열은 불변(immutable)이고, 내부적으로는 char[]로 표현되기 때문에, ToCharArray() 후 fixed로 고정하면 포인터 사용 가능.
📦 정리
대상포인터 사용 가능?설명클래스 인스턴스 전체 (&inst)❌참조형, GC가 이동시킬 수 있음값형 필드 (&inst.Count)✅fixed 안에서 고정 시 가능문자열 내부 (ToCharArray())✅fixed 후 사용 가능
근데 컴파일에러가 아닌 컴파일경고만 뜸(CS8500)
->
C# 컴파일러는 unsafe 블록 안에선 다음처럼 매우 위험한 코드라도 컴파일러가 허용하는 방침을 취합니다:
C# 철학: "unsafe는 네가 위험을 감수하겠다는 뜻이니, 컴파일러는 간섭 안 함" 그래서 **CS8500은 '명백한 문제지만 실행 가능은 하다'**라는 중간 단계인 경고로 처리되는 거예요.
Managed* pInst = &inst;
inst는 참조형 (class)이기 때문에 &inst로 주소를 얻는 것은 C# 메모리 모델 상에서 정의되지 않은 동작 즉, GC가 언제든지 inst를 이동시키면 pInst가 죽은 포인터가 되어버림
그래서 C# 설계자들은 "이건 허용은 하되 경고 줘서 너가 알아서 해라"는 방침을 취한 거고요.
진짜 하고 싶다면?
참조형 객체의 주소를 가져오고 싶다면 **GCHandle**을 써야 안전합니다:
using System; using System.Runtime.InteropServices; class Managed { public int Count; public string Name; } class Program { static void Main(string[] args) { Managed inst = new(); var handle = GCHandle.Alloc(inst, GCHandleType.Pinned); IntPtr ptr = GCHandle.ToIntPtr(handle); Console.WriteLine($"Object address: 0x{ptr.ToString("X")}"); handle.Free(); } }
- 이 방식은 GC가 해당 객체를 이동하지 못하게 하고
- 정상적인 메모리 주소를 안전하게 참조할 수 있는 유일한 공식 방식
✅ 요약 정리
항목설명
CS8500 관리되는 타입에 포인터 사용 시 나오는 경고 (주소 취득, 포인터 선언, sizeof 등) 왜 경고지? unsafe 문맥에서는 개발자 책임 하에 위험한 코드 허용 실행은 가능? 네, 되긴 하지만 매우 위험함 안전한 방법 GCHandle로 객체를 고정(Pin) 후 IntPtr로 주소 취득
5.1.3.6 고정 크기 버퍼: fixed
해당 절의 fixed는 이전 절의 예약어와 이름은 동일하나 용도는 다르다.
struct CppStructType
{
public:
int fields[2];
__int64 dummy[3];
};
__declspec(dllexport) void __stdcall ProcessItem(CppStructType* value)
{
for (int idx = 0; idx < 2; ++idx)
{
value->fields[idx] = (idx + 1) * 2;
}
for (int idx = 0; idx < 3; ++idx) {
value->dummy[idx] = (idx + 1) * 20;
}
}
위의 cpp 함수를 extern 예약어를 통해 C#에서 호출하려면 CppStructType에 맞는 구조체를 정의해야한다.
class Program
{
struct CSharpStructType
{
public int[] fields;
public long[] dummy;
}
static void Main(string[] args)
{
CSharpStructType item = new();
item.fields = new int[2];
item.dummy = new long[3];
}
}
위와 같이 구조체를 정의하면 될까?
CppStructType의 메모리 할당은 연속적인 반면 C#의 경우 필드마다 배열이 별도의 메모리를 할당 받는다.


CSharpStructType의 필드는 타입 내의 메모리 공간에 배열 공간을 품지 못하고 별도로 할당된 배열 공간에 대한 참조 주소를 갖는 메모리 배치가 이뤄진다.
using System.Runtime.InteropServices;
struct CSharpStructType
{
public int[] fields;
public long[] dummy;
}
class Program
{
[DllImport("...C/C++ processItem 구현.dll...")]
internal static unsafe extern int ProcessItem(CSharpStructType value);
unsafe static void Main(string[] args)
{
CSharpStructType item = new();
item.fields = new int[10];
item.dummy = new long[20];
ProcessItem(item); // 프로세스 비정상 종료
}
}
c++ DLL 측에서 참조 오류 예외가 발생하면서 프로그램이 비정상 종료하게 된다. 이런 문제를 해결하고자 특별히 메모리 배열을 타입 내에 담을 수 있도록 지원하는 구문이 fixed 배열이다.
using System.Runtime.InteropServices;
unsafe struct CSharpStructType
{
public int[] fields;
public long[] dummy;
}
class Program
{
[DllImport("...C/C++ processItem 구현.dll...")]
internal static unsafe extern int ProcessItem(CSharpStructType* value);
unsafe static void Main(string[] args)
{
CSharpStructType item = new();
CSharpStructType* ptItem = &item;
ProcessItem(ptItem);
for(int idx = 0; idx < 2; ++idx)
{
Console.WriteLine(item.fields[idx]);
}
Console.WriteLine();
for(int idx = 0; idx < 3; ++idx)
{
Console.WriteLine(item.dummy[idx]);
}
}
}
이렇게 정의한 CSharpStructType은 CppStructType과 동일한 메모리 구조로 인스턴스가 생성되므로 다음과 같이 cpp 츠그이 함수에 인자로 전달 할 수 있다.
5.1.3.7 스택을 이용한 값 형식 배열: stackalloc
값 형식은 스택에 할당되고 참조 형식은 힙에 할당된다. 그러나 값 형식임에도 배열로 선언되는 경우 힙에 할당 된다. stackalloc 예약어는 값 형식의 배열을 힙이 아닌 스택에 할당하게 만든다.
int* pArray = stackalloc int[1024]; // int 4byte * 1024 == 4KB 용량 스택에 할당
포인터 연산을 사용하기에 stackalloc 도 unsafe 문맥에서 사용해야 한다.
스택에 배열을 만들고자 하는 이유는 힙을 사용하지 않으므로 가비지 수집기의 부하가 없다는 장점 때문이다. 게임 프로그램을 만들 때 유용하지만 끊임없이 호출되는 메서드 내에서 힙에 메모리를 할당하면 가비지 수집기로인해 끊김 현상이 발생할 수 있는데, 이럴 때 stackalloc을 사용 시 가비지 수집기의 호출빈도를 조금이나마 낮출 수 있어 좀 더 원활한 게임 실행이 가능하다.
반대로, 스택에 배열을 만들고 싶지 않은 이유는 스택은 스레드마다 할당되는 메모리로 윈도우의 경우(32비트 프로세스 기준) 기본값으로 1MB 규모의 크기를 갖는다. 이처럼 제한된 자원을 남용하면 자칫 프로그램의 실행에 오류를 발생시킬 수 있으므로 사용 시 신중해야한다. 이때문에 특수 용도를 제외하곤 stackalloc 예약어가 사용되는 경우는 거의 없다.
volatile, lock: 스레드와 함께 사용, lock 의 경우 6장에서 다룸.
internal: 접근 제한자의 하나
try, catch, throw, finally: 예외 처리를 위해 사용
using: 네임스페이스를 선언하는 using이 아닌 IDisposable 인터페이스를 다루는 예약어.(자원 해제 절에서 다룸)
'C#(.Net)' 카테고리의 다른 글
[시작하세요 C# 12 프로그래밍 ] #6 BCL - 01 (6~6.3) (0) | 2025.03.30 |
---|---|
[시작하세요 C# 12 프로그래밍 ] #5 C# 1.0 완성하기 #3 (0) | 2025.03.23 |
[시작하세요 C# 12 프로그래밍 ] #5 C# 1.0 완성하기 #1 (0) | 2025.03.21 |
[시작하세요 C# 12 프로그래밍 ] #4 CSharp 객체 지향 문법 3 (1) | 2025.03.19 |
Field(클래스 내부 멤버 변수) vs Property(속성) vs Attribute(특성) (0) | 2025.03.18 |