중첩 필드
컬럼 여러 개를 레코드 하나, 또는 레코드의 배열로 접는 기능의 설계입니다. 왜 필요한지는 그 레이아웃 분석에 있습니다.
1. 문제
지금 시트에서 배열을 만드는 방법은 둘이고, 둘 다 원소가 스칼라입니다.
| 방법 | 시트 | 나오는 것 |
|---|---|---|
| serial field | Text1 Text2 | string[], 길이 = 컬럼 수 |
| 배열 타입 | Costs : int[] | int[], 길이 = 로우마다 |
원소가 여러 값을 묶은 것일 때 적을 방법이 없습니다. 실제 데이터에서 흔한 형태인데도
Slot1Id Slot1Count Slot2Id Slot2Count처럼 평평하게 펼쳐 적고, 읽는 쪽에서 다시 묶는
수밖에 없습니다. 생성된 코드가 시트의 의도와 다른 형태가 됩니다.
2. 표기 — Group.Member
결정. 멤버 이름을 .으로 붙이고, 배열은 기존 연번 규칙을 그대로 씁니다.
| 시트의 컬럼 이름 | 나오는 것 |
|---|---|
Pos.X Pos.Y | Pos — 레코드 하나. record.Pos.X |
Slot1.Id Slot1.Count Slot2.Id Slot2.Count | Slot — 레코드의 배열, 길이 2. record.Slot[0].Id |
Slot1 Slot2 | 스칼라 배열 (지금 그대로) |
타입·주석·target-side는 멤버 컬럼마다 따로 적습니다. 멤버는 서로 다른 타입일 수 있고, 그것이 레코드를 쓰는 이유입니다.
왜 이 표기인가.
- 연번 규칙을 새로 만들지 않습니다.
Text1/Text2를 이미 쓰는 사람이Slot1.Id/Slot2.Id를 적게 됩니다. 배열 길이를 컬럼 수가 정하는 것도, 그룹의 컬럼이 시트에서 붙어 있지 않아도 되는 것도 기존 폴딩의 성질을 그대로 물려받습니다. .은 지금 비어 있는 문법입니다. 필드 이름은RequiresIdentifier를 지나므로.이 든 이름은 현재 오류입니다. 그래서 기존 시트와 모호해질 여지가 없습니다 — 새 뜻을 얻는 것이 아니라 오류였던 것이 뜻을 갖습니다.- 다른 레이아웃의 표기도 이 모델로 번역됩니다.
character[0]["Id"]는Character1.Id와 같은 것을 가리키므로, 그 레이아웃의 파서는 두 번째 모델이 아니라 번역입니다. 모델이 하나면 두 레이아웃이 「이 컬럼이 무엇인가」에 다른 결과를 낼 수 없습니다.
고르지 않은 것.
| 대안 | 왜 아닌가 |
|---|---|
character[0]["Id"] (다른 레이아웃의 표기) | 엑셀 헤더에 따옴표를 넣는 것이 편집에 불편하고, 인덱스 표기가 연번 규칙과 이중이 됩니다. 우리 레이아웃에는 이미 연번이 있습니다 |
Slot[0].Id | .만으로 충분한데 대괄호를 더합니다. 연번과 대괄호 둘 중 하나만 쓰는 게 낫고, 기존 규칙과 이어지는 쪽은 연번입니다 |
Slot1_Id (밑줄) | _는 이름 정규화에서 지워집니다 — Slot1_Id와 Slot1Id가 같은 이름이 되므로 구분이 성립하지 않습니다 |
3. 모델 — SerialField 원소 타입의 확장
익스포터와 모든 생성기가 보는 단위는 Field가 아니라
SerialField — 「컬럼들을 배열 하나로 접은 그룹」입니다.
중첩 필드는 새 개념이 아니라 그 그룹의 원소가 스칼라에서 레코드로 넓어진 것입니다.
SerialField
Name "Slot"
Kind Scalar | Record ← 신규
Members [ { Name="Id", Fields=[Slot1.Id, Slot2.Id] }, ← 신규. Kind=Record일 때만
{ Name="Count", Fields=[Slot1.Count, Slot2.Count] } ]
Fields (Kind=Scalar일 때 지금 그대로)
멤버 하나가 원소 인덱스 순서의 Field 목록을 들고, 그 목록의 길이가 배열 길이입니다.
Pos.X처럼 연번이 없으면 길이 1이고 배열이 아닌 레코드 하나로 냅니다.
4. 와이어 — 변경 없음
컬럼 지향 형식이므로 레코드의 배열은 「멤버마다 고정 배열 컬럼 하나」입니다.
Slot1.Id Slot2.Id → 컬럼 Slot.Id : KindFixedArray, 길이 2
Slot1.Cnt Slot2.Cnt → 컬럼 Slot.Count : KindFixedArray, 길이 2
즉 API는 AoS, 파일은 SoA입니다. 얻는 것이 셋입니다.
KindFixedArray가 이미 그것이라 형식에 새 kind가 없고, 버전을 올리지 않습니다. 스펙과 스큐 게이트가 그대로입니다.- 멤버마다 별도 컬럼이라 컬럼 인코딩이 멤버 단위로 걸립니다. 같은 값이 이어지는 멤버는 런렝스가, 값 종류가 적은 멤버는 딕셔너리가 그대로 듣습니다. 레코드를 한 덩어리로 저장하면 그 둘이 전부 무력해집니다.
- 멤버 추가가 컬럼 태그 하나 늘는 가산적 변경입니다. 기존 리더는 모르는 태그를 건너뜁니다.
lib/의 리더 13개도 그대로입니다. 생성되는 읽기 루프는 컬럼을 태그로 분기해
record.<필드>[j]에 대입하는 형태이고, 중첩은 대입 대상이 record.<그룹>[j].<멤버>가
되는 것이 전부입니다 — 디코드 원시연산은 건드리지 않습니다.
실제 파일에서 확인된 것
nested 픽스처의 Loadout 테이블(3행)이 만든 .tcb 183 바이트의 디스크립터입니다.
그룹 6개가 와이어 컬럼 8개로 나갔습니다.
| 태그 | 컬럼 | element | kind | count | 바이트 |
|---|---|---|---|---|---|
| 1 | index | i32 | scalar | 1 | 3 |
| 2 | Name | string | scalar | 1 | 19 |
| 3 | Pos.X | f32 | scalar | 1 | 12 |
| 4 | Pos.Y | f32 | scalar | 1 | 12 |
| 5 | Slot.Id | i32 | fixedArray | 2 | 24 |
| 6 | Slot.Label | string | fixedArray | 2 | 25 |
| 7 | Note | string | scalar | 1 | 7 |
| 8 | Tag | string | fixedArray | 2 | 10 |
읽을 것이 셋입니다.
Slot이 컬럼 둘입니다 —Slot.Id가i32고정 배열(길이 2),Slot.Label이string고정 배열(길이 2). 레코드 하나를 한 덩어리로 만든 것이 아니라 멤버별로 갈랐습니다.- 원소가 하나인 레코드는 스칼라입니다 —
Pos.X/Pos.Y가count 1의 scalar입니다.KindFixedArray로 만들지 않는 것이 맞습니다. 배열이 아닌 것을 배열로 적으면 리더가 배열을 만듭니다. version이 그대로 102이고 새 kind가 없습니다. 옆의Tag(기존 serial field)와Slot.Id가 같은fixedArray입니다 — 리더 입장에서 구분할 것이 없다는 뜻이고, 그래서lib/의 13개를 건드리지 않습니다.
이 바이트는 골든에 들어가 있습니다. 그리고 기존 .tcb 골든은 한 바이트도 바뀌지 않았습니다 —
와이어 컬럼 단위로 리팩터링한 것이 레코드 없는 테이블의 출력을 보존하였다는 확인입니다.
태그는 멤버마다 하나
「멤버가 컬럼」의 직접적인 결과입니다. serial field는 컬럼이 몇 개든 와이어 컬럼 하나이므로 태그가 첫 멤버에만 붙는데, 레코드 그룹은 와이어 컬럼이 멤버 수만큼이므로 멤버마다 하나입니다.
Text1 Text2 → 와이어 컬럼 1개, 태그 1개
Slot1.Id Slot1.Label
Slot2.Id Slot2.Label → 와이어 컬럼 2개(Slot.Id, Slot.Label), 태그 2개
@N을 직접 다는 경우 Slot1.Id@7 Slot1.Label@8처럼 각 멤버의 첫 원소에 답니다.
같은 멤버의 나머지 원소(Slot2.Id)에 달면 오류입니다 — serial field의 두 번째 컬럼에
다는 것과 같은 이유입니다.
그래서 태그를 배정하는 단위는 「그룹」이 아니라 「와이어 컬럼」입니다. 순서 모드에서도 위치가
와이어 컬럼 순서로 매겨지므로, 멤버를 하나 추가하면 그 뒤 태그가 밀립니다 —
@N을 달지 않은 테이블에 「끝에 추가하는 것만 안전하다」는 기존 규칙이 그대로 적용됩니다.
그리고 이 단위를 WireColumn 한 곳에만 두었습니다.
와이어 컬럼이 무엇인지 판단하는 곳이 셋(writer · 태그 배정 · 베이스라인 검사)이면 셋이 어긋납니다 —
실제로 어긋났습니다. 태그 배정이 「그룹당 태그 하나」를 가정하고 있었고, 그것은 레코드가
없던 모든 테이블에 대해 맞는 가정이라 드러날 이유가 없었습니다. 이제 3개 다 Table.WireColumns를 읽습니다.
베이스라인 검사가 특히 그렇습니다. 그쪽은 태그를 키로 컬럼을 기록하므로, 그룹 단위로 읽으면 레코드 그룹 전체가 첫 멤버의 태그 하나로 기록되고 나머지 멤버는 「사라진 컬럼」으로 보고됩니다.
5. 깊이의 제약과 남은 제약
깊이는 제한하지 않습니다. 멤버는 스칼라이거나 다시 그룹이고, 컬럼 이름이 적은 만큼
들어갑니다 — Star1.Position.X가 그것입니다. 폴딩이 경로를 따라 내려가므로 레벨 수를 세는
곳이 없고, RecordMember.IsLeaf가 「여기가 컬럼인가」를 나타냅니다.
이 선은 한 번 다른 자리에 있었습니다. 처음에는 깊이 1까지였고 근거는 실측이었습니다 — 615개 테이블에서 깊이 1의 세 형태가 리프의 99.5%였기 때문입니다. 그 판단이 뒤집힌 이유는 비율이 바뀐 것이 아니라, 깊이가 모델의 상한일 이유가 없다는 쪽이 맞았기 때문입니다. 다중 중첩에 그 경과가 있습니다.
남은 제약은 깊이가 아니라 아래 셋입니다.
| 무엇 | 제약 | 왜 |
|---|---|---|
| 번호가 붙는 레벨 | 한 그룹에 하나. 예외는 안쪽에 이름이 없는 경우(Grid1.2)뿐입니다 | 리프 하나가 와이어 컬럼 하나이고, 원소 수는 그 한 레벨이 정합니다. 두 레벨이 번호를 가지면 조합마다 컬럼이 생기는데, 형식은 표현할 수 있지만 아직 생성기가 없습니다 |
| 자르기·옵셔널 | 최상위 배열만 | presence와 길이는 그룹 하나에 대한 것입니다. 깊은 레벨마다 두려면 형식이 바뀝니다 (로드맵) |
| 깊은 그룹의 원소 번호 | 최상위 레벨에만. Pos.Sub.X1처럼 안쪽 레벨에 번호가 붙으면서 중첩하는 것은 거부합니다 | 와이어는 담을 수 있지만 배열이 레코드 아래로 들어가 생성기 13개의 경우가 곱해집니다. 실측에 없는 형태라 일부 언어에서만 되는 것보 다 그 이름과 함께 거부하는 편이 낫습니다 |
| 코드 생성 | 모든 언어 + json·binary. 깊이 제한이 없습니다 | 타깃마다 SupportsDeepNestedFields로 나타냅니다. 14번째 타깃은 이 플래그를 먼저 만나고, 지원하기 전에는 자기 이름과 함께 거부합니다 |
6. 생성기 — 선언은 그룹 단위, 읽기는 와이어 컬럼 단위
모든 생성기가 전부 이 형태입니다. C#의 뷰를 읽고 확정하였고, 나머지 12개가 그것을 따랐습니다.
지금 생성기의 필드 뷰는 와이어 컬럼 하나를 전제하고 있습니다 — Tag · ColumnCheck ·
CursorOpen · ElementRead · RunCall을 각각 하나씩 듭니다. 레코드 그룹은 와이어 컬럼이
멤버 수만큼이므로 그 전제가 깨집니다.
해법은 뷰를 둘로 가르는 것입니다.
| 무엇 | 단위 | 들고 있는 것 |
|---|---|---|
| 필드 뷰 | 그룹 | 프로퍼티 이름, 타입(또는 생성될 레코드 타입 이름), 선언 형태, 원소 개수, 참조 정보 |
| 컬럼 뷰 | 와이어 컬럼 | Tag · ColumnCheck · CursorOpen · ElementRead · RunCall · RunRead |
템플릿에서는 선언부가 필드 뷰를 돌고, 읽기 switch가 컬럼 뷰를 돕니다. 지금은 둘 다 같은 목록을 도는데, 그것이 「그룹 = 컬럼」이라는 가정이 템플릿에 고정되어 있는 자리입니다. 가르면 레코드 지원이 분기 추가가 아니라 목록 교체가 됩니다.
읽기 대상 표현식만 달라집니다. 지금은 record.<필드> 또는 record.<필드>[j]이고,
레코드 멤버는 record.<그룹>[j].<멤버>입니다 — ElementReadLines가 이미 target 문자열을
받아 쓰는 구조라 그 문자열을 만드는 곳만 바뀝니다.
단계를 둘로 나누는 것이 안전합니다.
- 읽기를 와이어 컬럼 단위로 재구성 — 레코드 없는 테이블에서는 동작이 같습니다(그룹 하나 = 컬럼 하나). 골든 무변경이 합격 기준입니다. 바이너리 writer를 이 순서로 작성하였고 실제로 그렇게 산출되었습니다.
- 그 위에 레코드 지원 — 원소 타입 선언 +
record.<그룹>[j].<멤버>대입.
TypeScript만 다른 점
다음 언어로 TypeScript를 고른 이유는 왕복 테스트입니다 — 같은 테이블을 JSON과 바이너리 양쪽에서 읽어 필드 단위로 비교하므로, 중첩의 JSON 형태와 바이너리 레이아웃이 서로 맞는지를 다른 언어로는 못 하는 방식으로 확인해 줍니다.
대신 JSON을 읽는 유일한 언어라서 경로가 둘 더 있습니다.
| 경로 | 레코드에서 필요한 것 |
|---|---|
| named 행 | this._slot = dataRow.slot.map(e => ({ id: e.id, label: e.label })). 레코드 하나면 map 없이 |
| compact 행 | 멤버마다 slice로 N개를 집고, 그것들을 zip해서 객체 배열을 만듭니다 |
compact 경로는 컬럼 순서 수정이 선행 조건이었습니다. 그전에는 멤버의 항목이 행에 흩어져
있어서 slice로 집을 수가 없었습니다. 지금은 와이어 컬럼 순서라 멤버마다 붙어 있습니다.
const _slot_id = dataRow.slice(offset, offset + 2); offset += 2
const _slot_label = dataRow.slice(offset, offset + 2); offset += 2
this._slot = _slot_id.map((v, k) => ({ id: v, label: _slot_label[k] }))
바이너리 switch는 C#과 같습니다 — fields 대신 columns를 돌고, 멤버 컬럼은 배열을
새로 만들지 않고 record._slot[j].id에 대입합니다. TS의 템플릿은 read_kind가 따로 없고
field.kind로 분기하므로, 컬럼 뷰에 kind를 두는 것이 C#과 유일하게 다른 점입니다.
7. 단계 — 언어를 하나씩
13개 템플릿을 한 번에 고치지 않았습니다. 중첩을 아직 모르는 타깃은 그 사실을 적는 오류를 내게 하고, 언어를 하나씩 늘렸습니다.
Target `html` does not support nested fields yet.
Table `Item` field `Slot` is a record group.
at sheets/data.xlsx : Items : F3
조용히 평평하게 펼치는 것보다 낫습니다 — 그러면 13개 산출물 중 하나만 다른 형태가 되고, 어느 것이 맞는지 아무도 모릅니다.
전부가 전부 지원하므로 이 오류를 내는 타깃은 이제 없습니다. 검사는 ITarget에 남아 있고,
14번째 타깃이 지원하기 전에 만나는 것이 그것입니다.
| 단계 | 내용 | 상태 |
|---|---|---|
| 1 | 이름 파싱 (Group.Member → 그룹·멤버·원소 서수). 순수 함수, 단위 테스트 | 됨 |
| 2 | 모델 (SerialField.Kind·Members) 과 폴딩, 와이어 컬럼 단위 태그 배정 | 됨 |
| 3 | nested 픽스처 + JSON 익스포터 + 골든. 모르는 타깃은 그 이름과 함께 거부 | 됨 |
| 4 | 바이너리 writer — WireColumn을 1급 개념으로. 위의 디스크립터가 결과 | 됨 |
| 5 | C# 생성기 + 템플릿(§6의 구조로). 시트 → .tcb → 생성 코드로 되읽기까지 확인 | 됨 |
| 6a | TypeScript. JSON·바이너리 두 경로 왕복 확인 | 됨 |
| 6b | 나머지 언어. 하나씩, 적합성 코퍼스에 형태 추가 | 됨 — 전부 |
| 7 | HTML·DB 익스포터, 쇼케이스 재생성 | — |
| 8 | 다른 레이아웃 파서에서 이 모델로 번역 | — |
5단계에서 생성되는 것과, 그것이 실제로 읽어낸 값입니다.
public struct SlotEntry { public int Id; public string Label; }
public SlotEntry[] Slot => _slot;
internal const int Slot_N = 2; // 공개하지 않습니다 — 아래 참조
internal SlotEntry[] _slot = NewSlotEntryArray(); // 원소별 할당 없음
case 5: // Loadout.Slot.Id — 멤버 하나가 컬럼 하나
for (int i = 0; i < count; i++) {
var record = records[i];
for (int j = 0; j < Record.Slot_N; ++j)
record._slot[j].Id = reader.ReadI32As(column.Element);
}
pos = {"X":1.5,"Y":-2.5}
slot = [{"Id":10,"Label":"sword"}, {"Id":11,"Label":"shield"}]
JSON 익스포트와 값이 같습니다. 그리고 이 코드는 C# 8 · netstandard2.1로 경고 없이 컴파일됩니다 — 유니티 2020.3이 받는 수준입니다.
길이 상수는 공개 API가 아닙니다. 여기 적히는 수는 이 코드를 생성한 시점의 시트가 가졌던 것이고, 소비하는 쪽이 그것을 붙들면 데이터가 더 이상 동의하지 않아도 되는 수를 붙드는 것이 됩니다 — 배열의
Length가 답이고, 그것은 낡지 않습니다. 레코드 그룹에서 상수가 남는 것은 컬럼 여럿이 배열 하나를 채우기 때문입니다. 어느 컬럼도 그 배열을 혼자 갖지 않으므로 길이는 컬럼들이 합의한 수이고, 그것은 생성된 형태의 일부입니다. 컬럼 하나가 배열 하나를 갖는 형태 —Tag1·Tag2를 접은 스칼라 배열 — 에서는 상수가 아예 없고, 길이를 그 컬럼의 디스크립터에서 읽습니다 (nullable 배열 원소).
원소 타입을
struct로 한 이유가 둘입니다. 배열이 원소별 할당을 하지 않고 (21,000행 × 17원소 테이블이면 357,000개 객체를 만들지 않는다는 뜻입니다),array[j].Member = x가 구조체 배열에서 합법이라 멤버 컬럼의 읽기가 그대로 대입이 됩니다.대가가 하나 있었습니다 — 구조체는 필드 초기화자를 쓸 수 없습니다(C# 10 기능이고 명시적 무인자 생성자가 필요합니다). 그런데 문자열 멤버는
null이 아니라""로 시작해야 합니다. 그 멤버가 생기기 전에 쓰인 파일에는 컬럼이 없어서 아무도 그 필드를 쓰지 않고,null은 한 필드 뒤에서 터지기 때문입니다. 그래서 정적 팩토리가 채웁니다.
3단계까지의 결과로 표기가 실제로 동작하는 것을 JSON에서 확인할 수 있습니다.
{ "index": 1, "name": "first",
"pos": { "x": 1.5, "y": -2.5 },
"slot": [ { "id": 10, "label": "sword" }, { "id": 11, "label": "shield" } ],
"note": "n1",
"tagArray": [ "a", "b" ] }
slot의 멤버가 int와 string으로 서로 다른 것이 요점입니다 — 배열로는 표현할 수 없고,
그래서 레코드가 필요합니다. 옆의 tagArray는 기존 serial field가 그대로 접힌 것입니다.
JSON의 compact 형식은 중첩 없이 남습니다. 컬럼 위치로 인덱싱하는 형식이고 레코드의 멤버는 보통 컬럼이므로, 그것이 compact의 정의에 맞습니다. 두 형식 다 골든이 확인하고 있습니다.