다중 중첩 — 두 형태와 그 값의 차이
중첩 필드가 깊이 1로 그은 선 밖에 있던 형태들의 설계입니다.
문서 이름이 한 번 바뀌었습니다. 원래 제목은 「깊이 2 중첩」이었는데, 그 문구가 틀렸습니다. 아래 두 형태는 깊이가 다른 것이 아니라 순서와 이름 유무가 달랐고, 이 문서의 결론이 그것입니다. 그리고 그 이름이 구현의 상한을 그대로 옮겨 적고 있었으므로, 이름을 고치면서 상한도 없앴습니다 — 아래.
잔여 항목
실제 워크북 105개 테이블 중 둘이 이 선 밖에 있습니다.
| 테이블 | 컬럼 | JSON에서의 형태 |
|---|---|---|
<워크북> : BossRaidGuideInfo | guideBattleSkill["CombatAbility"][0] | { "CombatAbility": [a,b], "Order": [c,d] } |
<워크북> : TownText | voiceTagForAdd[0][0] | [ [a,b,c,d], [e,f,g,h] ] |
컬럼 이름은 로우 안으로 들어가는 경로입니다 — 원본 익스포터가 그 경로대로 JSON을 만듭니다. 그래서 대괄호 안이 숫자냐 이름이냐, 그리고 어느 쪽이 먼저냐가 산출물의 형태를 정합니다.
name["Member"][0]을name[0]["Member"]로 읽으면 안 됩니다. 컬럼 집합은 똑같지만 나오는 JSON이 다릅니다 — 앞은 멤버마다 배열 하나(SoA)이고, 뒤는 레코드의 배열(AoS)입니다. 우리 모델이 둘을 같은 컬럼으로 담을 수 있다는 것이 둘이 같은 것이라는 뜻은 아닙니다.
두 형태 모두 지원 — 값도 동일
| 형태 | 와이어 | 모델 | 생성기 13개 |
|---|---|---|---|
| 멤버가 배열인 레코드 | 그대로 | 플래그 하나 | 선언만 |
| 배열의 배열 | 그대로 | 플래그 하나 | 선언 + 대입 대상 |
처음에 적은 표는 달랐습니다. 배열의 배열은 「안쪽 폭이 필요」하고 「
Members가 그룹을 담게」 해야 한다고 봤는데, 첫째를 만들고 나니 둘이 같은 것이었습니다. 아래 2절이 그 틀린 예측을 그대로 두고, 끝에서 왜 틀렸는지 적습니다 — 예측이 어디서 어긋나는지는 지워버리면 알 수 없습니다.
1. 멤버가 배열인 레코드 — 컬럼이 이미 같은 형태
guideBattleSkill["CombatAbility"][0] guideBattleSkill["Order"][0]
guideBattleSkill["CombatAbility"][1] guideBattleSkill["Order"][1]
이 컬럼 넷은 레코드 배열의 컬럼 넷과 완전히 같습니다. Slot[0]["Id"]·Slot[0]["Label"]·
Slot[1]["Id"]·Slot[1]["Label"]과 개수도, 타입도, 순서도 같습니다.
와이어는 레코드를 멤버마다 한 컬럼으로 저장합니다 — AoS를 SoA로. 그런데 이 형태는 원래부터 SoA입니다. 그러니 와이어에 새로 필요한 것이 없습니다. 없는 정도가 아니라, 레코드 배열보다 더 곧이곧대로 실립니다.
모델에도 새 구조가 필요 없습니다. SerialField{Kind=Record, Members=[CombatAbility, Order]}
이고 각 RecordMember.Fields가 컬럼 둘을 갖습니다 — 지금 그대로입니다. 달라지는 것은
그 컬럼 둘을 무엇으로 읽느냐 하나뿐입니다.
| 같은 컬럼, 다른 답 | 배열 길이 | 레코드 개수 |
|---|---|---|
Slot[0]["Id"] — 레코드의 배열 | 멤버는 스칼라 | 2 |
guideBattleSkill["CombatAbility"][0] — 멤버가 배열인 레코드 | 2 | 1 |
그래서 필요한 것은 그룹에 붙는 비트 하나입니다 — 「이 그룹의 배열성은 그룹이 아니라
멤버에 있다」. 이름은 MembersAreArrays.
변경 사항
| 어디 | 무엇 |
|---|---|
NamedRangeColumnPath | ["Member"][0] 순서를 읽고, 경로에 그 사실을 실어 보냅니다. 지금은 「깊이 2」라며 거부합니다 — 거부 문구부터 틀렸습니다 |
SerialField | MembersAreArrays 하나. IsArray는 이 그룹에 대해 거짓입니다 — 배열인 것은 멤버입니다 |
| JSON 익스포터 | 레코드 배열을 내는 자리에서 오브젝트 하나를 내고, 멤버마다 배열 |
| 생성기 13개 | 선언만. 멤버 타입이 T에서 T[]가 되고, 그룹 자체는 배열이 아닌 레코드 하나 |
| 읽기 | 손대지 않습니다. 같은 와이어 컬럼을 같은 순서로 읽고, 대입 대상만 r.G[j].M에서 r.G.M[j]로 |
읽는 루프가 그대로인 것이 이 형태의 값입니다. 중첩 필드가 「선언은 그룹 단위, 읽기는 와이어 컬럼 단위」로 갈라둔 덕이고, 그 분리가 여기서 회수됩니다.
2. 배열의 배열 — 새 형태라고 봤던 것
이 절의 예측은 틀렸습니다. 아래 「2도 됐습니다」가 실제로 무엇이었는지 적습니다. 지우지 않고 두는 것은, 「이름이 없으니 다른 형태이겠다」가 얼마나 그럴듯하게 틀렸는지가 기록으로 남을 만하기 때문입니다.
voiceTagForAdd[0][0] voiceTagForAdd[0][1] voiceTagForAdd[0][2] voiceTagForAdd[0][3]
voiceTagForAdd[1][0] voiceTagForAdd[1][1] voiceTagForAdd[1][2] voiceTagForAdd[1][3]
바깥 2 × 안쪽 4. 안쪽에는 이름이 없으므로 레코드로 접을 수 없습니다 — 멤버가 없는 것이 아니라, 멤버라는 개념이 없습니다.
곁에 있는 컬럼들을 보면 기획 의도가 보입니다.
order[0]·contentsTermsId[0]·contentsTermsTarget[0]·contentsTermsCount[0]다음에voiceTagForAdd[0][0..3]이 오고, 그 다음이order[1]입니다. 바깥 인덱스가 하나의 레코드 배열을 이루고 있는데 레코드로 적히지 않은 것입니다. 레코드로 적었다면 이것은 「멤버가 배열인 레코드의 배열」이고, 그건 이 문서의 두 형태를 합친 것입니다.시트를 고치는 것이 답은 아닙니다 — 배포 중인 데이터이고, 그쪽 익스포터가 이 표기로 이미 JSON을 내고 있습니다. 다만 왜 이 형태가 하나뿐인지의 설명은 됩니다.
와이어
컬럼 8개를 어떻게 담을지가 결정 사항입니다.
| 안 | 담는 법 | 값 |
|---|---|---|
| 바깥마다 한 컬럼 | 길이 4짜리 고정 배열 컬럼 2개 | 기존 KindFixedArray 그대로. 대신 바깥 길이가 스키마에 박힙니다 — 바깥이 늘면 컬럼이 늘고, 그건 가산적 변경이라 괜찮습니다 |
| 하나의 컬럼 | 길이 8 + 안쪽 폭 | 형식에 폭을 담을 자리가 필요합니다. 새 kind 또는 와이어 바이트의 남은 비트 |
앞이 낫습니다. 형식을 건드리지 않고, 런렝스·딕셔너리 인코딩이 그대로 걸리며,
Slot1.*/Slot2.*가 이미 그렇게 실리는 것과 같은 방식입니다 — 바깥 인덱스는 컬럼을
가르고, 안쪽 인덱스는 컬럼 안에서 셉니다.
모델
RecordMember.Fields가 컬럼을 담듯, 바깥 원소마다 컬럼 묶음이 하나입니다. Members가
SerialField를 담게 넓히는 방향(중첩 필드가
적어둔 그것)이 정공법이지만, 안쪽에 이름이 없으므로 RecordMember를 쓸 수 없습니다 —
이름 없는 멤버를 만들면 생성기가 그것으로 프로퍼티 이름을 짓습니다.
그래서 SerialFieldKind에 셋째를 두는 쪽입니다: ScalarMatrix — 바깥 개수와 안쪽 개수를
갖고, Fields가 행 우선으로 전부를 담습니다.
생성기 13개
T[][]를 선언할 수 있는 언어인지가 언어마다 다릅니다. C는 T**와 개수 둘, Go는
[][]T, Rust는 Vec<Vec<T>> 또는 [[T; 4]; 2]. 13개를 하나씩 하는 것은
중첩 필드와 옵셔널 필드가
간 길과 같고, 걸리는 시간도 그만큼입니다.
순서 — 결과를 바꾼 요인
1을 먼저 합니다. 와이어도 형식도 건드리지 않고 테이블 하나가 열리며, 2를 하다가 판단이 바뀌어도 되돌릴 것이 없습니다. 2는 형식 결정이 하나 들어 있고 생성기 13개를 도는 일이라, 1이 끝난 자리에서 다시 값을 측정해 보는 편이 낫습니다 — 테이블 하나 때문에 모든 언어를 도는 일이기 때문입니다.
순서를 뒤집을 이유가 하나 있다면, 2가 형식에 손을 대는 마지막 기회일 때입니다. 지금은 아닙니다 — 위의 「바깥마다 한 컬럼」이 형식을 건드리지 않습니다.
그리고 이 순서가 2의 결론을 바꿨습니다. 1을 만들고 나서 보니 2가 같은 형태였고, 반대 순서였다면 2를 새 kind로 만들어 놓고 1이 그것의 특수한 경우임을 나중에 알았을 것입니다. 「싼 것부터」가 여기서는 「덜 잘못 만들기」이기도 했습니다.
1의 지원 완료
모든 언어와 json·binary. 예상대로 형식은 한 비트도 바뀌지 않았고, 기존 골든
12개도 한 바이 트도 움직이지 않았습니다.
들어간 것은 이만큼입니다.
| 어디 | 무엇 |
|---|---|
NamedRangeColumnPath | ["Member"][0] 순서를 읽습니다. 「깊이 2」라며 거부하던 자리인데, 거부 문구부터 틀렸었습니다 — 깊이는 같고 순서만 반대였습니다 |
TabbitLayoutParser | 네이티브 표기에도 자리를 냈습니다 — 숫자가 그룹에 붙으면 Slot1.Id(레코드의 배열), 멤버에 붙으면 Pos.X1(멤버가 배열인 레코드). 양쪽에 붙으면 거부합니다 |
Field.MemberIsArray · SerialField.MembersAreArrays · RecordMember.IsArray | 컬럼이 나타내고, 폴딩이 그룹으로 올리고, 그룹 안에서 멤버가 나타냅니다 |
WireColumn.IsFixedArray | 그룹에서만 보던 것을 멤버에서도 봅니다. 이걸 놓쳤을 때 멤버 컬럼이 스칼라로 읽혔습니다 |
JsonExporter | RecordOfArrays — RecordElement의 거울 |
| 생성기 13개 | 선언에 배열, 읽기에서 인덱스가 멤버 뒤로 |
네이티브 표기를 넓힌 것이 필요했던 이유는 픽스처입니다. 그 표기로 적을 수 없으면 생성기 13개를 시험할 시트를 만들 수 없고, 그러면 이 형태는 레이아웃 하나에서만 닿는 기능이 됩니다.
게이트는 member-array 픽스처 하나에 걸려 있습니다 — 골든 트리, 컴파일 7개, 읽기 3개,
그리고 TypeScript 왕복. 왕복이 이 형태에서 특히 값이 있습니다: JSON은 오브젝트를 통째로
싣고 바이너리는 멤버마다 고정 배열 컬럼을 실으므로, 둘이 같은 것을 조립하는지가 표기와 JSON
형태와 와이어 레이아웃이 서로 맞는다는 뜻입니다.
2의 지원 완료 — 비용은 예상보다 낮음
모든 언어와 json·binary. 형식은 여기서도 한 비트도 바뀌지 않았습니다.
이 문서가 처음에 「진짜 새 형태」이라고 적은 것은 틀렸습니다. 1을 만들고 나서 보니 배열의 배열은 1과 컬럼도 와이어도 완전히 같았습니다 — 바깥 레벨에 이름이 있느냐 없느냐만 다릅니다.
guideBattleSkill["CombatAbility"][0] → g.CombatAbility[j] 바깥에 이름이 있음
voiceTagForAdd[0][0] → g[0][j] 이름 대신 번호
그래서 위의 「와이어」 절에서 고민한 것 — 컬럼 8개를 어떻게 담을지 — 은 이미 답이 나와
있었습니다. 「바깥마다 한 컬럼」이 레코드를 담는 방식 그대로이고, 1이 그것을 쓰고 있었습니다.
SerialFieldKind에 셋째를 두자던 것도 필요 없었습니다: 그룹에 비트 하나(MembersAreAnonymous)와
RecordMember에 이름 대신 번호면 됩니다.
| 어디 | 무엇 |
|---|---|
NamedRangeColumnPath | [0][1]을 읽습니다. 바깥 인덱스가 멤버 이름 자리에 들어갑니다 |
TabbitLayoutParser | 네이티브 표기도 — Grid1.2. 멤버 쪽이 숫자만이면 이름이 없는 것입니다 |
SerialField.MembersAreAnonymous · RecordMember.IsAnonymous | MembersAreArrays와 같은 자리 |
WireColumn.MemberAt | 이름으로 못 닿는 바깥 레벨을 위치로 가리킵니다 |
JsonExporter | ArrayOfArrays — RecordOfArrays에서 키를 뗀 것 |
| 생성기 13개 | 선언에 중첩 배열, 읽기에서 바깥 인덱스가 고정·안쪽이 루프 |
언리얼만 다릅니다. UHT에 중첩 컨테이너 프로퍼티가 없어서
TArray<TArray<T>>는UPROPERTY가 될 수 없습니다.double멤버와 같은 취급 — 선언하고 읽되 리플렉션에서 빠지고, 왜 빠졌는지 생성된 파일에 한 줄로 적힙니다.
게이트는 member-array 픽스처 하나가 두 형태를 같이 들고 있습니다 — 골든 트리, 컴파일 8개,
읽기 3개, UHT, 그리고 TypeScript 왕복. 실제 워크북에서도 확인했습니다:
<워크북>.xlsx : TownText 2,344행이 [[대사 4개], [대사 4개]]로 나옵니다.
남은 교훈
두 번 다 「새 형태」이 아니었습니다. name["M"][0]은 깊이 2가 아니라 순서가 반대였고,
name[0][0]은 그 옆 형태에서 이름을 뗀 것이었습니다. 와이어가 레코드를 멤버마다 한 컬럼으로
싣기로 한 그 결정 하나가 둘 다를 미리 담고 있었습니다.
임의 깊이 — 상한을 없앤 근거
위의 두 형태를 만들고 나서 남은 것은 진짜로 더 깊은 형태였습니다.
실측에서 최대 깊이는
3이고 배열 > 레코드 > 레코드가 리프 4개입니다 —
ConstellationMiniGame.star[].position.x.
그것을 깊이 3으로 지원한 것이 아니라, 깊이를 세지 않게 했습니다. 형태를 하나씩 더하는 방식으로는 이 문서가 두 번 틀린 그 자리를 세 번째로 다시 밟게 됩니다.
레벨마다 두 질문
컬럼 이름은 경로이고, 경로의 각 레벨에 확인할 것이 둘뿐입니다.
| 질문 | 예 |
|---|---|
| 이 레벨에 이름이 있는가 | Position은 있고, Grid1.2의 안쪽은 없습니다 |
| 이 레벨이 반복하는가 | Slot1은 하고, Pos는 하지 않습니다 |
이 두 비트의 조합이 이 도구가 지원하는 모든 형태입니다. 배열의 레코드·레코드의 배열·배열의
배열은 같은 두 질문을 다른 레벨에서 답한 것이고, 그래서 세 케이스가 아니라 규칙 하나로
줄어듭니다. FieldPathStep이 그 두 비트이고, 두 표기가 만나는 지점입니다.
그 표기의 문법도 규칙 둘로 줄었습니다 — 이름이 든 대괄호는 새 레벨을 엽니다, 숫자가 든
대괄호는 왼쪽 레벨에 번호를 붙입니다(이미 붙어 있으면 이름 없는 새 레벨을 엽니다).
character[0]["Id"]와 guideBattleSkill["CombatAbility"][0]이 이 규칙에서 저절로 갈리고,
a[0]["p"]["x"]는 케이스를 따로 두지 않아도 읽힙니다.
형식 무변경 — 리프 하나가 와이어 컬럼 하나
리프 하나가 와이어 컬럼 하나이고, 이것이 깊이와 무관합니다. 와이어는 레코드를 원래 멤버마다 한 컬럼으로 싣기 때문에, 멤버가 다시 레코드인 것은 컬럼에 닿는 경로가 길어지는 것일 뿐입니다.
Star1.Position.X Star1.Position.Y 와이어 컬럼 2개
Star2.Position.X Star2.Position.Y 각각 셀 2개 (바깥 배열 길이)
깊이 1을 이 규칙으로 걸으면 오늘과 똑같은 목록이 나옵니다. 그것이 판정 기준이었고,
기존 골든 시나리오 11개가 한 바이트도 움직이지 않았습니다 — member-array와 nested를
포함해서입니다.
들어간 것
| 어디 | 무엇 |
|---|---|
FieldPathStep · Field.NamePath | 컬럼 이름이 경로가 됐습니다. MemberName·GroupOrdinal·MemberIsArray·MemberIsAnonymous 네 개가 이것 하나로 대체됐습니다 |
RecordMember.Members | 멤버가 다시 멤버를 갖습니다. IsLeaf가 「여기가 컬럼인가」를 나타내고, 아무 곳도 레벨을 세지 않습니다 |
Table.BuildMembers | 경로를 따라 내려가며 노드를 만듭니다. 한 레벨이 값과 레코드를 동시에 갖는 것은 거부합니다 |
NestedName · NamedRangeColumnPath | 세그먼트 N개, 대괄호 N개. 「깊이 2」·「한 레벨 더 」 거부 문구가 사라졌습니다 |
WireColumn.Of | 리프 순회. MemberPath가 경로를, AnyLevelIsArray가 「경로상 어느 레벨이든 배열인가」를 나타냅니다 |
JsonExporter | RecordElement·RecordOfArrays·ArrayOfArrays 세 개가 Compose·MemberValue 재귀 둘로 합쳐졌습니다 |
생성기 13개 — 뷰에서 끝나는 재귀
깊이는 템플릿에 닿지 않습니다. 뷰가 레벨마다 타입을 만들어 평평한 목록으로
내놓고(<Lang>FieldView.RecordTypes, 안쪽부터), 템플릿은 그 목록을 돕니다. 13개 템플릿 중
어느 것도 재귀 함수를 쓰지 않으므로, 깊이를 템플릿 문법으로 다루기 시작하면 그 자리만 다른
열두 개와 달라집니다. 그래서 언어당 변경이 뷰 하나·빌더 하나·템플릿 루프 하나입니다.
public struct StarPositionEntry { public int X; public int Y; }
public struct StarEntry { public int Id; public StarPositionEntry Position; }
읽기는 언어당 한 줄만 달라졌습니다. 대입 대상이 wire.MemberPath 전체가 되어
record._star[j].Position.X가 나오고, 읽기 switch는 깊이를 모릅니다 — 배열 레벨이 여전히
하나뿐이라 루프도 [j] 하나입니다. 타입 이름은 경로를 실어 StarPositionEntry가 되므로 두
레코드가 각자 Position을 가져도 부딪히지 않습니다.
안쪽부터 선언하는 것이 다섯 언어에서는 취향이 아니라 요구입니다. C·C++는 멤버를 선언하려면 완전한 타입이어야 하고, Python·Ruby는 생성자가 아래 레벨의 이름을 실행 시점에 해석하며, 언리얼은 UHT가 헤더를 순서대로 읽습니다.
언어마다 다른 것은 아래 레벨을 무엇으로 채우느냐 하나였습니다.
| 언어 | 아래 레벨을 채우는 곳 |
|---|---|
| Go·Rust·C·C++ | 아무것도 안 합니다 — 값 타입이 스스로 0/Default로 시작합니다 |
| C# | New<타입>() 팩토리. 구조체는 C# 8에서 자기 필드를 초기화할 수 없습니다 |
| Java·Kotlin·Dart·Python·Ruby | 선언 또는 생성자에서 그 클래스를 만듭니다 |
| PHP | 생성자에서만 — 프로퍼티 초기값은 상수식이어야 하고, 타입 프로퍼티를 비워두면 읽을 때 오류입니다 |
| TypeScript | 리터럴. JSON 경로가 하나 더 있어 읽기 경로가 셋입니다 |
언리얼은 이번에 잃은 것이 없습니다. 배열의 배열은
TArray<TArray<T>>가UPROPERTY가 될 수 없어 리플렉션에서 빠졌는데, USTRUCT 타입의 멤버는 UHT가 받는 프로퍼티입니다. 그래서 이 형태는 중첩 레벨까지UPROPERTY를 유지하고, 그 사실은 UHT만 판정할 수 있습니다.
남은 것
모든 언어와 json·binary가 깊이 제한 없이 지원합니다.
SupportsDeepNestedFields로 타깃마다 나타내고, 14번째 타깃은 지원하기 전에 자기 이름과 함께
거부합니다 — 그 플래그가 없으면 생성기가 Star1.Position.X를 리프 이름만으로 선언해서
record.Star[j].X를 내놓습니다. 컴파일되지 않거나, 더 나쁘게는 컴파일되고 다른 것을
뜻합니다.
번호가 붙는 레벨은 여전히 한 그룹에 하나이고(이름 없는 안쪽 레벨은 예외), 깊은 그룹에서는
최상위 레벨에만 붙습니다 — Pos.Sub.X1처럼 안쪽에 번호가 붙으면서 중첩하는 것은
거부합니다. 와이어는 담을 수 있지만 배열이 레코드 아래로 들어가 모든 언어의 경우가 곱해지고,
실측에 없는 형태입니다. 자르기와 옵셔널도 최상위 배열만입니다 — 근거는
중첩 필드 §5에 있습니다.
게이트
nested-deep 픽스처 하나가 전부를 들고 있습니다. 원래 깊이 3을 거부하는지 확인하 던
픽스처였고, 지금은 같은 자리에서 나오는 형태를 고정합니다.
| 게이트 | 무엇을 확인할 수 있는가 |
|---|---|
골든 트리 (모든 언어 + binary·json 둘) | 생성된 페이지와 파일 바이트 |
| JSON 산출물 | star[i].position.x가 배열 안 오브젝트 안 오브젝트인가 |
| 컴파일 (C#·C·C++·Go·Rust·Java·Kotlin·Dart) | 타입 셋과 그 대입이 그 언어에서 합법인가 |
| 되읽기 (C#·Python·Ruby·PHP) | 선언의 Star[j].Position.X와 파일의 Deep.Star.Position.X가 같은 컬럼인가 |
| TypeScript 왕복 | 명명 JSON·컴팩트 JSON·바이너리 세 경로가 같은 것을 조립하는가 |
| UHT | 중첩 USTRUCT가 UPROPERTY를 유지하는가 |
되읽기가 파싱만 되는 언어에서 특히 중요합니다. r.star[k].position.x는 position이
무엇이든 파싱되므로, Python·Ruby·PHP에서 컴파일 게이트는 거의 아무것도 증명하지 않습니다.
그 셋은 실제 값을 확인합니다.
컴팩트 JSON이 가장 틀리기 쉬운 경로입니다. 와이어 컬럼 순서의 위치 기반 형식이고, 컬럼은 멤버가 아니라 리프입니다 — 멤버당 하나씩 잘라내면 첫 리프의 구간을 레코드 전체로 읽습니다. TypeScript가 그 경로를 바이너리와 대조합니다.
Star1.Id가 Star1.Position.X 옆에 있는 것은 의도입니다 — 한 레벨이 값과 레코드를 함께
들고, 그것이 폴딩이 레벨을 세지 않는다는 증거입니다.