Nullable 참조 형식 실전
이미 있는 코드에 뒤늦게 들이는 방법입니다.
형식은 실행되지 않습니다
3.6에서 물음표로
null 을 형식에 적었습니다. 컴파일러가 그것을 보고 경고를
냅니다.
거기까지입니다. 만들어진 프로그램에는 그 표시가 남지 않고,
null 인지 확인하는 코드도 생기지 않습니다.
바깥에서 들어오는 값은 형식을 지키지 않습니다.
그래서 이 단원은 두 가지입니다. 바깥과 만나는 자리를 어떻게 막을 것인가, 그리고 이미 있는 코드에 어떻게 뒤늦게 들일 것인가입니다.
적혀 있어도 들어옵니다
아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.
using System.Text.Json; // Name 에는 물음표가 없습니다. null 이 아니라고 적은 것입니다. // (형식 선언은 최상위 문보다 뒤에 와야 하므로 아래에 있습니다) // 1) 값을 빠뜨린 JSON var a = JsonSerializer.Deserialize<Person>("""{"Age":32}""")!; Console.WriteLine(a.Name is null ? "null" : a.Name); // 2) 대놓고 null 을 넣은 JSON var b = JsonSerializer.Deserialize<Person>("""{"Name":null,"Age":32}""")!; Console.WriteLine(b.Name is null ? "null" : b.Name); // 3) 형식은 null 이 아니라고 하는데, 물으면 try { _ = a.Name.Length; } catch (Exception ex) { Console.WriteLine(ex.GetType().Name); } // 4) required 를 붙이면 들어오는 자리에서 막힙니다 try { _ = JsonSerializer.Deserialize<Strict>("""{"Age":32}"""); } catch (Exception ex) { Console.WriteLine(ex.GetType().Name); } class Person { public string Name { get; set; } public int Age { get; set; } } class Strict { public required string Name { get; set; } public int Age { get; set; } }
코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.
물음표를 붙이지 않았는데 null 이 들어
있습니다. 컴파일러는 Name 을 안전한 것으로 보고
경고를 내지 않으므로, 확인하지 않은 코드가 그대로 나갑니다.
마지막 줄이 답입니다. required 를 붙이면
만들어지는 자리에서 막힙니다. 안쪽까지 들어와 엉뚱한 곳에서
예외가 발생하는 것보다 낫습니다.
바깥과 만나는 자리
형식을 지키지 않는 곳은 정해져 있습니다. 거기서만 막으면 안쪽은 믿을 수 있습니다.
| 어디서 | 어떻게 막는가 |
|---|---|
| JSON · 폼 · 쿼리 | required 를 붙이거나 받는 형식에 물음표를 적고 한 번 검사합니다 |
| 데이터베이스 | 컬럼이 NULL 을 받으면 물음표를 적는 것이 사실입니다 |
| 리플렉션 · 역직렬화 | 형식을 거치지 않고 담습니다. 위와 같습니다 |
| 물음표가 없는 옛 라이브러리 | 표시가 없으므로 컴파일러가 아무 말도 하지 않습니다. 받는 쪽에서 검사합니다 |
표에 NULL 이 들어가는 컬럼을 물음표 없이 받지
마십시오. 형식에 거짓을 적는 것이고, 그 거짓을 믿고 적은 코드가 실행할 때
무너집니다. 물음표를 적으면 컴파일러가 검사하지 않은 자리를 전부 짚어 줍니다.
! 는 아무 일도 하지 않습니다
! 는 경고만 지웁니다. 값은 그대로입니다.
string? maybe = null; var forced = maybe!; // 경고가 사라집니다 forced is null; // True forced.Length; // NullReferenceException
그러니 ! 는 사람이 아는 것을 컴파일러가 모를
때만 정직합니다. 그런 자리는 생각보다 적습니다.
| 이럴 때 | 정직한가 |
|---|---|
| 바로 위에서 검사했는데 못 알아볼 때 | 대개 흐름을 알려 주는 특성으로 고칠 수 있습니다 |
| 테스트 자료를 만들 때 | 됩니다. 틀리면 그 자리에서 드러납니다 |
| 경고가 시끄러워서 | 아닙니다. 미룬 것이지 고친 것이 아닙니다 |
| 바깥에서 들어온 값 | 아닙니다. 거기가 검사할 자리입니다 |
! 를 적을 때는 왜 아는지를 주석으로 함께
남기십시오. 나중에 그 근거가 사라져도 코드는 그대로 남습니다.
나중에 채우는 것
생성자가 직접 담지 않고 다른 메서드가 담으면 컴파일러가 알아보지 못합니다.
class Sample { private string _name; public Sample() => Init(); private void Init() => _name = "값"; } // warning CS8618: null을 허용하지 않는 속성 'Name'은(는) 생성자를 종료할 때 // null이 아닌 값을 포함해야 합니다.
[MemberNotNull] 로 이 메서드를 지나면 채워져
있다고 알려 주면 경고가 사라집니다. ! 와 달리
거짓이면 그 메서드 안에서 경고가 납니다.
using System.Diagnostics.CodeAnalysis; [MemberNotNull(nameof(_name))] private void Init() => _name = "값"; // 경고가 사라집니다
비슷한 것이 몇 가지 더 있습니다. 3.6의
[NotNullWhen] 과 같은 묶음입니다.
| 특성 | 무엇을 알려 주는가 |
|---|---|
| [MemberNotNull] | 이 메서드를 지나면 그 멤버는 채워져 있습니다 |
| [MemberNotNullWhen] | 돌려주는 값이 그것이면 채워져 있습니다 |
| [NotNullWhen] | TryParse 처럼 true 일 때만 값이 있습니다 |
| [AllowNull] | 담을 때는 null 도 받지만 읽을 때는 아닙니다 |
| [DisallowNull] | 읽으면 null 일 수 있지만 담을 때는 안 됩니다 |
제네릭에서는 물음표가 다릅니다
제약이 없는 T 는 참조 형식일 수도 값 형식일 수도
있습니다. 그래서 T? 가 두 가지를 뜻합니다.
static T? Default<T>() => default; Default<string>() is null; // True 참조 형식이면 null Default<int>(); // 0 값 형식이면 0 입니다. null 이 아닙니다
T? 를 돌려주고 null 인지
물으면 값 형식에서 틀립니다. 둘을 갈라야 한다면 제약을 적으십시오.
where T : class // 참조 형식만. T? 는 null 일 수 있는 참조입니다 where T : struct // 값 형식만. T? 는 Nullable<T> 입니다 where T : notnull // null 이 올 수 없는 것만
이미 있는 코드에 들이기
프로젝트 전체를 한 번에 켜면 경고가 수백 개 쏟아지고 아무도 보지 않게 됩니다. 켜는 방식이 네 가지 있습니다.
| <Nullable> | 물음표를 읽는가 | 경고를 내는가 |
|---|---|---|
| disable | 아니요. 물음표 자체가 CS8632 | 아니요 |
| annotations | 예 | 아니요 |
| warnings | 아니요 | 예 |
| enable | 예 | 예 |
같은 코드를 네 번 빌드해 받은 번호입니다.
string? maybe = null; Console.WriteLine(maybe.Length); // 역참조 string never = null; // null 을 담기 // disable CS8632 // annotations (없음) // warnings CS8602 · CS8632 // enable CS8600 · CS8602
annotations 로 시작하는 것이 편합니다.
물음표를 적어 나가면서 경고에는 아직 쫓기지 않습니다. 다 적고 나면
enable 로 올립니다.
파일 단위로 나눌 수도 있습니다. 손대는 파일부터 켜 나가는 방식이 가장 널리 사용됩니다.
// 파일 맨 위에 #nullable enable // 한 구간만 잠시 끄기 #nullable disable warnings … #nullable restore
다 옮기고 나면 경고를 오류로 올리십시오. 경고로 두면 다시 쌓입니다.
<Nullable>enable</Nullable> <WarningsAsErrors>nullable</WarningsAsErrors>
옮기는 차례
| 차례 | 무엇을 |
|---|---|
| 1. 안쪽부터 | 다른 것에 기대지 않는 형식부터. 밖에서 켜면 안쪽 경고가 밀려 올라옵니다 |
| 2. 사실대로 적기 | null 이 올 수 있으면 물음표를 붙입니다. 붙이지 않으려고 고치지 마십시오 |
| 3. 경계를 막기 | 들어오는 자리에서 한 번 검사하면 안쪽 경고가 대부분 사라집니다 |
| 4. 오류로 올리기 | 다 옮긴 프로젝트부터 WarningsAsErrors |
2번이 가장 자주 어긋납니다. 경고를 없애려고 물음표를 떼거나
! 를 붙이면, 형식에 적힌 것과 실제가 갈라집니다.
그 상태의 코드는 켜기 전보다 위험합니다. 읽는 사람이 형식을 믿기
때문입니다.
이 문법, 몇 버전부터 사용할 수 있나요
- 8.0Nullable 참조 형식 · NotNullWhen · AllowNull
- 9.0MemberNotNull · MemberNotNullWhen
- 11.0required
이 기능은 한 번에 완성되지 않았습니다. 8.0이 물음표와 경고를
들여왔지만, 생성자 밖에서 채우는 코드나 들어오는 값을 다루기에는 모자랐습니다.
9.0의 [MemberNotNull] 과 11.0의
required 가 그 자리를 메웠습니다. 지금 옮기신다면
셋을 함께 사용할 수 있으니, 예전 글에서 ! 로
넘기라고 한 자리들은 다시 보십시오.
직접 해보기
아래 형식은 데이터베이스에서 읽어 옵니다. memo 컬럼은
NULL 을 받고 나머지는 받지 않습니다.
경고가 나지 않으면서 사실과 맞도록 고쳐보세요.
class Member { public int Id { get; set; } public string Name { get; set; } public string Memo { get; set; } }
아래 코드에는 ! 가 셋 있습니다. 각각
정직한지 판단하고, 아닌 것은 고쳐보세요.
class Cache { private Dictionary<string, string> _map; public Cache() => Load(); private void Load() => _map = new(); public bool Has(string key) => _map.ContainsKey(key); public string Get(string key) { _map.TryGetValue(key, out var v); return v!; // 1 } public string First(List<string>? keys) => Get(keys!.First()); // 2 public static Cache FromJson(string json) => JsonSerializer.Deserialize<Cache>(json)!; // 3 }
- 물음표는 컴파일할 때만 있습니다. 실행 중에 확인하는 코드는 생기지 않습니다.
- JSON·데이터베이스는 형식을 지키지 않습니다. 들어오는 자리에서 막으십시오.
required를 붙이면 만들어지는 자리에서 걸립니다.!는 값을 바꾸지 않고 경고만 지웁니다. 적을 때는 까닭을 함께 남기십시오.- 나중에 채우는 것은
[MemberNotNull]입니다. 거짓이면 경고가 납니다. - 제약 없는
T?는 값 형식에서null이 아닙니다. - 옮길 때는
annotations부터. 경고를 없애려고 형식에 거짓을 적지 마십시오.
C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.