본문 바로가기
개발/Unity 내일배움캠프 TIL

[내일배움단] 사전캠프 C# TIL 8. 알아두면 좋을 것들

by 석시 2023. 7. 25.



C#에서 필요한 잡다한 것들을 정리해놓으려고 한다.

Generic

일반화를 위한 기능이다.

특정 자료구조를 내가 직접 한 번 만든다고 해보자.

자료구조니까 모든 자료형을 받을 수 있어야 할텐데, 이를 직접 구현한다 생각하면 쉽지 않아 보인다.

참고) object 타입

고려할만한건 object 타입의 사용이겠다.

class 버전의 var라고 생각하면 되겠다.

varobject의 차이는 var는 그것의 타입이 정확히 뭔지 컴파일 타임에 결정된다는 것이고, object는 컴파일 단계에서도 자료형이 object로 인식된다.

object의 정체는 사실 stringint 같은 자료형의 부모 클래스이다.

그러면 모든 자료형에 object를 사용하면 되지 않느냐? 하는데 모든 곳에 object를 사용하면 문제가 모든 변수가 참조 타입으로 저장된다는 것이다.

사실 고민해보면 당연하다. 데이터의 크기를 뭔지 모르는 상태에서 런타임에서 그것을 결정해야하니, 힙을 사용할 수 밖에 없는 것이다.

해당 과정의 박싱과 언박싱 단계에서 매우 많은 시간이 걸리니 우리는 일반화를 위해 다른 요소를 고려할 수 밖에 없는 것이다.

Generic의 기본 구조

class MyStructure<T>
{
    T[] arr = new T[10];
}

다음과 같이 T를 사용해 일반화를 하면 된다.

그 후 클래스 내부에서는 T를 마치 특정한 자료형처럼 사용하면 된다.

특정 형식에 사용할 수 없도록 다음과 같이 조건을 추가할 수 있다.

// 구조체 형식이어야 하는 경우
class MyStructure<T> where T : struct
{
    T[] arr = new T[10];
}

// 참조 형식이어야 하는 경우
class MyStructure<T> where T : class
{
    T[] arr = new T[10];
}

// 반드시 어떠한 인자도 받지 않는 기본 생성자가 있어야 하는 경우
class MyStructure<T> where T : new()
{
    T[] arr = new T[10];
}

Interface

클래스를 설계할 때 행위에 강요를 주는 기능이다.

abstract

class Character
{
    public virtual void Attack() { }
}

class Warrior : Character
{

}

class Magician : Character
{

}

다음과 같이 어떤 클래스를 상속받았을 때 특정 virtual 함수의 오버라이딩을 강요하고 싶다고 하자.

이 때 Character 클래스 앞에 abstract라는 키워드를 붙여 해당 클래스를 추상 클래스로 만들 수 있다.

abstract class Character

추상 클래스는 개념적으로만 존재하는 것이기 때문에

더이상 해당 클래스로는 더이상 인스턴스를 생성할 수 없게 된다.

추상 클래스 안에서는 함수들 역시 추상적으로 만들어줄 수 있다.

abstract class Character
{
    public abstract void Attack();
}

이 때 추상 함수이기 때문에 본문을 선언하면 안된다.

이 때부턴 추상 함수 역시 개념적으로만 존재하게 되기 때문에 해당 클래스를 상속받게 되면 반드시 해당 멤버를 오버라이딩해줘야 한다.

interface

C++에선 다중상속을 지원하지만, 다중상속은 죽음의 다이아몬드라는 어마어마한 문제가 하나 있다.

그때문인지 C#에서는 다중상속을 지원하지 않는다. 하지만 상속을 받으면서 추가적으로 기능을 넣어주고 싶은 경우가 있을 것이다.

이것을 위해 존재하는 것이 interface이다.

abstract class Character
{
    public abstract void Attack();
}

interface IFlyable
{
    void Fly();
}

class Warrior : Character
{
    public override void Attack() { }
}

class FlyableWarrior : Warrior, IFlyable
{
    public void Fly()
    {
        Console.WriteLine("난다요");
    }
}

다음과 같이 구현할 수 있다.

상속 시 여러 개의 인터페이스를 받을 수 있지만, 클래스는 단 하나밖에 받지 못한다.

보통 interface를 쓸 때 컨벤션을 이름 앞에 대문자 I를 붙이니 참고.

static void DoFly(IFlyable flyable)
{
    Console.WriteLine("난난다요");
}

static void Main(string[] args)
{
    IFlyable flyable1 = new FlyableWarrior();
    DoFly(flyable1);

    FlyableWarrior flyable2 = new FlyableWarrior();
    DoFly(flyable2);
}

다음과 같이 해당 인터페이스를 가지는 클래스에 대한 일반화된 함수 역시 구현 가능하니 참고하자.

Property

객체지향의 은닉성은 불필요한 정보를 외부에 노출시키지 않겠다는 의미였다.

보통 다른 언어에서는 클래스의 멤버 변수를 접근하기 위해 public int GetHP();와 같은 메소드를 직접 구현하였다.

이를 캡슐화라고 부르는데, 장점이 좀 있다.

  • 어느 곳에서 해당 변수를 참조하는 지 알 수 있다.
  • 해당 변수의 접근에 조건을 추가할 수 있다.

그래서 Get 함수(Getter) 하나 , Set 함수(Setter) 하나를 구현하도록 하는 패턴이 매우 자주 보인다.

C#에서는 해당 함수들을 편하게 구현하도록 하기 위한 Property라는 것을 지원한다.

protected int hp;

public int Hp
{
    get { return hp; }
    set { Hp = value; }
}

다음과 같이 구현하면, 다른 곳에서 일반 멤버 변수처럼 접근할 수 있다.

static void Main(string[] args)
{
    Character chara = new Character();
    chara.Hp = 100;
}

getset 앞에 private을 달아버릴 수도 있다.

그러면 해당 클래스 밖에서는 접근이 안된다.

자동구현 Property

여기서 더 나아가, 매번 저런 패턴인데, 이것도 매번 만들어야 하나? 라고 생각할 수 있을 것이다.

이것 역시 자동으로 처리하도록 작성할 수 있다.

public int Attack
{
    get; set;
}

getset에 추가작업이 필요하지 않다면, 다음과 같이 써놓기만 하면 끝이다.

public int Attack { get; set; } = 100;

다음과 같이 초기화도 가능하다.

delegate

콜백 (Callback)

게임에서 UI 관련 기능을 구현하다보면 UI 상에서의 기능게임 상에서의 기능이 얼기설기 섞이는 경우가 있을 것이다.

두 기능은 분리해서 구현하는 것이 바람직하다는 것은 알 것이다.

또는 프로젝트를 진행 중 어떤 함수 기능의 변경이 필요한데, 그 함수를 수정할 수 없는 경우도 있을 것이다.

이 때 함수 자체를 인자로 넘겨주고, 함수를 호출하는 방식을 구현해서 해결할 수도 있다!

해당 방식을 위해서는 함수 구현 시 인자로 받을 함수가 있다는 것을 가정하고 구현하고, 사용할 때는 실제 함수를 인자로 넘겨주는 식인데, 이러한 방식을 콜백(Callback)이라고 한다.

대리자(Delegate)

C#에서는 콜백을 위해 대리자(Delegate)라는 타입을 사용한다.

delegate int OnClicked();

다음과 같이 앞에 delegate를 붙이면 된다.

정말정말 유의해야하는 것은, 이 때 OnClicked()는 함수가 아니라 string, int와 같은 형식(Type)이라는 것이다!

형식은 형식인데, 함수 자체를 인자로 넘겨주는 타입인 것이다.

delegate string myDelegate(int a, int b);

다음과 같이 작성하면 반환은 string, 입력으로 첫번째 int, 두번째 int를 받는 delegate 타입입니다! 라고 선언하는 것과 같다.

delegate를 변수로 받는 함수를 구현했으면, 해당 함수 내부에서는 해당 변수를 그냥 함수처럼 사용하면 된다.

static void useDelegate(myDelegate dFunction)
{
    int a = 3;
    int b = 7;
    Console.WriteLine(dFunction(a, b));
}

이러면 대리자와 그 메소드를 구현했으면, 실제 사용에선 그 delegate의 인스턴스를 구현해줘야 한다.

앞서 언급했듯이, delegate는 함수가 아니라 타입이기 때문에 그 타입에 맞는 함수를 선언해서 인스턴스를 만든 후, delegate 인자를 받는 함수에다가 그 인스턴스를 넘겨줘야 한다는 것이다.

즉, 전체 사용과정을 코드로 보면 다음과 같다.

delegate string myDelegate(int a, int b);

static void useDelegate(myDelegate dFunction)
{
    int a = 3;
    int b = 7;
    Console.WriteLine(dFunction(a, b));
}

static string AddFunction(int a, int b)
{
    return (a + b).ToString();
}

static string MulFunction(int a, int b)
{
    return (a * b).ToString();
}

static void Main(string[] args)
{
    useDelegate(AddFunction);
    useDelegate(MulFunction);
}

이 때 인자로 delegate를 넣어줘야하기 때문에 인자로 넣어주는 함수 인스턴스는 클래스 인스턴스에 종속된 함수가 아닌 static 함수를 넣어줘야 한다.

전체적인 작동방식은 C++의 함수 포인터와 비슷해보이지만, 함수의 참조를 넘겨주는 것이 아니라 함수를 객체로써 다룬다는 점이 약간 차이가 있겠다.

따라서 다음과 같은 식으로도 선언이 가능하다.

myDelegate AddNWrite = new myDelegate(AddFunction);
Console.WriteLine(AddNWrite(4, 9));

델리게이트 체이닝(Delegate Chaining)

함수를 객체처럼 다룰 수 있다는 특징 때문에 여러 가지 상상을 뛰어넘는 짓들이 가능한데, 그 중 하나가 델리게이트 체이닝이다.

두 델리게이트를 엮어버릴 수 있는 기능인데, 그냥 밑의 예제를 보자.

myDelegate AddNWrite = new myDelegate(AddFunction);
AddNWrite += MulFunction;
Console.WriteLine(AddNWrite(7, 11));

delegateoperator+()에 대한 오버라이딩이 되어있기 때문에 두 함수를 더해버리는 짓도 가능하다.

이럴 경우 AddNWrite()를 호출하면 AddFunction()MulFunction() 순으로 호출이 되고, return 값은 가장 마지막 함수의 리턴값으로 오버라이드 된다.

즉, MulFunction()의 결과 값인 77이 나온다.

Event

델리게이트가 완전 무적과도 같은 기능처럼 보이지만, 취약점이 하나 있다.

함수 호출에 제약이 전혀 없다는 것이다.

델리게이트가 특정 함수 안에서만 호출되게 하고 싶은데, 위의 예제대로만 하면 함수 밖에서도 자유자재로 호출이 된다.

그래서 델리게이트에 제약 조건을 추가한, 이벤트라는 것을 C#에서 지원한다.

class InputManager
{
    public void Update()
    {
        if (Console.KeyAvailable == false) return;

        ConsoleKeyInfo info = Console.ReadKey();
        if (info.Key == ConsoleKey.A)
        {
            // 기능?
        }
    }
}

다음과 같이 기능 구현을 한다고 했을 때, event를 이용하여 내부의 기능을 구현해줄 수 있다.

class InputManager
{
    public delegate void OnInputKey();
    public event OnInputKey InputKey;

    public void Update()
    {
        if (Console.KeyAvailable == false) return;

        ConsoleKeyInfo info = Console.ReadKey();
        if (info.Key == ConsoleKey.A)
        {
            InputKey();
        }
    }
}

class Program
{
    static void OnInputTest()
    {
        Console.WriteLine("A!");
    }

    static void Main(string[] args)
    {
        InputManager inputManager = new InputManager();
        inputManager.InputKey += OnInputTest;

        while (true)
        {
            inputManager.Update();
        }
    }
}

delegate와 다른 점은 event는 해당 클래스 외부에서 마음대로 호출할 수가 없다는 점이다.

+-를 이용해 event를 마치 호출할 함수들을 담고 빼는 그릇처럼 사용한다.

event는 할당 연산자 =를 사용할 수 없다.

반드시 +=, -=를 이용해서 함수를 추가해줘야 한다.

조금더 보안을 좋게 만들고 캡슐화를 더 한 것이다.

매우 안정성이 높은 이유이다.

설명해주는 사람들은 event를 마치 유튜브, event에 함수를 추가하는 것을 구독에 비유하는데, 아주 적절한 것 같다.

참고로 위의 예제처럼 객체의 상태 변화가 있을 때, 관련된 객체들에게 알림을 보내는 이러한 디자인 패턴을 옵저버 패턴이라고 한다.

event 예제를 하나 더 첨부하도록 하겠다.

// 델리게이트 선언
public delegate void EnemyAttackHandler(float damage);

// 적 클래스
public class Enemy
{
    // 공격 이벤트
    public event EnemyAttackHandler OnAttack;

    // 적의 공격 메서드
    public void Attack(float damage)
    {
        // 이벤트 호출
        OnAttack?.Invoke(damage);
				// null 조건부 연산자
				// null 참조가 아닌 경우에만 멤버에 접근하거나 메서드를 호출
    }
}

// 플레이어 클래스
public class Player
{
    // 플레이어가 받은 데미지 처리 메서드
    public void HandleDamage(float damage)
    {
        // 플레이어의 체력 감소 등의 처리 로직
        Console.WriteLine("플레이어가 {0}의 데미지를 입었습니다.", damage);
    }
}

// 게임 실행
static void Main()
{
    // 적 객체 생성
    Enemy enemy = new Enemy();

    // 플레이어 객체 생성
    Player player = new Player();

    // 플레이어의 데미지 처리 메서드를 적의 공격 이벤트에 추가
    enemy.OnAttack += player.HandleDamage;

    // 적의 공격
    enemy.Attack(10.0f);
}

Lambda

람다는 일회용 함수를 만드는데 사용하는 문법이라 할 수 있다.

비슷한 함수를 굉장히 여러 가지 만들어야 한다고 해보자.

delegate를 이용하여 중복되는 부분을 최대한 제거한다 하더라도 함수 선언이 굉장히 자주 이루어질 수밖에 없다.

또, 함수를 한 번만 사용한다면?

해당 함수의 메모리 조차도 아까울 때가 있을 것이다.

다음과 같은 함수가 있다고 하자.

static int Func_Double(int num)
{
    return num * 2;
}

이를 람다 함수로 만들어보자.

무명 메서드 (Anonymous Function)

delegate를 사용한다고 하면 다음과 같이 구현할 수 있겠다.

delegate int Del_Double(int num);

static void Main(string[] args)
{
    Del_Double del_Double;
    del_Double = delegate (int num) { return num *= 2; };
}

하지만 해당 형식을 람다라고 부르진 않는다.

이를 익명 메서드 또는 무명 메서드라고 부른다.

람다의 형식은 여기서 살짝만 더 바뀐다.

람다의 형식

입력값 => 반환값

delegate int Del_Double(int num);

static void Main(string[] args)
{
    Del_Double del_Double;
    del_Double = (int num) => { return num * 2; };
}

어떻게 쓸 지 매우 혼란스러운데, 이걸로 게임 시작화면을 만든다면 어떨지 예시를 첨부하겠다.

// 델리게이트 선언
public delegate void GameEvent();

// 이벤트 매니저 클래스
public class EventManager
{
    // 게임 시작 이벤트
    public event GameEvent OnGameStart;

    // 게임 종료 이벤트
    public event GameEvent OnGameEnd;

    // 게임 실행
    public void RunGame()
    {
        // 게임 시작 이벤트 호출
        OnGameStart?.Invoke();

        // 게임 실행 로직

        // 게임 종료 이벤트 호출
        OnGameEnd?.Invoke();
    }
}

// 게임 메시지 클래스
public class GameMessage
{
    public void ShowMessage(string message)
    {
        Console.WriteLine(message);
    }
}

// 게임 실행
static void Main()
{
    // 이벤트 매니저 객체 생성
    EventManager eventManager = new EventManager();

    // 게임 메시지 객체 생성
    GameMessage gameMessage = new GameMessage();

    // 게임 시작 이벤트에 람다 식으로 메시지 출력 동작 등록
    eventManager.OnGameStart += () => gameMessage.ShowMessage("게임이 시작됩니다.");

    // 게임 종료 이벤트에 람다 식으로 메시지 출력 동작 등록
    eventManager.OnGameEnd += () => gameMessage.ShowMessage("게임이 종료됩니다.");

    // 게임 실행
    eventManager.RunGame();
}

람다 일반화 (Action, Func)

위에서 설명한 방식에 Generic을 끼얹어서 범용성을 매우 크게 넓힐 수도 있다.

delegate Return MyFunc<T, Return>(T item);

static void Main(string[] args)
{
    MyFunc<int, int> gen_Double = (int num) => { return num * 2; };
}

처음 볼때는 너무나도 골때리는 방식.

놀랍게도 해당 대리자는 C#에서 이미 구현이 되어 있다.

내가 따로 MyFunc 같은 것을 선언해줄 필요가 없다는 말씀.

반환 타입이 있을 경우는 Func, 반환 타입이 없으면 Action을 사용한다.

야무지게 구현이 되어 있다.
오버로드가 무려 17개
Action도 마찬가지.

  • Func 예제
// Func를 사용하여 두 개의 정수를 더하는 메서드
int Add(int x, int y)
{
    return x + y;
}

// Func를 이용한 메서드 호출
Func<int, int, int> addFunc = Add;
int result = addFunc(3, 5);
Console.WriteLine("결과: " + result);

  • Action 예제
// Action을 사용하여 문자열을 출력하는 메서드
void PrintMessage(string message)
{
    Console.WriteLine(message);
}

// Action을 이용한 메서드 호출
Action<string> printAction = PrintMessage;
printAction("Hello, World!");

  • Action을 이용하여 체력 변경 시 이벤트 구현 예제
// 게임 캐릭터 클래스
class GameCharacter
{
    private Action<float> healthChangedCallback;

    private float health;

    public float Health
    {
        get { return health; }
        set
        {
            health = value;
            healthChangedCallback?.Invoke(health);
        }
    }

    public void SetHealthChangedCallback(Action<float> callback)
    {
        healthChangedCallback = callback;
    }
}

// 게임 캐릭터 생성 및 상태 변경 감지
GameCharacter character = new GameCharacter();
character.SetHealthChangedCallback(health =>
{
    if (health <= 0)
    {
        Console.WriteLine("캐릭터 사망!");
    }
});

// 캐릭터의 체력 변경
character.Health = 0;

Exception

예외 처리이다.

버그나 크래쉬 같은 상황을 방지하기 위해 사용한다.

  • 0으로 나눌 때
  • 잘못된 메모리 참조
  • 오버플로우

int a = 10;
int b = 0;
int result = a / b;

다음과 같이 코드를 작성하면, 바로 크래시가 나는 것을 볼 수 있다.

하지만, 다음과 같이 작성한다면?

try
{
    int a = 10;
    int b = 0;
    int result = a / b;
}
catch (Exception ex)
{

}

다음과 같이 크래쉬가 나지 않고, 크래쉬 정보가 Exception 변수인 ex에 들어가 있는 것을 볼 수 있다.

Exception이 정확히 뭐냐하면, 모든 예외 사항의 조상님 같은 존재라고 생각하면 좋다.

해당 Exception들을 구분해서 catch 해줄 수도 있다.

try
{
    int a = 10;
    int b = 0;
    int result = a / b;
}
catch (DivideByZeroException ex)
{

}
catch (Exception ex)
{

}

이 때 catch가 여러 개면 한 개의 catch만 작동한다.

try
{
    int a = 10;
    int b = 0;
    int result = a / b;
}
catch (Exception ex)
{

}
catch (DivideByZeroException ex)
{

}

위의 코드 역시 생각해볼 수 있겠지만,

오류를 뱉어낸다.

상위의 Exception이 있어서 오류가 난다.

참고로 try을 수행하다가 catch에 걸려버리면 try에서 걸린 지점 뒤로는 전부 무시된다.

try
{
    int a = 10;
    int b = 0;
    int result = a / b;
    Console.WriteLine("메롱");
}
catch (Exception ex)
{

}

catch에도 elsedefault 같은 것이 있을까?

바로 finally이다.

finally는 예외 발생 여부와 상관없이 항상 실행되는 코드 블록이다.

예외 처리의 마지막 단계이기 때문에 보통은 예외 발생 시 정리 작업이나 리소스 해제 등의 코드를 작성한다.

try
{
    int a = 10;
    int b = 1;
    int result = a / b;
}
catch (Exception ex)
{

}
finally
{
    Console.WriteLine("정상!");
}

Exception은 기본으로 제공된 것만 쓰는 것이 아니라 내가 직접 만들 수도 있다.

Exception을 상속받는 새로운 클래스를 생성하는 방식이다.

class myException : Exception
{

}


try
{
    int a = 10;
    int b = 1;
    int result = a / b;

    throw new myException();
}
catch (Exception ex)
{
    Console.WriteLine("억까!");
}

유니티에서 쓰일 일은 잘 없으니까 참고만 해두자.

다양한 Exception 사용 예제

// 플레이어 이동
try
{
    // 플레이어 이동 코드
    if (IsPlayerCollidingWithWall())
    {
        throw new CollisionException("플레이어가 벽에 충돌했습니다!");
    }
}
catch (CollisionException ex)
{
    // 충돌 예외 처리
    Debug.Log(ex.Message);
    // 예외에 대한 추가 처리
}


// 리소스 로딩
try
{
    // 리소스 로딩 코드
    LoadResource("image.png");
}
catch (ResourceNotFoundException ex)
{
    // 리소스가 없는 경우 예외 처리
    Debug.Log(ex.Message);
    // 예외에 대한 추가 처리
}
catch (ResourceLoadException ex)
{
    // 리소스 로딩 중 오류가 발생한 경우 예외 처리
    Debug.Log(ex.Message);
    // 예외에 대한 추가 처리
}


// 게임 상태 전이
try
{
    // 상태 전이 코드
    if (currentGameState != GameState.Playing)
    {
        throw new InvalidStateException("게임이 실행 중이 아닙니다!");
    }
    // 게임 상태 전이 실행
}
catch (InvalidStateException ex)
{
    // 상태 예외 처리
    Debug.Log(ex.Message);
    // 예외에 대한 추가 처리
}

Reflection

클래스를 보면, 클래스의 정보를 알 수 있게 해주는 많은 기능들이 존재한다.

클래스의 이름을 알 수 있게 해준다던지 (GetType()), 가지고 있는 정보들이 무엇인지 (GetFields()), 어떤 함수를 가지고 있는지 등을 할 수 있다.

해당 정보를 런타임에 알 수 있는 근본적인 이유가 바로 C#에서 리플렉션이라는 기능을 지원해주기 때문이다.

using System.Reflection;

class Character
{
    public int publicProperty;
    protected int protectedProperty;
    private int privateProperty;

    void myMethod() { }
}
static void Main(string[] args)
{
    Character character = new Character();
    Type type = character.GetType();

    FieldInfo[] fields = type.GetFields(BindingFlags.Public
        | BindingFlags.NonPublic
        | BindingFlags.Static
        | BindingFlags.Instance);

    foreach (FieldInfo field in fields)
    {
        string access = "protected";
        if (field.IsPublic)
            access = "public";
        else if (field.IsPrivate)
            access = "private";

        Console.WriteLine($"{access} {field.FieldType.Name} {field.Name}");
    }
    
}

Attribute

보통 프로퍼티를 설명해줄 때, 주석을 남기게 되지만 런타임에 이를 확인할 수 없다.

컴파일러가 다 무시해버리니깐!

런타임에도 확인할 수 있게 정보를 남길 수 있을까?

그것이 바로 Attribute라는 것이다.

class Important : System.Attribute
{

}

class Character
{
    [Important]
    public int publicProperty;

    protected int protectedProperty;
    private int privateProperty;

    void myMethod() { }
}

유니티에서 사용해주던 [SerializedField]가 바로 이러한 Attribute이다.

이를 Reflection에선 GetCustomAttributes()로 확인이 가능하다.

Nullable

직역하면 Null + able로 읽힌다.

C#에서 참조 방식의 변수들은 null을 집어넣는 것이 가능하지만, 값 방식의 변수들은 그렇지 않다.

즉, int나 float 같은 변수에 null을 넣을 수가 없는 것이다.

하지만, 자료구조에서의 Find 함수와 같이 못찾았을 때 null을 반환하는 함수가 필요할 것이다.

이 때 변수를 Nullable로 만들면 해당 문제를 해결할 수 있다.

int? number = null;

이 때 intint?는 서로 다른 자료형이기 때문에 int에다가 int?를 직접 대입하는 것이 불가능하다.

따라서 Value라는 프로퍼티를 넣어주게 된다.

int? number 3;
int a = number.Value;

이 때 int?null이 들어있는 상태로 값에 접근해 int에 넣어주면 Exception Error가 뜬다.

따라서 HasValue를 이용해 먼저 null 체크를 활용할 수도 있고, ??를 사용할 수도 있다.

int? number 5;
int b = number ?? 0;

위의 ??는 Nullable이 아니더라도 참조 방식의 변수에도 비슷한 기능을 하는 것이 있다.

바로 ?.이다.

class Character
{
    public int Id {  get; set; }
}

static void Main(string[] args)
{
    Character character = null;

    int? id = character?.Id;
}


Uploaded by N2T