TCB v106 — 원소 presence 비트맵
상태: 구현 완료 — 변환기와 모든 런타임 · 대상 버전 106 (105를 대체)
배열의 어느 원소에 값이 있는지를 파일에 담습니다. 와이어 바이트의 비트 7이 그 사실을 선언하고, 컬럼 블록에 원소당 1비트의 비트맵이 하나 더 붙습니다. 값 블록과 인코딩 13종은 그대로입니다.
이 문서의 범위는 형식으로 한정합니다. 시트 표기(T?[] · T[]? · T?[]?)와 모델, 그리고
모든 언어가 내는 원소별 답은 원소가 없을 수 있는 배열에 있습니다.
1. 변경 근거 — 없는 원소와 빈 값의 구별
v103이 로우에 대해 답한 질문이 원소에 대해서는 답되지 않고 있었습니다. 배열의 원소는 전부 있거나 전부 없거나라고 가정되어 있었고, 실제 시트는 그렇게 쓰이지 않습니다 — 가운데가 빈 배열이 있고, 그것을 표현할 수 없어서 생긴 우회가 셋이었습니다 (그 셋).
string[]이 이 형식이 무엇을 사는지 가장 분명하게 보여줍니다. 값이 없는 원소는 빈 문자열을
차지하고, 그것은 작성자가 빈 문자열을 적었을 때와 같은 바이트입니다. 4절의 파일에서 그 두
경우가 나란히 나옵니다.
2. 와이어 바이트 — 비트 7
7 6 5 4 3 2 1 0
[ ?[] ] [?] [kind] [ element ]
↑
v106이 쓰기 시작한 비트
| 마스크 | 의미 |
|---|---|
0x0F | 엘리먼트 타입 |
0x30 | kind (스칼라 · 고정 배열 · 가변 배열) |
0x40 | 로우 presence — 블록 앞에 로우당 1비트 (TcbFormat.WireNullable, v103) |
0x80 | 원소 presence — 그 뒤에 원소당 1비트 (TcbFormat.WireElementNullable) |
두 비트는 직교합니다. int?[]?는 둘을 함께 켜고 비트맵을 둘 갖습니다. kind 값을 쓰지 않은
이유는 v103과 같습니다 — 원소 nullability는 kind와 직교하고, 남은 kind 값 하나는 진짜 새로운
종류를 위한 것입니다. 이제 이 바이트에 예약 비트가 없습니다.
연번 컬럼이 접힌 배열은 비트 7만 켭니다. 그 그룹에는 배열을 가리키는 셀이 없어서 배열 자체의 없음이라는 것이 없기 때문입니다 — 그 이유. 4절의 컬럼이 그 경우입니다.
3. 블록 배치 — 비트맵 둘, 값은 그대로
블록: [비트 6이면 로우 presence: 인코딩 1바이트 + 비트맵]
[비트 7이면 원소 presence: counter32 원소 총수 + 인코딩 1바이트 + 비트맵]
encoding이 정한 배치로 담긴 값들
| 규칙 | 근거 |
|---|---|
| 원소 총수를 비트맵 앞에 counter32로 적습니다 | 가변 배열의 총수는 로우 길이의 합이고, 그 길이들은 값 블록 안에 있습니다. 비트맵을 먼저 만난 리더에게는 크기를 잴 것이 없습니다. 컬럼당 최대 5바이트 |
| 원소 비트맵은 로우 비트맵 뒤, 값 앞 | 리더가 만나는 순서가 그 순서입니다 — 로우가 배열을 갖는지, 그 배열의 어느 자리가 값을 갖는지, 그리고 값 |
| 비트맵은 자기 인코딩 바이트를 답니다 | 로우 비트맵이 v105에서 그렇게 되었고, 같은 선택을 받습니다 |
| 값은 모든 원소에 대해 기록합니다. 없는 원소는 타입의 빈 값을 차지합니다 | v103이 로우에 대해 내린 것과 같은 결정입니다. 그래서 인코딩 13종의 디코드 경로가 하나도 바뀌지 않았습니다 — 배열은 길이 스트림과 원소 스트림을 따로 인코딩하는데, 그 둘 중 어느 것도 비트맵을 보지 않습니다 |
byteLength는 비트맵 둘을 포함한 블록의 총 바이트입니다 | 모르는 컬럼 건너뛰기가 advance(byteLength) 한 번이라는 불변식이 유지됩니다 |
| 원소가 필수인 컬럼에는 비트맵이 없습니다 | 전부 1인 비트맵은 원소당 1비트를 내고 아무것도 나타내지 않습니다 |
비트맵의 길이는 실제로 기록된 원소의 총수입니다 — 고정 배열이면 rowCount × count, 가변
배열이면 로우 길이의 합입니다. 배열이 없는 로우는 원소를 하나도 쓰지 않으므로 비트도 내지
않습니다. 리더는 이미 길이를 순서대로 읽으므로, 늘어나는 것은 모든 로우의 모든 원소마다 한 번
증가하는 카운터 하나입니다.
4. 실제 파일 한 개 — nullable-elements 골든의 Folded.tcb
4행 2컬럼, 88바이트. Tag1·Tag2·Tag3가 접힌 string?[] 하나이고, 로우마다 다른 자리가
비어 있습니다.
| 로우 | 시트 | 읽히는 것 |
|---|---|---|
| 1 | a b c | ["a","b","c"] |
| 2 | a - c | ["a", null, "c"] |
| 3 | - b c | [null, "b", "c"] |
| 4 | a (빈 칸) c | ["a", "", "c"] |
54 43 42 00 magic "TCB\0" ── 헤더 42바이트 ──
6a 00 00 00 version 106
00 flags
00 cipher
00 × 12 nonce
00 × 16 mac
54 43 42 00 keyCheck
08 rowCount counter32, 지그재그 8 → 4
04 columnCount counter32, 지그재그 4 → 2
디스크립터 둘입니다.
| tag | wire | 해석 | encoding | count | byteLength |
|---|---|---|---|---|---|
| 1 | 02 | i32 · 스칼라 · 필수 | DELTA_RLE | 1 | 3 |
| 2 | 96 | string · 고정 배열 · 로우 필수 · 원소 옵셔널 | RAW | 3 | 25 |
96이 이 문서의 바이트입니다 — 1001_0110이므로 엘리먼트 6(string), kind 1(고정 배열),
비트 6은 0, 비트 7은 1입니다. 접힌 배열이므로 로우 비트맵은 없고 원소 비트맵만
있습니다.
tag 2 (string[3], 25B)
18 원소 총수 counter32, 지그재그 24 → 12 (4행 × 3원소)
00 비트맵 인코딩 RAW
af 0f 비트맵 12비트, 하위 비트부터
af = 1010_1111 0f = 0000_1111
│││││││└ 원소 0 로우1 "a" 1
││││││└─ 원소 1 로우1 "b" 1
│││││└── 원소 2 로우1 "c" 1
││││└─── 원소 3 로우2 "a" 1
│││└──── 원소 4 로우2 - 0 ← 없음
││└───── 원소 5 로우2 "c" 1
│└────── 원소 6 로우3 - 0 ← 없음
└─────── 원소 7 로우3 "b" 1
원 소 8 로우3 "c" 1
원소 9 로우4 "a" 1
원소 10 로우4 "" 1 ← 빈 칸은 값
원소 11 로우4 "c" 1
02 61 02 62 02 63 로우 1: "a" "b" "c"
02 61 00 02 63 로우 2: "a" "" "c"
00 02 62 02 63 로우 3: "" "b" "c"
02 61 00 02 63 로우 4: "a" "" "c"
로우 2·3·4의 00이 이 형식이 산 것입니다. 없는 원소(로우 2의 원소 4, 로우 3의 원소 6)와
작성자가 빈 문자열을 적은 원소(로우 4의 원소 10)가 값 블록에서 같은 한 바이트입니다. 그 셋을
가르는 것은 비트맵뿐이고, 비트맵이 없으면 파일에 그 구별이 없습니다.
로우 2와 로우 3도 값만으로는 같습니다 — 둘 다 "a"·""·"c"가 아니라, 한쪽은
"a" "" "c"이고 다른 쪽은 "" "b" "c"이므로 여기서는 값이 다릅니다. 그러나 비트맵이 하는
일은 그 차이가 어느 자리의 없음인지를 나타내는 것입니다: 로우 3은 원소 0이 없고, 그것이
"b"·"c"에 대해 아무 말도 하지 않는다는 사실이 이 비트맵으로 처음 표현됩니다.
5. 버전 — 105 파일의 거부
| 버전 | 배포 | 대체 사유 |
|---|---|---|
| 103 | 미배포 | 인코딩 4종 추가와 flags → 104 |
| 104 | 미배포 | 인코딩 1종 추가와 로우 비트맵의 인코딩 바이트 → 105 |
| 105 | 미배포 | 컬럼이 원소별 값 유무를 담게 됨 → 106 |
비트 7을 검사하지 않는 105 리더는 원소 비트맵을 값 블록의 앞부분으로 읽습니다. 감지되지 않는 읽기 오류이므로, v103이 그랬던 것처럼 버전으로 거부합니다.
table format version 105 is not supported (expected 106)
원소 옵셔널 컬럼이 없는 파일은 버전 4바이트만 달라집니다 — 비트 7이 꺼진 컬럼의 블록은 v105와 한 바이트도 다르지 않습니다.
6. 담지 않는 것
| 무엇 | 왜 |
|---|---|
| 레코드 멤버의 원소별 presence | 레코드 배열이 표현하는 없음은 원소 개수이고, 그것은 비트맵이 아니라 길이입니다 |
| 값의 압축 | 없는 원소도 빈 값을 차지합니다. 압축하면 인코딩 13종 × kind 3종의 디코드를 「present인 원소만 세면서」 다시 쓰게 되고, 그것을 모든 언어에서 반복합니다. 원소당 1비트를 내고 그 작업을 사지 않았습니다 |
| 바깥 레벨의 길이 | 배열의 배열에서 바깥 길이는 「컬럼이 몇 개인가」이고, 파일은 그것을 숫자가 아니라 별개의 태그들로 표현합니다 |
7. 검증 게이트
| 무엇 | 어떻게 |
|---|---|
| 비트 6·7이 따로 켜지는 것 | NullableArrayElementTests.The_file_declares_the_two_bitmaps_separately — 파일에서 디스크립터를 직접 읽습니다 |
| C#·TypeScript의 왕복 | 파일을 읽어 익스포터가 같은 시트에서 쓴 JSON과 대조합니다 (cs-check-nullable-elements · ts-check-nullable-elements) |
| Python·Ruby·PHP | 읽어서 값과 존재 여부를 직접 확인합니다 |
| 나머지 언어 | 생성 코드의 컴파일. 언리얼은 UHT가 헤더를 받는지까지 |
nullable-elements 골든 | 모든 언어와 json·binary. 표기 4종과 string?[]이 한 테이블에 있습니다 |
8. 비용
| 무엇 | 얼마 |
|---|---|
| 원소 옵셔널 컬럼 | 원소당 1비트 + 총수 counter32(최대 5바이트) + 인코딩 1바이트 |
| 그 밖의 컬럼 | 0 — 비트 7이 꺼져 있으면 블록은 v105와 같습니다 |
| 파일 전체 | 버전 4바이트 |
| 모든 런타임 | 컬럼 구조체에 bool 한 칸, ReadElementPresence 하나, CheckColumn의 비교 한 줄 |