옵 셔널 필드 — 타입 끝의 ?
없음을 셀에 적는 표기는 빈 칸과 없음이 대체합니다. 이 문서가 「빈 칸」이라고 적은 자리는
-입니다 — 빈 칸은 그 타입이 읽는 값(""·false· 빈 배열)이고,?는 「이 컬럼에-를 적을 수 있다」를 뜻합니다. 아래의 와이어 설계와 액세서 결정은 그대로입니다.
어떤 컬럼이 모든 로우에 값을 요구하는가를 시트가 적을 수 있게 하는 기능의 설계입니다. 왜 필요한지는 그 레이아웃 분석에 있습니다.
1. 문제
테빗은 전부 required입니다. 그렇게 정한 적은 없고, 그냥 그렇게 동작합니다. 빈 칸을 만나면 타입마다 다르게 굽니다.
| 타입 | 빈 칸 |
|---|---|
string | "" — 문서화되어 있고, 실제로 그렇게 씁니다 |
bool | false — 같음 |
int long float double | 파싱 오류 |
datetime timespan uuid | 파싱 오류 |
| enum 라벨 | 파싱 오류 |
즉 숫자 컬럼의 빈 칸은 언제나 오류였고, 그것을 허용한다고 적을 방법이 없었습니다.
named-range 레이아웃 은 :required 행으로 컬럼마다 이것을 적습니다. 받아 옮길 곳이 모델에 없었습니다.
2. 표기 — 타입 끝의 ?
결정. 타입 이름 끝에 ?를 붙이면 required=false입니다.
| 타입 칸 | 뜻 |
|---|---|
int | 필수. 값이 없다고 적을 수 없습니다 |
int? | 없음을 적을 수 있습니다. 적는 방법은 -입니다 |
int[]? | 배열이 옵셔널. 옵셔널의 배열이 아닙니다 |
왜 타입에 붙이는가. 질문이 있는 곳이 거기입니다 — "여기 숫자가 반드시 있어야 하나"는
숫자에 대한 질문입니다. 그리고 이 표기에서 코드를 생성해 받는 언어들이 ?로 뜻하는 것과
같게 읽힙니다.
?는 배열 괄호 뒤에 옵니다. 한 셀에 든 구분자 목록의 원소들은 다 있거나 다 없거나이지,
가운데 하나가 비는 것이 아닙니다. 그래서 원소가 옵셔널인 배열은 표현하지 않습니다.
이 전제를 원소가 없을 수 있는 배열이 뒤집습니다. 실제 시트에는 가운데가 빈 배열이 있고, 그것을 표현할 수 없어서 생긴 우회가 셋입니다. 괄호 앞의
?가 원소의 답이 되고, 뒤의?는 이 문서가 정한 그대로입니다.
인덱스는 예외 없이 required입니다. 로우를 식별하는 것이고 이 테이블을 가리키는 모든
참조가 그것을 거쳐 풀립니다. 빈 인덱스는 뜻할 것이 없습니다. int?를 첫 컬럼에 적으면
거부합니다 — 그러지 않으면 빈 로우들이 전부 인덱스 0을
받고, 실패가 중복 키로 나타나거나 (그런 로우가 하나뿐인 테이블에서는) 아예 나타나지 않습니다.
* 접두사로 붙인 두 번째 인덱스도 같은 이유로 같이 거부합니다.
required는 계속 기본값입니다. ?를 적지 않은 컬럼은 지금과 완전히 같습니다. 그래서
이 기능은 존재하는 모든 시트에 대해 더해지기만 합니다.
3. 두 단계 구분
?가 뜻할 수 있는 것이 둘인데, 값이 다르고 비용이 아주 다릅니다.
| 단계 | 뜻 | 필요한 것 | 상태 |
|---|---|---|---|
| 1 | 없음을 받아 타입의 빈 값으로 읽는다 | 파서뿐 | 됨 |
| 2 | 값이 없을 수 있다 | 와이어에 존재 여부, 리더와 생성기 | 됨 — 모든 언어와 json·binary |
2단계가 진짜 옵셔널입니다. 1단계만으로는 읽는 쪽이 없다고 적은 칸과 0이라고 적은 칸을 구별할 수 없습니다. 0이 뜻을 갖는 컬럼에서는 그 구별이 필요한 것의 전부입니다.
한동안 아직 지원하지 않은 타깃들이 그 이름과 함께 거부했습니다. 그 기간에 그 리더들은 kind 마스크를
0x03에서0x07로 넓혀 두었습니다 — 그러지 않으면 비트 6을 그냥 무시해서 presence 비트맵을 값으로 읽기 때문입니다. 이제 전부가 비트 6을 nullability로 읽으므로 마스크는 다시0x03입니다. 거부 자체는ITarget에 남아 있고, 14번째 타깃이 지원하기 전에 만나는 것이 그것입니다.
2단계 설계
와이어 바이트의 비트 6을 nullability 플래그로 씁니다. 지금 이 바이트는 하위 4비트가
엘리먼트 타입, 비트 45가 kind이고, 비트 67은 쓰이지 않습니다.
7 6 5 4 3 2 1 0
[ ? ] [ kind ] [ element ]
↑
새로 쓰는 비트
kind 값을 쓰지 않는 이유. kind는 2비트라 남은 값이 3 하나뿐입니다. 옵셔널은 kind와
직교합니다 — 스칼라도, 고정 배열도, 가변 배열도 옵셔널일 수 있습니다. 직교하는 것을
kind에 넣으면 남은 한 자리를 쓰고도 조합을 표현하지 못합니다. 마지막 kind 값은 진짜 새로운
형태를 위해 남겨 둡니다.
블록 형태. 와이어 배치와 실제 파일의 바이트는
TCB v103에 있습니다. 컬럼 블록 앞에 로우당 1비트의
presence 비트맵을 붙이고, 값 블록은 지금
그대로 둡니다 (없는 로우는 빈 값을 차지). 그래서 기존 디코드 경로를 건드리지 않고,
리더마다 늘어나는 것은 비트맵을 읽어 IsPresent(row)를 내주는 것뿐입니다.
값을 압축해 담는 편이 바이트는 적지만, 인코딩 9종 × kind 3종의 디코드 경로를 전부 "present인 로우만 세면서" 다시 쓰게 됩니다. 1비트/로우를 내고 그것을 사지 않습니다.
버전을 v103으로 올렸습니다. 비트 6을 검사하지 않는 기존 리더는 새 파일을 드러나지 않게 잘못
읽습니다. 감지되지 않는 읽기 오류보다 버전 거부가 낫습니다. 옵셔널 컬럼이 없는 파일은 버전 4바이트만
달라집니다 — 실제로 골든의 .tcb 하나를 비교하면 다른 바이트가 정확히 하나입니다.
레코드의 멤버는 presence를 갖지 않습니다. 레코드는 소비하는 쪽에 하나의 값이고, 「Id는
있는데 Count는 없다」는 그 API에 없는 형태입니다. 레코드 배열이 표현하는 없음은
원소 개수이고, 그것은 비트맵이 아니라 길이입니다.
자르는 시트 에서 멤버에 ?를 다는 것은 여전하지만 — 셀이 「값 없음」을 나타내는 방법이 그것이므로 —
와이어까지 가지는 않습니다.
액세서 — HasPrice를 냅니다. 값의 타입은 그대로 둡니다.
처음에는 「옵셔널 값 타입이 있는 언어는 그것을 쓰고(int?·number | null·Option<i32>·
*int32), 없는 언어는 HasPrice()를 낸다」로 적었습니다. 바꿨습니다. 이유가 셋입니다.
- 모든 언어에서 같은 형태인 것이 이것뿐입니다. 언어마다 다른 표현을 쓰면 언어별 타입 매핑표가 하나 더 생기고, 그 표가 틀려도 아무 신호가 없습니다.
string에서 두 방식이 갈립니다. 값 타입은int?가 명확한데 참조 타입은string그대로라 API가 null일 수 있다고 나타내지 않습니다. C#에서string?을 적으려면 nullable 참조 문맥이 필요하고, 생성 코드는 Unity 2020.3의 C# 8로 컴파일되어야 합니다.HasLabel은 두 경우에 똑같이 동작합니다.- 읽는 쪽 코드가 짧아지지 않습니다.
x.HasValue나x != null이나HasX나 분기 하나입니다.
언리얼이 이 판단을 가장 분명하게 확인해 줍니다. TOptional<T>는 4.27에도 있지만
UPROPERTY로 쓸 수 없습니다 — 리플렉션이 아는 프로퍼티 타입이 아니고, FOptionalProperty는
UE 5.4에 들어왔습니다. 생성되는 멤버는 UPROPERTY(EditAnywhere, BlueprintReadOnly)이므로
TOptional을 쓰려면 UPROPERTY를 떼야 하고, 그러면 에디터와 블루프린트에서 보이지 않습니다 —
언리얼 타깃을 쓰는 이유가 사라집니다.
그리고 엔진 자신이 같은 문제를 같은 형태로 풉니다: FPostProcessSettings의
bOverride_{속성} + {속성} 쌍이 그것입니다. 프로퍼티가 옵셔널일 수 없어서 플래그를 따로 둔
것이고, HasX와 정확히 같습니다. 즉 언리얼에서 이것은 타협이 아니라 관용구입니다.
C++는 std::optional<T>을 쓸 수 있습니다. 쓰지 않는 이유는 그러면 13개 중 둘만 다른 형태가
되고, 그 둘이 C++와 언리얼로 갈리기 때문입니다 — 같은 데이터를 두 타깃으로 받는 프로젝트가
바로 이 프로젝트입니다.
required 컬럼의 액세서는 바뀌지 않습니다. 늘어나는 것은 ?를 적은 컬럼의 HasX 하나뿐이고,
값 액세서는 그대로입니다 — 값이 없는 로우에서는 타입의 빈 값을 냅니다(1단계와 같음).
4. 모델
Field.IsRequired 하나입니다. 기본값 true.
?는 파서에서 떨어집니다. CookingContext.SplitOptionalMarker가
타입 이름에서 떼어내고, 그 아래로는 아무도 ?를 보지 않습니다 — required 여부는 타입 이름이
아니라 필드가 지고 다닙니다. int?는 int와 같은 ValueType으로 풀립니다.
레이아웃마다 이것을 적는 방법이 다르고, 여기서 만납니다.
| 레이아웃 | 적는 곳 |
|---|---|
tabbit | 타입 칸 끝의 ? |
named-range | :required 행. 1이면 필수, -면 아님. 행이 아예 없는 테이블은 전부 옵셔널 |
| 다른 레이아웃 | 없음. 전부 required |
표기가 둘이고 결과가 하나인 것이 모델이 이것을 지고 다니는 이유입니다.
5. string과 bool
이 둘은 빈 칸을 ""와 false로 읽습니다. 문서화되어 있고 의존하는 시트가 있습니다. 그래서
?를 붙이지 않은 string을 지금 required로 만들지 않습니다 — 그것은 이 기능이 조용히
켜는 것이 아니라 따로 내릴 결정입니다.
?가 붙어도 빈 칸의 뜻은 같습니다. string?의 빈 칸은 빈 문자열이고, 없음은 -입니다.
빈 칸과 없음이 정하는 것이 그것입니다 — 이 문서를 처음 적을 때는
옵셔널 컬럼의 빈 칸을 없음으로 읽었고, 그러면 string? 컬럼에 빈 문자열을 적을 방법이
없었습니다.
이 둘이 presence를 확인하는 유일한 방법인 것도 여기서 나옵니다. 값만 보면 「없다고 적은 칸」과 「빈 문자열을 적은 칸」이 같아 보이므로, 라운드트 립 테스트가 이 두 타입을 일부러 포함합니다 — 비트맵이 틀려도 값 비교로는 드러나지 않습니다.