
제프리 리처의 CLR via C#
[CLR/C#] 타입의 기초
4장 목표
타입을 다루기 위한 기본 사항과 CLR에 대한 내용 소개
1. 모든 타입은 System.Object를 상속한다
모든 타입은 System.Object 타입으로부터 파생되는 것으로 한다.
class TestA {
}
class TestA : System.Object {
}
위 코드의 두 TestA 클래스는 서로 같은 의미를 가진다. 단, 명시적과 암시적의 차이다. TestA 클래스는 Object 타입의 Public, Protected 인스턴스 메서드 및 멤버를 사용할 수 있다.
2. new 연산자의 동작 과정
C#에서 객체 생성 시, new 연산자를 통해서 생성할 수 있다. new 연산자를 통해서 객체를 생성하는 것은 다음의 프로세스를 거친다.
- 할당 하려는 타입에 포함된 모든 타입들을 메모리에 할당하기 위하여 용량 계산
- Heap의 모든 객체는 별도로 타입 객체 포인터와 동기화 블록 인덱스를 추가한다.
- 추가된 멤버들은 CLR이 관리를 목적으로 사용하게 된다.
- 관리되는 힙에서 1번에서 계산된 용량만큼 메모리를 할당하고 0으로 초기화한다.
- 객체의 타입 객체 포인터, 동기화 블록 인덱스 멤버를 초기화한다.
- 생성된 인스턴스의 생성자를 실행한다. 이때, 상속 관계의 끝으로 올라가 System.Object의 생성자까지 실행하고 반환된다.
3. 타입 간 캐스팅하기
GetType 메서드를 통해서 객체의 정확한 타입을 가져올 수 있다.
C#에서는 상속 타입에 대한 형변환에 대해서는 암묵적으로 진행되는데 파생 타입의 형변환은 실행 시 코드가 실패할 확률이 존재하기 때문에 별도의 명시적인 문법이 필요하다.
// internal 접근 지시자는 어셈블리 코드에서만 사용할 수 있다.
internal class Test {
...
}
public sealed class Program {
public static void Main() {
// Test는 암시적으로 System.Object를 상속하고 있다.
// 암시적 변환도 문제 없음
Object o = new Test();
// Test 클래스는 Object 클래스를 암시적 변환이 안된다.
// 명시적 변환을 통해 문제 없음
Test a = (Test)o;
}
}
3.1. is 연산자
is 연산자는 지정된 형식과 호환되는지 확인한다. 호환될 경우 True, 아닐 경우 False를 반환한다.
int i = 34;
object iBoxed = i;
int? jNullable = 42;
if(iBoxed is int a && jNullable is int b)
{
// output : 76
Console.WriteLine(a + b);
}
3.2. as 연산자
CLR은 is 연산자를 사용할 때, 타입 검사에 대한 비용을 발생시킨다. 객체에 대응하는 타입이 있는지 확인을 하기 때문에 비용은 불가피하지만, C#은 성능을 개선하여 작업을 단순화할 수 있도록 as 연산자를 지원한다.
as 연산자는 결과를 지정된 참조 혹은 nullable 값 형식으로 명시적 변환한다.
IEnumerable<int> numbers = [10, 20, 30];
IList<int> indexable = numbers as IList<int>;
if(indexable != null)
{
// output : 40
Console.WriteLine(indexable[0] + indexable[indexable.Count - 1]);
}
4. 네임스페이스와 어셈블리
// namespace 미사용
public sealed class Program {
public static void Main() {
System.IO.FileStream fs = new System.IO.FileStream(...);
System.Text.StringBuilder sb = new System.Text.StringBuilder(...);
}
}
// namespace 사용
using System.IO;
using System.Text;
public sealed class Program {
public static void Main() {
FileStream fs = new FileStream(...);
StringBuilder sb = new StringBuilder(...);
}
}
위 코드에서 FileStream 타입과 StringBuilder 타입을 사용하기 위해서, 각자 System.IO와 System.Text를 붙인다. 타입이 길어지는 것을 방지하고자 using 지시자를 사용하면 컴파일러는 using 지시자의 타입들을 읽고 코드 내에 있는 타입들과 대응시켜 실행함으로 길어지는 것을 줄일 수 있다.
4.1. 성능 확인
* 궁금해서 확인해봤으나, 결론은 무의미하다.
컴파일러가 using 지시자의 타입을 접두사로 붙인다는 점이 흥미로워서 단순한 코드로 성능을 측정했다. 측정한 코드는 다음과 같으며, 결과는 각자 아래에 있다.
// 1번) using 지시자 사용
using System;
using System.Diagnostics;
namespace CLR_Study
{
public sealed class Program
{
public static void Main()
{
Stopwatch stop = new Stopwatch();
stop.Start();
for (int i = 0; i <= 9999; ++i)
{
Console.WriteLine("HelloWorld");
}
stop.Stop();
// 1번째 시도 : 1969
// 2번째 시도 : 1908
// 3번째 시도 : 1875
Console.WriteLine(stop.ElapsedMilliseconds);
}
}
// 2번) using 지시자 미사용
namespace CLR_Study
{
public sealed class Program
{
public static void Main()
{
Stopwatch stop = new Stopwatch();
stop.Start();
for (int i = 0; i <= 99999; ++i)
{
System.Console.WriteLine("HelloWorld");
}
stop.Stop();
// 1번째 시도 : 1917
// 2번째 시도 : 1987
// 3번째 시도 : 1878
System.Console.WriteLine(stop.ElapsedMilliseconds);
}
}
}
환경과 코드에 따라서 다른 결과가 도출될 수 있겠지만, 유의미한 결과를 보여주진 않는다. 사실, 의미있는 테스트는 아니다. 그냥 혹시나 싶은 생각으로 진행해봤다.
이전에 CLR 1장에서 이미 컴파일러는 JIT 과정에서 코드에 존재하는 메서드들의 어셈블리를 가져온다고 설명한 적이 있다. 그렇기 때문에 실행 시간에 약간의 손실이 있을지언정, 메서드를 실행하는 성능 자체에서 문제가 발생할 수 없다.
5. 어셈블리의 모호성
using a;
using b;
public sealed class Program {
public static void Main()
{
Widget wg = new Widget();
}
}
// 문제 : 'a'와 'b' 사이에 어떤 Widget을 가져와야하는가?
Widget을 using 지시자로 표현한다면, a와 b 사이에서 어떤 Widget을 가져와야하는지 모호성이 성립한다. 이 경우에는 using 구문을 통해서 해결할 수 있다.
using aWidget = a.Widget;
using bWidget = b.Widget;
▶ using 지시자? 구문?
using 지시자는 namespace 선언에서 사용이 된다. using 구문은 자원 관리를 간편하게 해주는 기능을 제공하는 것으로 둘은 별개의 존재로 인식한다.
6. 실행 시점의 활동
단일 프로세스에서 새로운 스레드를 생성하면 스레드 당, 1MB 크기의 스택이 할당된다. 생성된 스택 공간은 메서드로 인수를 전달하는 과정, 메서드 내의 지역 변수를 보관하는 용도로 사용된다. 스택은 상위 메모리 주소 → 하위 메모리 주소에 걸쳐 만들어진다.
대부분의 메서드는 프롤로그 코드와 에필로그 코드를 자동으로 포함하여 역할을 수행한다. 프롤로그 코드는 메서드 안의 코드 동작을 위해 초기화를 수행하는 역할을 하며, 에필로그 코드는 메서드 실행 후 정리 작업을 수행하며 원래의 호출자에게 돌아갈 수 있도록 준비한다.

- name 지역 변수의 주소가 스택에 저장된다.
- M2의 매개변수 s의 주소가 스택에 저장된다.
- 일부 아키텍처는 레지스터 전달을 통해 성능 향상을 도모한다.
- 되돌아올 호출자 메서드 주소를 스택에 저장한다.
- B 메서드의 지역변수 Length가 스택에 저장된다.
- B 메서드의 지역변수 Tally가 스택에 저장된다.
이후, B 메서드의 코드가 실행되고 Return문을 만나 CPU의 명령 포인터의 주소를 스택에 저장된 호출자 메서드의 주소로 설정하고 B 메서드와 만나기 전의 스레드 스택 상태로 돌아간다.
해당 게시글은 책을 기반으로 배운 내용을 간략하게 요약한 것입니다.
자세한 내용은 제가 참고한 책을 확인해주세요.
'독서 > CLR via C#' 카테고리의 다른 글
[CLR/C#] 기본, 참조, 값 타입 | 2024.04.16 |
---|---|
[CLR/C#] 공유 어셈블리와 강력한 이름의 어셈블리 | 2024.04.15 |
[CLR/C#] 빌드, 패키징, 배포, 응용프로그램과 타입의 관리 | 2024.04.15 |
[C#] IL 코드 확인 및 메타데이터 확인하기 | 2024.04.15 |
[CLR/C#] CLR의 실행 모델 | 2024.02.11 |