값이 없다는 것도 상태다
NullReferenceException 은 C# 에서 가장 자주 만나는 예외입니다.
앞에서 원인은 언제나 같다고 했습니다. 아무도 안 가리키는 것을 따라간 것입니다.
그런데 왜 이렇게 자주 날까요.
string name = FindName(id);
Console.WriteLine(name.Length);
FindName 이 못 찾으면 무엇을 돌려줄까요. 아마 null 일 겁니다.
그런데 그 사실이 코드 어디에도 안 적혀 있습니다. 타입은 그냥 string 입니다.
string 이라고 적혀 있으면 문자열이 온다고 읽습니다.
null 이 올 수도 있다는 것은 FindName 안을 열어 봐야 압니다.
그래서 확인을 빠뜨리고, 실행하다가 터집니다.
없을 수 있다는 것을 타입에 적어 두면 컴파일러가 확인해 줍니다.
값 타입에는 원래 null 이 없었다
먼저 값 타입부터 봅니다.
int n = null; // 컴파일 에러
int 는 4바이트 자리에 숫자가 들어 있습니다. 비어 있는 상태가 없습니다.
어떤 비트 조합이든 어떤 숫자로 읽힙니다. 0 도 값이지 “없음” 이 아닙니다.
그런데 없음이 필요할 때가 있습니다.
- 아직 입력하지 않은 나이
- 아직 안 정한 점수
- 데이터베이스에서 비어 있는 칸
0 으로 쓰면 안 됩니다. 0살인 것과 아직 안 넣은 것을 구분할 수 없습니다.
앞에서 Age 를 안 넣어서 0 이 됐을 때 문제였던 그 상황입니다.
물음표를 붙이면 됩니다.
int? age = null;
if (age.HasValue)
{
Console.WriteLine(age.Value);
}
int? 는 int 아니면 없음을 담을 수 있는 타입입니다.
HasValue 로 확인하고 Value 로 꺼냅니다.
int n = age ?? 0; // 없으면 0으로
?? 는 왼쪽이 null 이면 오른쪽을 쓰라는 뜻입니다. 곧 다시 나옵니다.
참조 타입에는 원래 null 이 있었다
참조 타입은 반대입니다. 처음부터 null 이 될 수 있었습니다.
주소를 담는 자리인데 “아직 아무 데도 안 가리킴” 이 자연스러운 상태니까요.
문제는 그게 구분이 안 된다는 것입니다.
string a = "안녕"; // 항상 값이 있는 것
string b = null; // 없을 수도 있는 것
둘 다 타입이 string 입니다. 코드를 읽는 사람도, 컴파일러도 구분을 못 합니다.
그래서 요즘 C# 에는 참조 타입에도 물음표를 붙이는 문법이 있습니다.
string name = "안녕"; // 없으면 안 되는 것
string? found = null; // 없을 수도 있는 것
이걸 켜 두면 컴파일러가 봐 줍니다.
string? found = FindName(id);
Console.WriteLine(found.Length); // 경고: null 일 수 있습니다
string name = null; // 경고: 물음표가 없는데 null 을 넣었습니다
확인을 빠뜨린 자리를 컴파일할 때 알려 줍니다. 실행해 볼 필요가 없습니다. 새 프로젝트를 만들면 대체로 켜져 있습니다.
물음표가 하는 일은 값을 바꾸는 것이 아닙니다. 의도를 적어 두는 것입니다. “여기는 없을 수 있다” 를 타입에 남기면 읽는 사람도 알고 컴파일러도 압니다.
null 검사를 짧게 쓰는 기호 세 가지
if (x != null) 을 계속 적는 것이 번거로워서 짧게 쓰는 문법이 있습니다.
?. 앞이 null 이면 그냥 넘어간다
int? len = name?.Length;
name 이 null 이면 Length 를 안 물어보고 결과가 null 이 됩니다.
안 터집니다. 결과가 null 일 수 있으니 받는 쪽이 int? 입니다.
이어 붙일 수도 있습니다.
string? city = player?.Address?.City;
중간 어디가 null 이든 그 자리에서 멈추고 null 이 됩니다.
if 를 세 겹으로 쓰던 것이 한 줄이 됩니다.
앞에서 이벤트를 부를 때 쓴 Died?.Invoke() 가 이것입니다.
?? 앞이 null 이면 오른쪽을 쓴다
string display = name ?? "이름 없음";
int count = list?.Count ?? 0;
?. 와 짝으로 자주 씁니다. “없으면 이걸로” 라는 기본값을 적는 자리입니다.
??= 비어 있을 때만 넣는다
list ??= new List<int>();
list 가 null 일 때만 새로 만듭니다. 이미 있으면 그대로 둡니다.
if (list == null) list = new List<int>();
이 두 줄과 같습니다.
없음을 애초에 안 만드는 것이 낫다
문법이 편해졌다고 null 을 많이 쓰라는 뜻은 아닙니다.
없을 수 있는 자리를 줄이는 것이 먼저입니다.
목록을 돌려주는 메서드를 보겠습니다.
List<int>? GetItems()
{
if (없으면) return null;
...
}
List<int> GetItems()
{
if (없으면) return new List<int>(); // 빈 목록
...
}
아래쪽이 낫습니다. 빈 목록은 그냥 돌면 0번 도는 것이라 부르는 쪽이 확인할 것이 없습니다.
null 을 돌려주면 부르는 모든 곳에서 확인해야 합니다.
문자열도 같습니다. null 대신 빈 문자열을 돌려주면 대부분 그냥 동작합니다.
앞에서 생성자로 상태를 지키는 이야기를 했습니다. 여기도 같은 자리입니다.
만들 때 채워 두면 null 이 될 일이 없습니다.
class Player
{
public string Name { get; }
public List<Item> Items { get; } = new List<Item>();
public Player(string name)
{
Name = name;
}
}
Items 는 절대 null 이 아닙니다. 만들 때 빈 목록으로 채웠습니다.
쓰는 쪽에서 확인할 일이 없어집니다.
정리
null은 0이 아니라 가리키는 대상이 없는 상태입니다.- 값 타입에는 원래 없음이 없습니다.
int?처럼 물음표를 붙이면 담을 수 있습니다. - 참조 타입은 원래
null이 될 수 있었는데, 그 사실이 타입에 안 적혀 있었습니다. string?처럼 적어 두면 컴파일러가 확인 빠뜨린 자리를 알려 줍니다.- 물음표는 값을 바꾸는 것이 아니라 의도를 적어 두는 것입니다.
?.는 앞이null이면 넘어가고,??는 없으면 오른쪽을 쓰고,??=는 비어 있을 때만 넣습니다.- 문법보다 중요한 것은 없을 수 있는 자리를 줄이는 것입니다.
- 빈 목록이나 빈 문자열을 돌려주면 부르는 쪽이 확인할 것이 없어집니다.