fix(attribute,slot): retract 전면 재설계, 소유권 레지스트리 통합, 코퍼스 감사
- Attribute: AttributeKeyHandler.retract를 완전 no-op으로 재정정 — 지우는 건 오직 명시적 None/nil(process)로만, 그룹이 사라진 이름을 자동으로 SetAttribute(nil) 안 해줌(Ref의 "Destroy 무관" 철학과 통일). 단 사라진 이름의 StoreBind 구독은 retractUnder로 끊어 리소스 누수 방지. - Slot: slotOwner를 elementOwner로 일반화해 top-level Dispatch/nested Add 경로가 같은 소유권 레지스트리를 쓰도록 통합(이중 마운트 gap 폐쇄). bindLifetime을 top-level 전용으로 축소(nested는 _elements 강참조로 transitively 생존). - 일반 규칙 신설(bind-system-plan.md): retract의 v는 타입 미보장이라 내용을 보려면 isX(v) 가드 필수, retract 안에서 process 호출은 retractUnder 체인 추적을 꼬는 UB. - and/or 삼항 관용구 전면 금지(기존 "항상-truthy면 예외" 조항 폐기), 코퍼스 전체 실제 코드 6곳을 if-then-else로 교체. - 7-에이전트 코퍼스 감사로 stale 서술 다수 정정: retract-always-fires 정정 전파 누락(bind-system-plan.md Tag 예시), Tween research→base 승격 반영 누락(architecture.md/ui-shorthand-plan.md), Relate API 인자 개수 버그(slot-plan.md), pre-implementation-audit.md 열린 항목 개수 오류(question.md/CLAUDE.md). - 백로그: Attribute 자동 unset용 :Apply 유틸 아이디어를 research/operator-sugar-plan.md에 추가(착수 안 함). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVG74qV2nQVykhvMRQW2UC
This commit is contained in:
parent
b91c662f50
commit
2df591851f
12 changed files with 510 additions and 178 deletions
|
|
@ -178,8 +178,8 @@ quad/
|
|||
|
||||
**남은 것**: Slot 코어 로직의 정확한 API(`research`→`base` 승격된
|
||||
`slot-plan.md` 참고)와 각 파일의 정확한 함수/타입 이름은 구현 단계에서.
|
||||
Tween/existing-instance-bind는 여전히 `research/`에 남아있고 이 구조 확정을
|
||||
막지 않음(`purity-and-effects-plan.md`는 이미 `base/`로 승격 완료).
|
||||
existing-instance-bind는 여전히 `research/`에 남아있고 이 구조 확정을
|
||||
막지 않음(`purity-and-effects-plan.md`/`tween-plan.md`는 이미 `base/`로 승격 완료).
|
||||
|
||||
## 코드 스타일 — 네이밍 케이싱 (2026-08-08 두 번째 세션 신설)
|
||||
|
||||
|
|
@ -227,9 +227,17 @@ Tween/existing-instance-bind는 여전히 `research/`에 남아있고 이 구조
|
|||
표현식 문법(공식 릴리스 노트: <https://luau.org/news/2021-10-31-luau-recap-october-2021/#if-then-else-expression>)
|
||||
— `cond and truthyOnly or fallback` 삼항 관용구와 달리 가운데 값이
|
||||
falsy(`nil`/`false`)여도 정확하게 동작함(`bind-system-plan.md`의
|
||||
`Dispatch.retractUnder` 정정 사례가 실제 버그 예시). **이 프로젝트의
|
||||
기본 삼항 표현 방식은 `if-then-else`** — `cond and x or y`는 `x`가
|
||||
테이블/숫자처럼 항상-truthy임이 보장될 때만 예외적으로 허용.
|
||||
`Dispatch.retractUnder` 정정 사례가 실제 버그 예시). **[강화, 2026-08-12
|
||||
세션 후속] `cond and x or y` 삼항 관용구는 전면 금지 — `if-then-else`만
|
||||
쓸 것, 가운데 값이 항상-truthy임이 보장돼도 예외 없음.** 처음엔 그
|
||||
경우만 예외적으로 허용했으나, 안전 여부와 무관하게 `if-then-else`가
|
||||
항상 더 낫다는 게 재확인됨 — `and`/`or`는 진짜 short-circuit이라 각
|
||||
단계 truthiness를 테스트하는 분기가 최대 2번 들어가는데(`cond` 테스트 +
|
||||
`and`의 결과 테스트), `if-then-else`는 `cond` 하나만 테스트하고 단일
|
||||
분기로 끝남 — 방어적이면서 바이트코드상으로도 더 적은 분기. 단순
|
||||
2항 fallback(`x or y`, "and" 없이 값 하나를 기본값으로 대체하는
|
||||
`props.Modifier or None`류)은 애초에 이 문제가 없어 그대로 유지 —
|
||||
금지 대상은 어디까지나 `A and B or C` 3항 조합.
|
||||
|
||||
**`const` 바인딩도 Luau 공식 문법**(<https://luau.org/syntax/#const-bindings>)
|
||||
이지만 **지금은 채택하지 않음** — 타입 추출/narrowing 등 주변 툴링이
|
||||
|
|
@ -296,8 +304,8 @@ pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬
|
|||
|
||||
## 아직 미정 (research/로 분리됨)
|
||||
|
||||
Tween 플러깅, 이미 생성된 인스턴스에 대한 바인드 — `.claude/research/` 각
|
||||
문서 참고, 전체 색인은 `.claude/README.md`. 바인드 디스패치/Slot/모듈
|
||||
이미 생성된 인스턴스에 대한 바인드 — `.claude/research/existing-instance-bind-plan.md`
|
||||
참고, 전체 색인은 `.claude/README.md`. 바인드 디스패치/Slot/모듈
|
||||
라이프사이클/Modifier/컴포넌트화(컴포넌트 경계 modifier/Ref 전달 포함)는
|
||||
위 "구현 착수" 섹션대로 확정되어 `.claude/base/`로 승격됨
|
||||
(`bind-system-plan.md`/`module-lifecycle-plan.md`/`slot-plan.md`/
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
# Attribute — 단일 키(`AttributeKey`)와 그룹(`Attribute(...)`) 두 프리미티브
|
||||
|
||||
**상태**: base — 단일 키 메커니즘/`None`/`retract` 동작과 타입 파라미터화는
|
||||
전부 확정(2026-08-09 열한 번째 세션). **[2026-08-11 아홉 번째 세션 추가]**
|
||||
전부 확정(2026-08-09 열한 번째 세션, **2026-08-12 세션 후속에서
|
||||
`retract` 완전 no-op화 + 그룹 청소 정책 전면 재정정 — 아래 "메커니즘"/
|
||||
"그룹 `Attribute(...)`" 절이 최신**). **[2026-08-11 아홉 번째 세션 추가]**
|
||||
Store 여러 개를 한 번에 attribute로 묶어 바인드하는 그룹 `Attribute(...)`
|
||||
프리미티브 신설, 이름 충돌 방지를 위해 기존 단일 키 생성자를
|
||||
`Attribute<<T>>` → `AttributeKey<<T>>`로 리네임(잠정 확정 — 최종 이름은
|
||||
|
|
@ -103,24 +105,30 @@ end
|
|||
|
||||
타입 파라미터화 이름과 무관하게 런타임 동작은 확정:
|
||||
|
||||
- `process(inst, k, v)` — `inst:SetAttribute(name, v)`가 사실상 전부.
|
||||
**Attribute는 `None`의 가장 깔끔한 사례** — Roblox API 자체가
|
||||
`SetAttribute(name, nil)`을 "그 Attribute 엔트리를 지운다"는 뜻으로
|
||||
네이티브 지원하므로, `None → nil` 재디스패치(`base/bind-system-plan.md`의
|
||||
`None` 센티널 절)가 도착했을 때 handler가 **아무 특별 처리도 없이**
|
||||
`inst:SetAttribute(name, nil)`을 그대로 호출하면 끝 — UICorner 숏핸드처럼
|
||||
"만들어둔 자식을 수동으로 찾아 지우는" 로직조차 필요 없음.
|
||||
- **`retract` 불필요** — Tag와 같은 이유: 값이 뭐든(실제 값/`nil`) 항상
|
||||
같은 `AttributeKeyHandler`가 이 키를 계속 담당(핸들러 *타입*이 안 바뀜).
|
||||
`retract`가 의미 있는 유일한 패턴("매치되는 핸들러 타입 자체가 바뀜",
|
||||
`Tag(...)`↔`nil`이 실사례 — 2026-08-10 세션부터 Tween은 더 이상 이
|
||||
패턴의 예시가 아님, `base/tween-plan.md`)에 해당 안 함 —
|
||||
`bind-system-plan.md` "확정된 디스패치 모델" 절이 한때 Attribute도
|
||||
retract 필요 예시로 들었던 걸 여기서 바로잡음. **[정정, 2026-08-12
|
||||
열 번째 세션] 그룹 위임 경로가 생기면서 `retract`가 다시 의미 있어짐 —
|
||||
아래 "이름 소유권" 절 참고, 이 문단이 말하는 "값이 뭐든 같은 핸들러"는
|
||||
여전히 맞지만 그룹이 이름을 놓을 때는 그 이름 전용 키 객체 자체가
|
||||
통째로 폐기되므로 그 키 슬롯 기준으로는 `retract`가 실행됨.**
|
||||
- `process(inst, k, v)` — `inst:SetAttribute(name, v)`가 사실상 전부,
|
||||
**`v`가 뭐든(실제 값이든 `nil`이든) 무조건 그대로 호출** — 일반
|
||||
프로퍼티 핸들러와 완전히 동일한 무조건 set. **Attribute는 `None`의
|
||||
가장 깔끔한 사례** — Roblox API 자체가 `SetAttribute(name, nil)`을
|
||||
"그 Attribute 엔트리를 지운다"는 뜻으로 네이티브 지원하므로, `None →
|
||||
nil` 재디스패치(`base/bind-system-plan.md`의 `None` 센티널 절)가
|
||||
도착했을 때 handler가 **아무 특별 처리도 없이** `inst:SetAttribute(name,
|
||||
nil)`을 그대로 호출하면 끝 — UICorner 숏핸드처럼 "만들어둔 자식을
|
||||
수동으로 찾아 지우는" 로직조차 필요 없음.
|
||||
- **`retract`는 완전 no-op — [재정정, 2026-08-12 세션 후속] "매번
|
||||
불리지만 대부분 no-op"이라던 직전 서술도 틀렸음, "대부분"이 아니라
|
||||
"항상"** — 일반 프로퍼티 핸들러(`retract`가 완전 무조건 no-op,
|
||||
`bind-system-plan.md` "일반 프로퍼티는 애초에 'unset' 개념이 없음")와
|
||||
완전히 같은 성격으로 재정정. **`AttributeKeyHandler.retract`는
|
||||
`SetAttribute`를 절대 호출하지 않음** — attribute를 지우는 유일한
|
||||
경로는 `process(inst,k,nil)`(`None`이든, State가 스스로 `nil`로
|
||||
바뀌든) 뿐. 이전 버전("이름이 사라질 때(`v==nil`)만 retract가
|
||||
`SetAttribute(name,nil)`을 호출")은 두 가지 문제가 있었음 — (1)
|
||||
`retract` 안에 관측 가능한 부작용이 생겨 `bind-system-plan.md`의
|
||||
"retract는 구조적 팝만, process 트리거 금지" 일반 규칙과 어긋나는
|
||||
성격의 코드가 됨, (2) 그룹이 survivor 이름에 `retractUnder(...,source)`를
|
||||
부를 때 그 시점에 `SetAttribute`가 잘못 끼어들 수 있는 경로가 생겨
|
||||
`a→nil→b` 깜빡임 위험(사용자 지적) — `retract`가 완전 no-op이면 이
|
||||
경로 자체가 물리적으로 없어짐.
|
||||
- store-bind 가능(일반 프로퍼티와 동일하게 취급, `Store<T>`/`State<T>`
|
||||
값도 받음).
|
||||
|
||||
|
|
@ -154,28 +162,17 @@ function AttributeKeyHandler.process(inst, k, v)
|
|||
if current ~= nil and current ~= k then
|
||||
error(("attribute \"%s\"는 이미 다른 AttributeKey가 관리 중"):format(name))
|
||||
end
|
||||
inst:SetAttribute(name, v)
|
||||
if v == nil then
|
||||
map[name] = nil -- nil로 귀결되면 소유권도 같이 반납 — 다른 claimant가 다시 쓸 수 있게
|
||||
else
|
||||
map[name] = k
|
||||
end
|
||||
inst:SetAttribute(name, v) -- v가 nil이든 아니든 무조건 — 일반 프로퍼티와 완전히 동일
|
||||
map[name] = if v == nil then nil else k -- nil로 귀결되면 소유권도 같이 반납
|
||||
owners:SetStrong(inst, map)
|
||||
end
|
||||
|
||||
function AttributeKeyHandler.retract(inst, k, v)
|
||||
-- [정정, 2026-08-12 열한 번째 세션] retract는 이 k가 store 재발행으로
|
||||
-- 다시 process될 때도 매번 불림(핸들러 타입이 안 바뀌어도) —
|
||||
-- bind-system-plan.md 일반 retract 계약 절 참고. v가 nil이 아니면
|
||||
-- 곧바로 process가 재확정하므로(같은 k가 계속 살아있는 한 항상
|
||||
-- 자기 자신이 다시 매치됨) 여기서 SetAttribute/소유권 반납을 할
|
||||
-- 이유가 없음 — v가 nil일 때만(진짜 이 이름이 사라지는 경우) 실행.
|
||||
local name = k.Name
|
||||
local map = owners:GetStrong(inst)
|
||||
if map and map[name] == k and v == nil then
|
||||
map[name] = nil
|
||||
inst:SetAttribute(name, nil)
|
||||
end
|
||||
-- 완전 no-op. 일반 프로퍼티와 동일 — "unset" 개념 자체가 없음.
|
||||
-- retract 안에서 process를 부르는 건 Dispatch.retractUnder의 체인
|
||||
-- 추적을 꼬는 UB(base/bind-system-plan.md 일반 규칙)라 여기서도
|
||||
-- SetAttribute를 직접이든 간접이든 절대 안 부름 — 지우는 건 오직
|
||||
-- process(inst,k,nil).
|
||||
end
|
||||
```
|
||||
|
||||
|
|
@ -188,22 +185,10 @@ end
|
|||
새 릴레이션 불필요, 이미 있던 걸 재사용. 이름을 처음 보면 `rawNew(name)`로
|
||||
만들어 이 맵에 캐싱하고 그 키로 위임, 이미 맵에 있으면(이전 사이클에
|
||||
이미 관리 중이던, 즉 "남아있는" 이름) **그 캐싱된 같은 객체를 그대로
|
||||
재사용**해서 위임.
|
||||
- **[정정, 2026-08-12 열한 번째 세션] "남아있는 이름은 retract 자체가 안
|
||||
불린다"는 예전 서술은 틀렸음** — `AttributeKeyHandler.retract`는 이제
|
||||
`v==nil`일 때만 실제로 뭔가 하므로(위 "메커니즘, `None`, `retract`" 절
|
||||
정정분), 남아있는 이름에 대해서도 `retractUnder`를 불러도 안전하고 —
|
||||
오히려 **불러야 함**: `Dispatch.process`가 매번 체인 꼬리에 새 항목을
|
||||
쌓기만 하지 스스로 옛 항목을 안 지우므로(popping은 `retractUnder`
|
||||
자신의 일), 남아있는 이름을 `retractUnder` 없이 `Dispatch.process`만
|
||||
반복 호출하면 그룹 값이 교체될 때마다 같은 `(inst,key)` 체인에 옛
|
||||
`AttributeKeyHandler` 항목이 계속 쌓이는 누수가 생김. **그래서 그룹의
|
||||
diff는 사라진/남아있는 이름 모두 자기 캐싱된 키로 먼저
|
||||
`Dispatch.retractUnder(inst, key, nil, newSourceOrNil)`를 부른 뒤에만
|
||||
`Dispatch.process`를 부름** — 새로 들어온 이름만 예외(옛 체인이 없으니
|
||||
retractUnder 없이 바로 process). `AttributeKeyHandler.retract`가 `v`가
|
||||
non-nil이면 즉시 return하는 얇은 함수라 이 추가 호출의 비용은 무시할
|
||||
수준.
|
||||
재사용**해서 위임. 이 diff/재위임 로직의 정확한 코드는 아래 "그룹
|
||||
`Attribute(...)`" 절의 `AttributeGroupHandler.process`/`.retract` 참고
|
||||
— `AttributeKeyHandler` 자신은 diff를 전혀 모름(위 "이름이 살아있는
|
||||
동안 항상 같은 키 재사용" 전제만 지켜지면 그만).
|
||||
- **패키지 경계**: `AttributeKey` 자체가 이미 quad-roblox 소속(Tag와
|
||||
달리 base/roblox로 안 쪼갬, 아래 "패키지 배치" 절)이고 그룹의 실제
|
||||
위임 로직도 이미 roblox 쪽 글루라 `rawNew` 호출이 새 역의존을 안 만듦 —
|
||||
|
|
@ -257,59 +242,95 @@ Dispatch 재진입 없이 직접 `SetAttribute`+수동 per-field StoreBind 구
|
|||
위 "이름 소유권" 절 참고, 이 절은 그 위에서 diff 로직이 어떻게 도는지만
|
||||
설명:
|
||||
|
||||
- **그룹 값이 (재)할당될 때**(마운트 시점, 또는 `:Compute`가 그룹 값
|
||||
자체를 교체하는 드문 경우 — 흔한 필드 하나 변경과는 다른 경로, 아래
|
||||
참고): `(inst, index)`(array-part 위치, `Tag`의 `relate:GetStrong(inst,k)`와
|
||||
동일 키잉 — `k`는 배열 인덱스)로 찾은 릴레이션에 저장된 **"이전에 쓴
|
||||
attribute 이름 → 그 이름 전용 키 객체 맵"**(구 "이름 문자열 집합" —
|
||||
이름 존재 여부뿐 아니라 그때 쓴 키 객체 자체까지 같이 들고 있어야 위
|
||||
"이름 소유권" 절의 동일 객체 재사용이 성립)과 새 값의 키 집합을 diff:
|
||||
- **[정정, 2026-08-12 열한 번째 세션] 사라진 이름뿐 아니라 남아있는
|
||||
이름도 먼저 `Dispatch.retractUnder(inst, key, nil, source)`를 부른
|
||||
뒤에야 `Dispatch.process`를 부름** — 그 이름이 맵에 들고 있던 **그
|
||||
전용 키 객체**로, 그 이름이 살아있는 동안 만들어졌던 원래 체인과
|
||||
정확히 같은 슬롯을 가리켜 정리(팝)됨. `retractUnder` 없이
|
||||
`Dispatch.process`만 반복 호출하면 체인이 매번 새 항목을 쌓기만 해서
|
||||
(팝은 `retractUnder`의 일) 같은 키 자리에 옛 `AttributeKeyHandler`
|
||||
항목이 계속 누적되는 진짜 누수가 생기므로, "값이 안 바뀌었으니
|
||||
retract 생략"은 성립 안 함 — `AttributeKeyHandler.retract`가 `v`
|
||||
non-nil이면 즉시 return하는 얇은 함수라(위 "이름 소유권" 절) 이
|
||||
호출 자체는 사실상 공짜.
|
||||
- 사라진 이름: `Dispatch.retractUnder(inst, key, nil, nil)` — 뒤이어
|
||||
`process` 안 부름, 맵에서도 그 이름 제거.
|
||||
- 남아있는 이름: `Dispatch.retractUnder(inst, key, nil, source)` →
|
||||
바로 `Dispatch.process(inst, key, source)` — **맵에 이미 캐싱된 같은
|
||||
키 객체를 그대로 재사용**.
|
||||
- 새로 들어온 이름: 옛 체인이 없으니 `retractUnder` 없이 바로
|
||||
`rawNew(name)`로 갓 만든 키를 맵에 새로 캐싱하고
|
||||
`Dispatch.process(inst, key, source)`.
|
||||
- **값 비교 없이 전부 재위임** — 작성자가 직접
|
||||
`[AttributeKey<<T>> name] = source`를 쓴 것과 거의 같은 경로를
|
||||
타므로(단, 공개 캐시가 아니라 그룹 전용 키를 씀) `source`가
|
||||
`State`/`Source`면 `Dispatch/StoreBind`가 알아서 언랩+구독까지 다
|
||||
해줌(그룹 Handler가 따로 구독 관리 안 함).
|
||||
- **값 비교(`:Get()`으로 old/new 비교)는 하지 않음** — State 계약("값은
|
||||
항상 선언된 Compute 재실행 결과, 캐시 비교 금지", `store-semantics.md`
|
||||
"하드 경계" 절)과 어긋나고, 릴레이션 키를 문자열 이름 집합(존재 여부)만
|
||||
들고 있으면 충분해서 굳이 값까지 비교할 이유가 없음.
|
||||
- **캐시(맵)는 그룹 값 교체를 넘어 계속 유지돼야 함** — 매 교체마다
|
||||
남아있는 이름의 키까지 새로 만들면, "이름 소유권" 절의 `owners`
|
||||
레지스트리엔 옛 키가 남아있어 새로 만든 키와 비교 시 오탐 충돌이
|
||||
남(자기 자신과 충돌하는 꼴). 이 릴레이션이 `(inst, index)`로
|
||||
영속되는 건 이미 확정돼 있던 설계라 새로 챙길 것 없음, 저장 값
|
||||
형태만 위처럼 바뀜.
|
||||
**그룹의 `process`/`retract` — [전면 재정정, 2026-08-12 세션 후속]**
|
||||
아래는 `(inst, index)`(array-part 위치, `Tag`의 `relate:GetStrong(inst,k)`와
|
||||
동일 키잉 — `k`는 배열 인덱스)로 찾은 릴레이션에 저장된 **"이름 →
|
||||
그 이름 전용 키 객체 맵"**(이름 존재 여부뿐 아니라 그때 쓴 키 객체
|
||||
자체까지 같이 들고 있어야 위 "이름 소유권" 절의 동일 객체 재사용이
|
||||
성립)을 씀:
|
||||
|
||||
```lua
|
||||
local groupState = Relate() -- {[inst(weak)] = {[index]: {[name]: keyObject}}}
|
||||
|
||||
function AttributeGroupHandler.process(inst, index, v)
|
||||
if v == nil then return end
|
||||
local map = groupState:GetStrong(inst, index) or {}
|
||||
for name, source in pairs(v:NameMap()) do
|
||||
local key = map[name] or rawNew(name) -- 남아있던 이름은 캐싱된 같은 객체 재사용
|
||||
map[name] = key
|
||||
Dispatch.retractUnder(inst, key, nil, source) -- chain-append-leak 방지, 매번(신규는 빈 체인이라 no-op)
|
||||
Dispatch.process(inst, key, source)
|
||||
end
|
||||
groupState:SetStrong(inst, index, map)
|
||||
end
|
||||
|
||||
function AttributeGroupHandler.retract(inst, index, v)
|
||||
local map = groupState:GetStrong(inst, index)
|
||||
if not map then return end
|
||||
local newNames = if isAttribute(v) then v:NameMap() else {}
|
||||
for name, key in pairs(map) do
|
||||
if newNames[name] == nil then -- 새 v에 이제 없는 이름만
|
||||
Dispatch.retractUnder(inst, key, nil, nil) -- 구독만 끊음 — SetAttribute는 안 일어남(아래 원칙)
|
||||
map[name] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
- **`process`는 매번 살아있는 이름 전부를 `retractUnder`+`process`
|
||||
페어로 재위임** — 신규/생존 구분 없이 균일 처리. `Dispatch.process`가
|
||||
매번 체인 꼬리에 새 항목을 쌓기만 하지 스스로 옛 항목을 안 지우므로
|
||||
(팝은 `retractUnder`의 일), `retractUnder` 없이 `Dispatch.process`만
|
||||
반복 호출하면 같은 키 자리에 옛 `AttributeKeyHandler` 항목이 계속
|
||||
쌓이는 누수가 생김 — 신규 이름은 아직 체인이 없어 `retractUnder`가
|
||||
그냥 no-op이라 이 페어링을 신규/생존 가리지 않고 통일해도 비용 없음.
|
||||
**값 비교(`:Get()`으로 old/new 비교)는 안 함** — State 계약("값은 항상
|
||||
선언된 Compute 재실행 결과, 캐시 비교 금지", `store-semantics.md`
|
||||
"하드 경계" 절)과 어긋나고, `source`가 `State`/`Source`면
|
||||
`Dispatch/StoreBind`가 알아서 언랩+구독까지 다 해줌(그룹 Handler가
|
||||
따로 구독 관리 안 함)이라 굳이 비교할 이유가 없음.
|
||||
- **[확정, 2026-08-12 세션 후속, 사용자 결정] `retract`는 `SetAttribute`를
|
||||
절대 안 부름 — Attribute는 오직 명시적 `None`/`nil`로만 지워진다.**
|
||||
그룹에서 이름이 조용히 빠지든(diff로 사라짐), 그룹 바인딩 자체가
|
||||
통째로 사라지든(컴포넌트 언마운트 등, `v`가 더 이상 Attribute가 아님)
|
||||
프레임워크가 자동으로 `SetAttribute(name,nil)`을 대신 불러주지
|
||||
않음 — 값이 이전 것 그대로 남는 게 정상 동작. `Ref`가 Destroy와
|
||||
무관하게 동작하는 것과 같은 철학("지울 거면 명시적으로 지우라",
|
||||
`bind-system-plan.md`의 "`Ref`의 retract" 절)으로 통일. **이전 초안은
|
||||
"Tag와 동일하게 확실히 청소"였으나 뒤집힘** — 이유: (1) diff로
|
||||
조용히 빠지는 이름은 안 지워주면서 통째 소멸일 땐 지워주면, 두 경우가
|
||||
서로 다른 규칙이 되어 오히려 모호해짐(사용자 지적: "diff 쌓인 거랑
|
||||
전부 지운 거랑 완전 달라짐"). (2) Attribute 이름은 이미 "겹치면
|
||||
error"로 소유 코드가 명확히 갈리는 설계(위 "이름 소유권" 절)라, 그
|
||||
이름을 만든 코드가 알아서 지우는 게 맞지 프레임워크가 대신 판단할
|
||||
이유가 불투명함. (3) 정말 자동 청소가 필요하면 `Animate`와 같은 모양
|
||||
(`State<data> -> State<Attribute>`를 만드는 `:Apply` 팩토리, 이전
|
||||
그룹과 비교해 사라진 이름을 `None`으로 명시적으로 채워 넣는 유틸)을
|
||||
나중에 opt-in으로 추가하면 됨 — 그건 사용자가 고른 명시적 선택이라
|
||||
모호하지 않음, 지금은 범위 밖(백로그).
|
||||
- **다만 사라진 이름의 *구독*은 끊음 — 값은 안 지워도 자원은 새면
|
||||
안 됨.** 위 "값은 안 지운다" 원칙과 별개로, 그룹이 더 이상 관리하지
|
||||
않는 이름의 `(inst,key)` 체인을 그대로 두면 그 키에 걸려있던
|
||||
`StoreBind` 구독이 인스턴스가 살아있는 동안 영원히 남아 원본
|
||||
`Source`가 바뀔 때마다 계속 `SetAttribute`를 쏘는 실제 리소스 누수가
|
||||
됨(이건 "마지막 값이 남는다"는 것과 다른 문제 — 안 죽는 구독 자체가
|
||||
문제). 그래서 `retract`는 사라진 이름에 한해 `Dispatch.retractUnder(inst,
|
||||
key, nil, nil)`만 부름 — **`Dispatch.process`는 절대 안 부르므로**
|
||||
(retract 안에서 process 호출은 `retractUnder`의 체인 추적을 꼬는 UB,
|
||||
`bind-system-plan.md` 일반 규칙) `AttributeKeyHandler.retract`(완전
|
||||
no-op)만 타고 끝나 `SetAttribute`는 여기서도 절대 안 일어남 — 위
|
||||
"명시적 None으로만 지운다" 원칙과 안 부딪힘.
|
||||
- **필드 하나만 바뀌는 흔한 경우**(`storeA.foo:Set(v)`, 그룹 자체는
|
||||
안 바뀜)는 위 그룹 재처리를 아예 거치지 않음 — 마운트 시 이미 걸린
|
||||
단일 키 `AttributeKeyHandler`의 store-bind 구독이 바로
|
||||
`SetAttribute("foo", v)`를 호출(그룹 재진입 없이 그 경로 스스로).
|
||||
**`AttributeChanged`/`GetAttributeChangedSignal` 남발 걱정 없음** —
|
||||
키 집합이 안 바뀌는 한 diff 로직 자체가 안 돎.
|
||||
- **`retract`**: 자기가 쓴 키(전용 키 객체) 전부 `Dispatch.retractUnder`로
|
||||
정리 — 단일 키의 `SetAttribute(name, nil)`이 그대로 실행되므로 결과적으로
|
||||
`Tag`와 동일하게 확실히 청소됨. Roblox는 Instance 풀링/재사용이 흔해서,
|
||||
이전 컴포넌트가 남긴 attribute가 재사용된 Instance에 방치되면
|
||||
selector/스타일시트 로직에 실제 버그가 됨. ("소진돼도 부작용 없으니
|
||||
방치해도 된다"는 초안은 기각 — Tag의 확실한 청소 원칙과 통일.)
|
||||
- **캐시(맵)는 그룹 값 교체를 넘어 계속 유지돼야 함** — 매 교체마다
|
||||
남아있는 이름의 키까지 새로 만들면, "이름 소유권" 절의 `owners`
|
||||
레지스트리엔 옛 키가 남아있어 새로 만든 키와 비교 시 오탐 충돌이
|
||||
남(자기 자신과 충돌하는 꼴). 이 릴레이션이 `(inst, index)`로 영속되는
|
||||
건 이미 확정돼 있던 설계.
|
||||
|
||||
**그룹 Handler에 남는 자기 로직은 사실상 "이름 집합 diff"뿐** — 실제
|
||||
`SetAttribute` 호출/`None` 처리/store-bind 구독은 전부 기존 단일 키
|
||||
|
|
@ -353,3 +374,10 @@ UICorner 숏핸드/Tween/Tag와 같은 판단 재사용 — `quad-roblox` 코어
|
|||
당장의 해석 모호성은 없앴음 — 그래도 최종 이름은 다른 가칭들(`State`/
|
||||
`DI`→`D`/`Slot`/`canExecute`/`Brand`)과 함께 `.claude/question.md` 용어정리
|
||||
대기열에 있음, 나중에 한꺼번에 재검토.
|
||||
- **[백로그, 2026-08-12 세션 후속]** 그룹이 이름을 조용히 놓아도
|
||||
`SetAttribute(name,nil)`을 자동으로 안 해준다는 위 "그룹 `Attribute(...)`"
|
||||
절의 결정 — 그래도 명시적 자동 unset이 갖고 싶으면 `Animate`와 같은
|
||||
모양의 `:Apply` opt-in 유틸(이전 이름 집합과 비교해 사라진 이름을
|
||||
`None`으로 채워주는 콤비네이터)을 나중에 추가할 수 있음, 착수 안 함 —
|
||||
`research/operator-sugar-plan.md` "Attribute 그룹 명시적 unset 유틸"
|
||||
절 참고.
|
||||
|
|
|
|||
|
|
@ -47,12 +47,13 @@ v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는
|
|||
없음(예: 일반 프로퍼티 핸들러는 보통 no-op).
|
||||
**`retract` 필드 자체는 생략 불가, no-op이라도 항상 정의할 것(2026-08-08
|
||||
세션, 확정)** — `Dispatch.process`(아래 "확정된 디스패치 모델" 절)는
|
||||
담당 핸들러 *타입*이 바뀔 때 이전 핸들러의 `retract`를 nil 체크 없이
|
||||
무조건 호출함. 필드를 생략한 핸들러가 나중에(드물더라도, 예: `Tag(...)`↔
|
||||
`nil` 교체 — `base/tag-plan.md`) 실제로 담당이 바뀌는 순간 `attempt to
|
||||
call a nil value`로
|
||||
바로 크래시 — "의미 있게 구현할 필요 없음"은 "구현이 사소해도 됨"이라는
|
||||
뜻이지 "필드를 안 둬도 안전하다"는 뜻이 아님. 새 핸들러를 짤 때 이 필드가
|
||||
store 재발행마다(핸들러 *타입*이 안 바뀌어도) 이전 핸들러의 `retract`를
|
||||
nil 체크 없이 무조건 호출함(`[전면 정정, 2026-08-12 열한 번째 세션]`,
|
||||
아래 "retract-always-fires" 정정 절 참고 — 담당 타입이 바뀔 때만
|
||||
불린다는 건 예전의 틀린 가정이었음). 필드를 생략한 핸들러는 이 반복
|
||||
호출에서 바로 `attempt to call a nil value`로 크래시 — "의미 있게
|
||||
구현할 필요 없음"은 "구현이 사소해도 됨"이라는 뜻이지 "필드를 안 둬도
|
||||
안전하다"는 뜻이 아님. 새 핸들러를 짤 때 이 필드가
|
||||
없으면 리뷰/린트에서 걸러내야 함(M2 착수 시 확인 목록에 추가).
|
||||
|
||||
디스패치는 등록된 핸들러를 우선순위 순으로 스캔하며 `isHandlable`을 호출,
|
||||
|
|
@ -155,6 +156,22 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
|
|||
패턴이 아님 — 상세 구현은 `base/tag-plan.md`/`base/attribute-plan.md`
|
||||
"이름 소유권" 절, `Ref`는 아래 "`Ref`의 retract" 절, `Slot`은
|
||||
`slot-plan.md` "Slot과 Store 바인드의 관계" 절 참고.
|
||||
- **[일반 규칙, 2026-08-12 세션 후속] `retract`의 `v`는 타입을 보장 안
|
||||
함 — 내용(메소드/필드)을 보려면 반드시 `isX(v)` 가드부터.** `retract`가
|
||||
`old ~= v`/`v == nil`처럼 identity/nil만 비교하면 가드가 필요 없지만
|
||||
(`Ref`/`Slot`/`AttributeKey`가 이 경우), `v`의 내용을 실제로 들여다봐야
|
||||
하면(`TagHandler.retract`의 `newv:Contains(name)`처럼) 그 전에 반드시
|
||||
`isTag(newv)` 같은 타입 가드를 거칠 것 — 안 그러면 그 자리 핸들러
|
||||
*타입*이 바뀌는 드문 경우에 엉뚱한 값의 메소드를 호출해 크래시함.
|
||||
(`AttributeGroupHandler.retract`가 이 가드를 처음엔 빠뜨렸던 실수,
|
||||
`base/attribute-plan.md` 참고.)
|
||||
- **[일반 규칙, 2026-08-12 세션 후속] `retract` 안에서 `Dispatch.process`를
|
||||
부르는 것은 UB — `Dispatch.retractUnder`가 체인을 걷는 도중의 트래킹이
|
||||
꼬임.** `retract`는 오직 청소(구조적 팝, 내부 자원 해제)만 전담하고
|
||||
새 등록을 트리거하면 안 됨 — 새 등록은 항상 바깥의 StoreBind/그룹
|
||||
로직이 `retract` 호출이 다 끝난 *뒤에* 별도로 `process`를 부르는
|
||||
순서로만 일어나야 함. `Dispatch.retractUnder`를 (다른 키에 대해)
|
||||
`retract` 안에서 부르는 건 문제없음 — 금지되는 건 오직 `process`.
|
||||
- Tween은 이 패턴과 무관 — 독립 Dispatch 핸들러가 아니라 PropertyHandler가
|
||||
소비하는 값-레벨 래퍼(`Tween<T>`)라 매치되는 핸들러가 항상
|
||||
PropertyHandler 하나뿐(2026-08-10 세션 재설계) — 트윈 취소/전환은
|
||||
|
|
@ -411,15 +428,20 @@ function Dispatch.retractUnder(inst, k, keep, v)
|
|||
end
|
||||
```
|
||||
|
||||
**[2026-08-12 세션에서 정정]** `list[i].retract(...)`의 세 번째 인자가
|
||||
원래 `i == cutoff + 1 and v or nil`(and/or 삼항 관용구)이었으나, `v`가
|
||||
`false`일 때(정당한 boolean 프로퍼티 값) `and`의 결과가 falsy가 되어
|
||||
`i == cutoff + 1`이 참이어도 `or nil`로 새는 조용한 버그였음 — Luau의
|
||||
`if-then-else` 표현식(2021년 도입)으로 교체. **일반 규칙**: `cond and
|
||||
truthyOnly or fallback` 관용구는 가운데 값이 테이블/항상-truthy 값일
|
||||
때만 안전(예: `Tag(...)`/`Length:Get()`처럼 절대 `nil`/`false`가 될 수
|
||||
없는 값) — 임의 `T`(boolean 포함) 값이 가운데 올 수 있으면 반드시
|
||||
`if cond then a else b`를 쓸 것.
|
||||
**[2026-08-12 세션에서 정정, 같은 세션 후속으로 예외 조항까지 폐기]**
|
||||
`list[i].retract(...)`의 세 번째 인자가 원래 `i == cutoff + 1 and v or
|
||||
nil`(and/or 삼항 관용구)이었으나, `v`가 `false`일 때(정당한 boolean
|
||||
프로퍼티 값) `and`의 결과가 falsy가 되어 `i == cutoff + 1`이 참이어도
|
||||
`or nil`로 새는 조용한 버그였음 — Luau의 `if-then-else` 표현식(2021년
|
||||
도입)으로 교체. **일반 규칙(강화): `cond and x or y` 삼항 관용구는
|
||||
전면 금지, `if cond then x else y`만 쓸 것** — 처음엔 "가운데 값이
|
||||
테이블/항상-truthy일 때만 예외적으로 and/or 허용"이었으나, 안전 여부와
|
||||
무관하게 `if-then-else`가 항상 우월하다는 게 재확인돼 예외 자체를
|
||||
없앰: `and`/`or`는 진짜 short-circuit이라 각 단계마다 truthiness를
|
||||
테스트하는 명령이 들어가는데(최대 2회 분기), `if-then-else`는 `cond`
|
||||
하나만 테스트하고 단일 분기로 끝남 — 안전한 경우에도 `if-then-else`
|
||||
쪽이 바이트코드상 더 적은 분기. `base/architecture.md`의 "코드 스타일
|
||||
— Luau 문법 관례" 절도 같이 갱신.
|
||||
|
||||
- **`handler.process(inst,k,v)`를 `Dispatch.process`를 거치지 않고 직접
|
||||
호출하는 것은 UB — 반드시 `Dispatch.process`를 통해서만 진입할 것.**
|
||||
|
|
@ -450,13 +472,18 @@ truthyOnly or fallback` 관용구는 가운데 값이 테이블/항상-truthy
|
|||
- **`retract`는 여전히 `(inst,k,v)` 3-인자** — 드롭하자는 제안이 대화
|
||||
중 한 번 나왔으나 기각(전체 삭제 vs 부분 diff를 갈라야 하는 핸들러가
|
||||
있어서, `base/tag-plan.md` 참고). 다만 `v`가 실제로 필요한지는
|
||||
핸들러마다 다름 — Tag는 구조상 retract가 "더 이상 매치 안 될 때만"
|
||||
불리므로 `v`를 안 봐도 항상 전체 삭제가 맞음(무조건) — `v`는 "계약상
|
||||
항상 주어지지만 안 쓰는 핸들러가 있어도 됨" 정도로 이해할 것.
|
||||
핸들러마다 다름 — **[정정, 2026-08-12 열한 번째 세션]** Tag는 오히려
|
||||
반대로 `v`를 반드시 봐야 하는 대표 사례다: `retract`는 store
|
||||
재발행마다(핸들러 타입이 안 바뀌어도) 항상 불리므로, `TagHandler.retract`는
|
||||
`v`가 여전히 그 이름을 `Contains`하는지 힌트로 확인해 실제 엔진
|
||||
`RemoveTag` 호출만 skip한다 — "더 이상 매치 안 될 때만 불리므로 무조건
|
||||
전체 삭제가 맞다"는 원 서술은 이 정정 전 잘못된 가정이었음, 상세는
|
||||
`base/tag-plan.md` "메커니즘" 절 참고. `v`는 "계약상 항상 주어지지만
|
||||
안 쓰는 핸들러가 있어도 됨" 정도로 이해할 것.
|
||||
**[정정, 2026-08-10 세션]** 원래 두 번째 예시로 들었던 Tween(자기
|
||||
`Relate` 저장분만 보고 `Cancel`하면 되니 `v`를 꼭 안 봐도 됨)은 더
|
||||
이상 유효한 예시가 아님 — PropertyHandler가 항상 매치되는 유일한
|
||||
핸들러가 되어 이 `retract` 경로 자체가 사실상 안 쓰임(`research/
|
||||
핸들러가 되어 이 `retract` 경로 자체가 사실상 안 쓰임(`base/
|
||||
tween-plan.md`).
|
||||
- **순환은 UB, 방어 로직 없음** — Handler 간 순환 참조(A가 B를 부르고
|
||||
B가 다시 A로 돌아오는 것)는 재귀 호출이 안 끝나 바로 스택오버플로가
|
||||
|
|
@ -624,7 +651,7 @@ local function recompute(ownerKey, bk)
|
|||
offset:Set(sum)
|
||||
end
|
||||
local v = bk.lengthList[i]
|
||||
sum += (isState(v) and v:Get() or v)
|
||||
sum += (if isState(v) then v:Get() else v)
|
||||
end
|
||||
if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then
|
||||
ownerKey.Length:Set(sum) -- ownerKey가 물리 inst가 아니라 Slot 자신인 재귀 케이스
|
||||
|
|
|
|||
|
|
@ -33,8 +33,13 @@ weak여야 함 — 그런데 그 안에 담기는 값은 경우에 따라 **강
|
|||
## 위험한 패턴 — 서로 다른 두 `Relate`의 상호 강참조 순환 (2026-08-12 열세/열네 번째 세션, `Slot` 설계 중 발견)
|
||||
|
||||
위 26-28행이 말하는 "값이 자기 키를 다시 참조하는" 자기참조(예:
|
||||
`Dispatch.setLength`의 `observer` 클로저가 `inst`를 캡처, `Ref.Value=inst`)는
|
||||
**단일 `Relate` 안에서** 일어나는 한 안전함 — 그 `Relate`의 키(`inst`)가
|
||||
`Dispatch.setLength`의 `observer` 클로저가 `inst`를 캡처, `Ref.Value=inst`,
|
||||
**[확인, 2026-08-12 세션 후속] `slot._mountedInst = physicalTarget`도
|
||||
같은 패턴** — `bindLifetime(physicalTarget, slot)`이 내부적으로
|
||||
`physicalTarget`을 키로 `slot`을 강하게 붙잡는 단일 `relate`고,
|
||||
`slot._mountedInst`는 그 값(`slot`)이 자기 키(`physicalTarget`)를 다시
|
||||
참조하는 필드일 뿐이라 GC 안전 — `base/slot-plan.md` "재귀 메커니즘"
|
||||
절)는 **단일 `Relate` 안에서** 일어나는 한 안전함 — 그 `Relate`의 키(`inst`)가
|
||||
테이블 *바깥에서* 독립적으로 reachable한지만 판별하면 되기 때문. 하지만
|
||||
**서로 다른 두 `Relate`가 서로의 키를 상대방 값으로 강하게 제공하는
|
||||
상호 순환**(예: `RelateA[inst]=value`(강)와 `RelateB[value]=inst`(강)가
|
||||
|
|
|
|||
|
|
@ -138,10 +138,14 @@ Fusion의 `Children` SpecialKey는 이걸 "특정 SpecialKey 하나의 내부
|
|||
마운트 이후에 호출되는 경우 이 값으로 즉시 활성화(아래 "`Slot:List(...)`"의
|
||||
"구독 시점" 절 참고).
|
||||
- **개별 element**: Slot 안에 담기는 각 element(Instance/컴포넌트 결과 등)
|
||||
마다 전역 weak-set 멤버십으로 추적 — 특정 Slot 인스턴스에 안 묶임
|
||||
("한 인스턴스가 어디에도 중복 마운트 안 됨"이 라이브러리 전역 불변식이라서).
|
||||
`Add`가 이 weak-set을 확인(이미 참이면 error)/설정, `Remove`/`Extract`
|
||||
둘 다 여기서 제거(둘의 차이는 파괴 여부일 뿐, "마운트 해제"라는 점은 같음).
|
||||
마다 전역 멤버십으로 추적 — 특정 Slot 인스턴스에 안 묶임("한 인스턴스가
|
||||
어디에도 중복 마운트 안 됨"이 라이브러리 전역 불변식이라서). **[구체화,
|
||||
2026-08-12 열여섯 번째 세션]** 이 전역 멤버십의 실제 구현은 아래
|
||||
"요소 소유권 — `elementOwner`" 절의 `claimOwner`/`releaseOwner` —
|
||||
`Add`가 `claimOwner`(이미 다른 owner면 error)/`Remove`·`Extract`가
|
||||
`releaseOwner`. top-level Dispatch 마운트(`SlotHandler`)도 **같은**
|
||||
레지스트리를 써서 두 경로가 서로의 소유권을 볼 수 있음(전에는 별도
|
||||
레지스트리라 안 보였던 gap, 해당 절 참고).
|
||||
|
||||
## 여럿 존재 가능, 부모가 실제 데이터 테이블만 다루면 됨
|
||||
|
||||
|
|
@ -152,12 +156,18 @@ Slot은 하나의 instance 안에 여럿 존재할 수 있다. 전부 하나의
|
|||
|
||||
## 마운트된 Slot의 재마운트는 즉시 throw (확정)
|
||||
|
||||
**사용자 확인 완료**: 이미 사용된(마운트된) slot을 재마운트하려 하면 **즉시
|
||||
`error()`로 중단** — warn+no-op 아님. 개발 중 바로 잡아낼 수 있게 강하게
|
||||
실패하는 쪽 선택. 마운트되는 순간 slot의 실제 대상은 고정된다 — 따라서
|
||||
**사용자 확인 완료**: 이미 사용된(마운트된) slot을 **다른 위치로** 재마운트하려
|
||||
하면 **즉시 `error()`로 중단** — warn+no-op 아님. 개발 중 바로 잡아낼 수 있게
|
||||
강하게 실패하는 쪽 선택. 마운트되는 순간 slot의 실제 대상은 고정된다 — 따라서
|
||||
**글로벌 스코프에서 slot을 쓰는 건 그다지 좋지 않을 수 있음**(재사용/재마운트가
|
||||
막히므로).
|
||||
|
||||
> **[명확화, 2026-08-12 열두 번째 세션 메커니즘과 대조]** 위 throw는 **다른**
|
||||
> `inst`로 마운트하려 할 때만 해당 — **같은** `inst`로 재-emit되는 경우(store
|
||||
> 재발행 등으로 `process`가 같은 slot을 다시 받는 경우)는 예외적으로 no-op이지
|
||||
> throw가 아니다. 상세 메커니즘은 아래 "Slot과 Store 바인드의 관계" 절의
|
||||
> `SlotHandler.process`(`owner == inst` 분기) 참고.
|
||||
|
||||
## 클래스가 슬롯을 받는 방법
|
||||
|
||||
"네이밍된 슬롯"이 필요한가에 대한 사용자 자문: 그냥 슬롯 바인드 테이블을
|
||||
|
|
@ -241,28 +251,54 @@ tables" 항목).** 즉 이 회피는 "혹시 몰라서"가 아니라 **Luau에
|
|||
하나로만 두고, `kSlotMap`/`slotOwner`는 전부 `SetWeak`(순수 조회용,
|
||||
아무것도 안 붙잡음)로 낮춤**:
|
||||
|
||||
**[일반화, 2026-08-12 열여섯 번째 세션] `slotOwner`는 top-level Dispatch
|
||||
마운트만 보고 있어서, Slot-in-Slot으로 nested `Add`되는 경로(아래 "요소
|
||||
소유권 — `elementOwner`" 절)와 서로 다른 레지스트리라 어느 한쪽이 이미
|
||||
소유 중인 걸 다른 쪽이 못 보고 이중 마운트를 허용하는 gap이 있었음
|
||||
(사용자 발견) — `slotOwner`를 element(범용, Slot이든 plain Instance든)
|
||||
전체를 커버하는 `elementOwner`로 승격해 top-level Dispatch 경로와
|
||||
nested CRUD 경로가 **같은** 레지스트리를 쓰도록 통합:
|
||||
|
||||
```lua
|
||||
local kSlotMap = Relate() -- SlotHandler 전용, (inst,k)별 마지막으로 마운트한 Slot — weak, 조회 전용
|
||||
local slotOwner = Relate() -- Slot 자신이 weak 키 — {[slot] = 지금 바인딩된 inst} — weak, 조회 전용
|
||||
local kSlotMap = Relate() -- SlotHandler 전용, (inst,k)별 마지막으로 마운트한 Slot — weak, 조회 전용
|
||||
local elementOwner = Relate() -- element(Slot이든 plain 마운트 가능 값이든) 전체 공용
|
||||
-- {[element] = ownerKey} -- ownerKey: inst | Slot, 전부 weak
|
||||
local OWNER = "__owner" -- sentinel key(Relate는 항상 3-인자 SetWeak/2-인자 GetWeak 이후
|
||||
-- 값이라 outer key 하나당 값 하나만 저장하고 싶어도 key가 필요함 —
|
||||
-- lifecycle-pattern.md의 GCCONN/GCHOLD와 같은 패턴, base/relate-plan.md 참고)
|
||||
|
||||
local function claimOwner(element, ownerKey)
|
||||
local current = elementOwner:GetWeak(element, OWNER)
|
||||
if current == ownerKey then
|
||||
return false -- 이미 같은 owner — no-op, 재확인만
|
||||
end
|
||||
if current ~= nil then
|
||||
error("이 요소는 이미 다른 곳에 마운트돼 있음 — 다중 마운트 금지")
|
||||
end
|
||||
elementOwner:SetWeak(element, OWNER, ownerKey)
|
||||
return true
|
||||
end
|
||||
|
||||
local function releaseOwner(element, ownerKey)
|
||||
if elementOwner:GetWeak(element, OWNER) == ownerKey then
|
||||
elementOwner:SetWeak(element, OWNER, nil)
|
||||
end
|
||||
end
|
||||
|
||||
function SlotHandler.process(inst, k, slotValue)
|
||||
local owner = slotOwner:GetWeak(slotValue)
|
||||
if owner == inst then
|
||||
if not claimOwner(slotValue, inst) then
|
||||
return -- 이미 이 inst에 바인딩된 채 — 단순 emit 전파, no-op
|
||||
end
|
||||
if owner ~= nil then
|
||||
error("이 Slot은 이미 다른 곳에 마운트돼 있음 — 다중 마운트 금지")
|
||||
end
|
||||
attachSlot(slotValue, inst, inst, k) -- 내부에서 bindLifetime(inst, slotValue) 호출(아래)
|
||||
slotOwner:SetWeak(slotValue, inst)
|
||||
attachSlot(slotValue, inst, inst, k) -- top-level이라 내부에서 bindLifetime(inst, slotValue) 호출(위 "재귀 메커니즘" 절)
|
||||
kSlotMap:SetWeak(inst, k, slotValue)
|
||||
end
|
||||
|
||||
function SlotHandler.retract(inst, k, v)
|
||||
local old = kSlotMap:GetWeak(inst, k)
|
||||
if old and old ~= v then -- v는 nil일 수도, 대체하는 새 Slot 자체일 수도 있음
|
||||
destroySlotTree(old) -- 내부에서 unbindLifetime(inst, old) 호출(아래), 폐기·옮기지 않음
|
||||
slotOwner:SetWeak(old, nil)
|
||||
destroySlotTree(old) -- 재귀 파괴(자식 정리), 폐기·옮기지 않음 — slot 자신의 GC 앵커는 안 건드림(top-level 전용)
|
||||
unbindLifetime(inst, old) -- top-level 자신의 GC 앵커 해제 — attachSlot의 top-level bindLifetime과 짝
|
||||
releaseOwner(old, inst)
|
||||
kSlotMap:SetWeak(inst, k, nil)
|
||||
end
|
||||
-- old == v(같은 Slot 재발행) → 아무 것도 안 함, 곧 process도 owner==inst로 no-op
|
||||
|
|
@ -270,10 +306,44 @@ end
|
|||
```
|
||||
|
||||
`attachSlot` 자체가 quad-roblox 소속이라 `inst`를 아는 건 자연스러움 —
|
||||
`slotOwner`가 굳이 `inst`의 정체를 몰라도(예: 다른 백엔드에서 중간
|
||||
`elementOwner`가 굳이 `inst`의 정체를 몰라도(예: 다른 백엔드에서 중간
|
||||
표현 테이블이어도) 무관하게 동작함, 그냥 "지금 이 자리를 차지한 값이
|
||||
누구냐"만 구분하면 됨.
|
||||
|
||||
### 요소 소유권 — `elementOwner`, nested `Add`/top-level Dispatch 공용 (2026-08-12 열여섯 번째 세션)
|
||||
|
||||
**문제(사용자 발견)**: 위 `elementOwner`가 승격되기 전엔 top-level
|
||||
Dispatch 마운트(`SlotHandler.process`)만 `slotOwner`를 봤고, `Add`가
|
||||
확인한다는 "개별 element 전역 weak-set"(구 "isMounted 이중 추적 분리"
|
||||
절)은 완전히 별개 레지스트리로 서술만 있고 코드가 없었음 — 그래서
|
||||
`slot1`을 top-level store-bind하면 `slotOwner`만 찍히고, 그 다음
|
||||
`otherSlot:Add(slot1)`을 하면 `Add`가 보는 레지스트리엔 아무것도 없어
|
||||
그대로 통과 → 같은 `slot1`이 두 군데 물리적으로 마운트되는데 에러가
|
||||
안 남(반대 순서도 동일하게 뚫림) — "핵심 제약: 소유권 귀속과 단일
|
||||
마운트" 절의 라이브러리 전역 불변식이 실제로는 안 지켜지던 gap.
|
||||
|
||||
**해법**: 위에서 승격한 `elementOwner`/`claimOwner`/`releaseOwner`를
|
||||
Slot뿐 아니라 **모든 마운트 가능 element(plain Instance 포함)**의
|
||||
소유권 판정에 공용으로 씀 — top-level(`SlotHandler.process`/`.retract`)과
|
||||
nested(`rawAdd`/`rawRemove`/`rawExtract`)가 정확히 같은 함수, 같은
|
||||
`Relate`를 호출하므로 어느 경로로 먼저 클레임하든 다른 경로가 반드시
|
||||
봄:
|
||||
|
||||
```lua
|
||||
-- rawAdd(self, element, index) 안, "이미 마운트" 에러 체크 자리
|
||||
claimOwner(element, self) -- self = 담는 Slot. 이미 다른 곳 소유면 여기서 error
|
||||
|
||||
-- rawRemove(self, index)/rawExtract 안, 요소를 내보내는 자리
|
||||
releaseOwner(element, self)
|
||||
```
|
||||
|
||||
`ownerKey`가 `inst`(top-level)든 `Slot`(nested)든 `elementOwner`는
|
||||
타입을 신경 안 써서 하나의 레지스트리로 충분 — `outerSlot`이 값으로
|
||||
들어가도 `elementOwner` 자체는 아무것도 강하게 안 붙잡고(전부
|
||||
`SetWeak`), 실제 강한 참조는 `outerSlot._elements`(plain array, `Relate`
|
||||
아님)가 이미 쥐고 있으므로 `relate-plan.md`가 경고하는 "두 Relate
|
||||
상호 강참조" 패턴과 다른 모양 — GC 문제 없음.
|
||||
|
||||
- **같은 바인딩이면 완전히 무시하는 게 이 자리에선 효율 문제가 아니라
|
||||
정합성 문제** — Slot은 아래 "확정" 절대로 "폐기, 옮기지 않음"(portal
|
||||
없음)이 이미 정책으로 확정돼 있어서, 이 no-op 가드가 없으면 **재귀 재
|
||||
|
|
@ -859,7 +929,7 @@ function activateList(self, inst)
|
|||
-- .Length만큼 건너뛴다 — 다음 형제의 index가 이 아이템이
|
||||
-- 실제로 차지하는 물리적 개수를 반영해야 함(아래 "index도
|
||||
-- nested-Slot 결과의 Length만큼 건너뛰어야 함" 절 참고)
|
||||
pos = candidateIndex - 1 + (isSlot(result) and result.Length:Get() or 1)
|
||||
pos = candidateIndex - 1 + (if isSlot(result) then result.Length:Get() else 1)
|
||||
end
|
||||
|
||||
if result ~= prev then
|
||||
|
|
@ -1090,9 +1160,9 @@ local function identityUpdateFn(item) return item end
|
|||
function Slot:Single(state, updateFn)
|
||||
updateFn = updateFn or identityUpdateFn -- [2026-08-11 일곱 번째 세션] 기본값 추가
|
||||
|
||||
local data = isState(state)
|
||||
and state:Compute(function(v) return v:Get() == nil and {} or { v:Get() } end)
|
||||
or (state == nil and {} or { state })
|
||||
local data = if isState(state)
|
||||
then state:Compute(function(v) return (if v:Get() == nil then {} else { v:Get() }) end)
|
||||
else (if state == nil then {} else { state })
|
||||
|
||||
return self:List(data, function(item, index, offset, prev, ud)
|
||||
return updateFn(item, offset, prev, ud) -- index는 항상 상수라 안 넘김
|
||||
|
|
@ -1151,10 +1221,19 @@ weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위
|
|||
local function attachSlot(slot, physicalTarget, ownerKey, position)
|
||||
slot._mounted = true
|
||||
slot._mountedInst = physicalTarget
|
||||
bindLifetime(physicalTarget, slot) -- [2026-08-12 열세 번째 세션 추가] Slot 자신의
|
||||
-- GC 앵커 — 이게 빠져있으면 아무도 slot을 강하게
|
||||
-- 안 붙잡아 조기 GC될 수 있음(위 "Slot과 Store
|
||||
-- 바인드의 관계" 절 GC 주의 참고)
|
||||
if ownerKey == physicalTarget then
|
||||
-- [2026-08-12 열여섯 번째 세션, 스코프 정정] bindLifetime은 최상위(물리
|
||||
-- inst에 직접 연동하는 말단)에서만 — 중첩 Slot은 자신을 담는 outer의
|
||||
-- `_elements`(plain strong array)로 이미 transitively 살아있어서
|
||||
-- (elementOwner는 전부 weak라 별도 anchor 아님, 위 "요소 소유권 —
|
||||
-- `elementOwner`" 절 참고) 여기서 또 anchor할 이유가 없음 — State의 노드 연결처럼
|
||||
-- 중간 노드는 구조로만 연결되고, 말단만 실제 엔진 생명주기에 건다는
|
||||
-- 원칙과 같은 결. 매 레벨 anchor하면 매 레벨 파괴 시 짝을 맞춰
|
||||
-- unbindLifetime해야 하는 부담만 늘어남(destroySlotTree 참고).
|
||||
bindLifetime(physicalTarget, slot) -- [2026-08-12 열세 번째 세션 추가] 최상위
|
||||
-- Slot의 GC 앵커 — 이게 빠져있으면 아무도
|
||||
-- 강하게 안 붙잡아 조기 GC될 수 있음
|
||||
end
|
||||
|
||||
Dispatch.setLength(ownerKey, position, slot.Length) -- slot.Length는 State<number>, 기존 로직 그대로
|
||||
local offsetSource = Source(0)
|
||||
|
|
@ -1222,10 +1301,16 @@ local function destroySlotTree(slot)
|
|||
unbindLifetime(slot._mountedInst, observer)
|
||||
end
|
||||
end
|
||||
unbindLifetime(slot._mountedInst, slot) -- [2026-08-12 열세 번째 세션 추가]
|
||||
-- attachSlot의 bindLifetime과 짝
|
||||
-- [2026-08-12 열여섯 번째 세션, 스코프 정정] slot 자신의 unbindLifetime은
|
||||
-- 여기서 안 부름 — attachSlot이 최상위에서만 bindLifetime하므로 짝도
|
||||
-- 최상위 파괴 지점(SlotHandler.retract, 아래)에서만 한 번. destroySlotTree는
|
||||
-- 재귀 전체에서 항상 이 위치까지만(자식 Observer 정리) 담당.
|
||||
end
|
||||
|
||||
-- [명확화] 아래 시그니처는 index 기준 예시 — 위 "raw* 내부 호출 규약" 절이
|
||||
-- 이미 못박았듯 reconcile(위 "여러 Slot이 섞일 때" 절 근처)은 element 기준으로
|
||||
-- rawRemove(self, prev)를 부름. 둘 중 하나로 통일할지 얇은 변환 계층을 둘지는
|
||||
-- 아직 M6 구현 세부로 열려 있음 — 이 블록은 그 결정 전 illustrative 예시.
|
||||
function rawRemove(self, index)
|
||||
local element = self._elements[index]
|
||||
local bk = getBookkeeping(self)
|
||||
|
|
@ -1349,7 +1434,7 @@ sugar가 성립하려면 `:Single(state)`(updateFn 생략)이 유효해야 함
|
|||
(`:List`의 reconcile은 `key` 기준으로 독립 동작, `sub`가 바깥에서
|
||||
어느 인덱스에 있든 상관 안 함).
|
||||
- **`State<T?>`(nilable)도 특별 취급 없이 그냥 됨** — `:Single`이 이미
|
||||
`v:Get() == nil and {} or {v:Get()}`로 nil을 "빈 리스트"로 흡수하므로, raw 직접
|
||||
`if v:Get() == nil then {} else {v:Get()}`로 nil을 "빈 리스트"로 흡수하므로, raw 직접
|
||||
전달 요소(`Add(element)`, State로 안 감싼 경우)에만 여전히 non-nil이
|
||||
요구되고, `State`/`Source`로 감싼 값은 내부적으로 nilable이어도 아무
|
||||
문제 없음 — 위 "요소 타입 제약" 절의 nil/None 금지는 **State/Source로
|
||||
|
|
@ -1403,7 +1488,7 @@ Slot-in-Slot으로 `T = Instance | Slot<Instance>`가 허용되면서, `:List`
|
|||
Slot(Length=3)을 반환할 때 다음 아이템의 `index`가 3만큼 안 건너뛰고
|
||||
1만 건너뛰어 물리적으로 겹치는 LayoutOrder 범위가 나옴 — **의도된 동작으로
|
||||
확정, 위 "구현" 절의 `reconcile` 의사코드에 이미 반영됨**(`pos = candidateIndex
|
||||
- 1 + (isSlot(result) and result.Length:Get() or 1)`).
|
||||
- 1 + (if isSlot(result) then result.Length:Get() else 1)`).
|
||||
|
||||
**남는 캐비엇 — `index`는 여전히 raw 스냅샷이라, nested Slot의 Length가
|
||||
outer `:List`의 reconcile 없이 나중에 바뀌면 그 이후 형제들의 `index`는
|
||||
|
|
|
|||
|
|
@ -67,16 +67,20 @@ plain string이라(핸들러 계층 값처럼 identity/생명주기가 얽힌
|
|||
"하나의 Tag 값을 프로그래밍적으로 조립"하는 용도).
|
||||
|
||||
**동적 토글은 `Source`/`State`로, `None` 불필요** — 상호배타 상태 전환은
|
||||
`store.activeTag:Compute(function(name) return name:Get() == "btn1" and
|
||||
Tag("selected") or nil end)`처럼 그냥 `nil`을 리턴하면 됨. `None` 센티널은
|
||||
"정적 테이블 리터럴에서 `키 = nil`이 키 없음과 구별 안 되는" 문제의
|
||||
해법이지(`bind-system-plan.md` "`None` 센티널" 절), 이건 함수 리턴값이
|
||||
동적으로 흘러가는 경우라 그 문제 자체가 없음 — `nil`을 인자로 넘기는 건
|
||||
아무 문제 없음. (단, `Frame { cond and Tag("a") or nil, sibling }`처럼
|
||||
**정적 리터럴**에서 조건부로 Tag를 넣거나 빼고 싶은 경우엔 다른 array-part
|
||||
값들과 마찬가지로 `cond and Tag("a") or None` 관용구가 여전히 유효 —
|
||||
이건 nil-hole 문제라 Tag만의 특수 규칙이 아니라 `props.Modifier`/
|
||||
`props.Ref`와 같은 일반 array-part 관용구.)
|
||||
`store.activeTag:Compute(function(name) return (if name:Get() == "btn1"
|
||||
then Tag("selected") else nil) end)`처럼 그냥 `nil`을 리턴하면 됨. `None`
|
||||
센티널은 "정적 테이블 리터럴에서 `키 = nil`이 키 없음과 구별 안 되는"
|
||||
문제의 해법이지(`bind-system-plan.md` "`None` 센티널" 절), 이건 함수
|
||||
리턴값이 동적으로 흘러가는 경우라 그 문제 자체가 없음 — `nil`을 인자로
|
||||
넘기는 건 아무 문제 없음. (단, `Frame { (if cond then Tag("a") else
|
||||
nil), sibling }`처럼 **정적 리터럴**에서 조건부로 Tag를 넣거나 빼고
|
||||
싶은 경우엔 다른 array-part 값들과 마찬가지로 `(if cond then Tag("a")
|
||||
else None)` 관용구가 여전히 유효 — 이건 nil-hole 문제라 Tag만의 특수
|
||||
규칙이 아니라 `props.Modifier`/`props.Ref`와 같은 일반 array-part
|
||||
관용구. **[2026-08-12 세션 후속]** 예전엔 `cond and Tag("a") or
|
||||
nil`/`or None`(and/or 삼항)으로 적었으나, `Tag(...)`가 항상-truthy라
|
||||
당장은 안전해도 `if-then-else` 전면 금지 규칙(`base/architecture.md`
|
||||
"코드 스타일" 절)에 예외를 안 두기로 하며 여기도 통일.)
|
||||
|
||||
## 메커니즘 — `TagHandler`, `retract`가 이제 의미 있어짐
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Tween / 애니메이션 플러깅 (전부 확정 — `base/`로 승격)
|
||||
|
||||
**상태**: research — **2026-08-10 세션에서 구조 전체가 재설계됨.** 기존
|
||||
**상태**: base — **2026-08-10 세션에서 구조 전체가 재설계됨.** 기존
|
||||
"`v`가 Store인 아무 `k`나 잡는 우선순위 최상위 Dispatch 핸들러" 모델은
|
||||
`research/pre-implementation-audit.md` 1-1이 지적한 구조적 모호함("애니메이션
|
||||
없는 일반 반응형 프로퍼티 바인딩도 결국 이름이 Tween인 파일을 거쳐가는가")을
|
||||
|
|
|
|||
|
|
@ -112,8 +112,8 @@ v1에서도 `Corner`/`PaddingAll`/`Scale`은 store 값으로 바인드 가능했
|
|||
"이전에 자기가 찾거나 만든 자식 Instance"를 얻어야 하는데, 이건 이미
|
||||
base가 범용 유틸로 제공하기로 확정한 per-instance weak-keyed 저장소
|
||||
(`Relate:SetStrong(inst,k,...)`, `base/relate-plan.md`/`base/bind-system-plan.md`
|
||||
"핸들러 내부 상태 저장" 절)를 그대로 재사용하면 됨 — Tween 핸들러가 실행 중인 Tween
|
||||
객체를 기억해두는 것과 정확히 같은 패턴. 새 메커니즘 발명 불필요, 이미
|
||||
"핸들러 내부 상태 저장" 절)를 그대로 재사용하면 됨 — PropertyHandler가 실행 중인
|
||||
Tween 상태를 기억해두는 것과 정확히 같은 패턴. 새 메커니즘 발명 불필요, 이미
|
||||
있는 "store 바인드는 pluggable 바인드를 재실행하는 래핑" 원칙
|
||||
(`base/bind-system-plan.md` "확정된 디스패치 모델" 절)이 그대로 적용됨.
|
||||
|
||||
|
|
@ -124,7 +124,7 @@ base가 범용 유틸로 제공하기로 확정한 per-instance weak-keyed 저
|
|||
넣어도 충분하다, opt-out할 이유가 별로 없다"는 게 사용자 판단 — **작고
|
||||
항상 켜져 있어도 비용이 무시할 만한 편의 기능은 별도 opt-out 패키지로
|
||||
쪼개지 말고 `quad-roblox` 코어에 직접 포함한다**는 원칙으로 확정(이미
|
||||
계획된 Tween 핸들러가 같은 모양이라는 게 근거). 이 원칙은 일반화해서
|
||||
계획된 Tween 처리 로직이 같은 모양이라는 게 근거). 이 원칙은 일반화해서
|
||||
재사용 가능 — 앞으로 비슷한 "작은 인스턴스 편의 기능"이 제안되면
|
||||
`quad-roblox-util` 같은 걸 새로 만들지 않고 이 선례를 따르면 됨.
|
||||
|
||||
|
|
|
|||
|
|
@ -195,8 +195,14 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만**
|
|||
있던 것을 이번에 `pre-implementation-audit.md` 2-11에도 해소 표시로
|
||||
동기화. **[해소됨, 2026-08-09 세 번째 세션]** Slot CRUD 의미론
|
||||
(`add`/`remove`/`clear`) 미정의(1-7)/`isMounted` 이중 추적 혼용(1-8) —
|
||||
`base/slot-plan.md` 참고. **아직 실제로 열려있는 건 하나** — 우선순위
|
||||
스캔 동률/매치실패 처리(1-3) — `pre-implementation-audit.md` 본문 참고.
|
||||
`base/slot-plan.md` 참고. **[2026-08-12 감사 세션 정정] 아직 실제로
|
||||
열려있는 건 하나가 아니라 넷** — `pre-implementation-audit.md` 원문에
|
||||
`[해소됨]` 마커가 없는 항목이 1-3 외에도 셋 더 있음: 우선순위 스캔
|
||||
동률/매치실패 처리(1-3), provider 미주입 상태 dispatch 처리(1-4),
|
||||
`store.key`의 레코드 필드 타이핑을 M0로 앞당길지(1-10), Modifier
|
||||
`__index`+`table.clone` 트릭 검증(1-11) — 이전 요약이 이 셋을 누락한
|
||||
채로 "1-3 하나뿐"이라 잘못 축약해뒀던 것, `pre-implementation-audit.md`
|
||||
본문 및 "다음 액션 제안" 절 참고.
|
||||
- **[해소됨, 2026-08-08 두 번째 세션]** `Frame { ref }`/`Frame { observer }`처럼
|
||||
children 배열 숫자 슬롯에 직접 놓는 leaf 값을 매칭·바인드하는 Handler
|
||||
(`(i:number, v=Ref/Observer/PreRef)`)의 패키지 배치 — 원래 제안대로
|
||||
|
|
|
|||
|
|
@ -209,6 +209,30 @@ introspection 로직을 추가하는 거라 라이브러리 복잡도가 늘어
|
|||
큼. Slot을 거치지 않는 순수 `State<table> -> State<table>` 값 변환
|
||||
용도라면 문제없이 들어갈 수 있음 — 실사용 사례가 나오면 재검토.
|
||||
|
||||
## 열린 질문 — Attribute 그룹 명시적 unset 유틸 (2026-08-12 세션 후속, 신설)
|
||||
|
||||
**배경**: `base/attribute-plan.md`가 "그룹에서 이름이 사라져도(diff든
|
||||
통째 언마운트든) 프레임워크가 자동으로 `SetAttribute(name,nil)`을
|
||||
안 해준다 — 지울 거면 명시적으로 `None`을 써라"로 확정됨(사용자 결정,
|
||||
Ref의 "Destroy 무관, 정리 필요하면 Effect" 철학과 통일). 이유는 diff로
|
||||
조용히 빠지는 것과 통째 소멸을 다르게 취급하면 오히려 모호해지고,
|
||||
Attribute는 이미 "겹치면 error"로 소유 코드가 명확히 갈리는 설계라
|
||||
프레임워크가 대신 판단할 근거가 불투명하기 때문(상세는 해당 문서
|
||||
"그룹 `Attribute(...)`" 절).
|
||||
|
||||
**아이디어(사용자 제시, 착수 안 함)**: 그래도 자동 unset이 갖고 싶으면,
|
||||
`Animate`와 같은 모양의 `:Apply` 팩토리로 **명시적 opt-in 유틸**을 나중에
|
||||
추가하면 됨 — `State<data> -> State<Attribute>`를 만들면서 내부적으로
|
||||
이전 이름 집합과 비교해 사라진 이름을 자동으로 `None`으로 채워 넣어주는
|
||||
콤비네이터. 이 카탈로그의 다른 항목들(`Sum`/`Concat`/`Sorted`)과 같은
|
||||
성격 — 순수 슈가, 없어도 기능 격차 없음, 사용자가 명시적으로 골라야만
|
||||
동작(자동/암묵적이 아님이 핵심 — 그래야 "프레임워크가 대신 판단"이라는
|
||||
모호함 문제 자체가 안 생김).
|
||||
|
||||
**우선순위**: 이 문서 전체와 동급으로 맨 마지막, 실사용 사례가 나오면
|
||||
재검토. 지금은 이름도 모양도 구체화 안 함 — 착수 시점에 이 문서의
|
||||
`Animate`/`Sum` 패턴을 그대로 참고.
|
||||
|
||||
## 우선순위
|
||||
|
||||
**맨 마지막.** 없어도 quad는 기능상 완전하고, 함수 간 의존이 없어 나중에
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
# 2026-08-12 열여섯 번째 세션 — 코퍼스 전체 감사, Attribute retract 전면 재설계, Slot 소유권 일반화
|
||||
|
||||
## 배경
|
||||
|
||||
사용자가 "문서 전체에서 가정이 틀렸는데 다른 문서에서는 이미 정정된
|
||||
사실이 있음에도 잘못된 추론이 그대로 남아있는 곳이 있는지" 감사를 요청.
|
||||
이전 세션들(특히 2026-08-12 열한~열다섯 번째, retract-always-fires
|
||||
정정과 그 파생 GC 이슈 시리즈)에서 큰 변경이 여러 차례 있었기 때문 —
|
||||
"정정됨" 표시만 붙고 원 문장이 그대로 방치되는 패턴이 반복된다는 CLAUDE.md
|
||||
자기 경고가 이번에도 실제로 맞아떨어짐.
|
||||
|
||||
## 1부 — 코퍼스 전체 감사 (7개 에이전트 병렬)
|
||||
|
||||
`base/` 14개 파일 + `research/`/`question.md`/`README.md`를 7개
|
||||
서브에이전트로 나눠 정독·대조. 발견해서 그 자리에서 고친 것:
|
||||
|
||||
- **`bind-system-plan.md`**: "Tag는 retract가 무조건 전체 삭제"라던
|
||||
옛 서술 2곳이 세션11 정정(참조 카운트 기반) 이후에도 안 고쳐진 채
|
||||
남아있었음 — 수정.
|
||||
- **`attribute-plan.md`**: "retract 불필요" 헤더가 바로 아래 정정된 코드
|
||||
주석과 모순 — 수정(이후 2부에서 이 절 전체가 다시 크게 재작성됨).
|
||||
- **`slot-plan.md`**: `slotOwner:GetWeak/SetWeak` 호출이 `Relate` 공식
|
||||
API(항상 3-인자)와 인자 개수가 안 맞는 실제 버그 — sentinel key 추가로
|
||||
수정. "재마운트는 즉시 throw" 절이 나중에 확정된 "같은 위치 재-emit은
|
||||
no-op" 예외를 반영 안 함 — 교차참조 추가. `rawRemove`가 element
|
||||
기준/index 기준 두 가지로 같은 파일에 리터럴로 존재 — M6 미확정임을
|
||||
명시하는 주석 추가(결정 자체는 안 내림).
|
||||
- **Tween 승격(research→base) 반영 누락**: `tween-plan.md` 상태 필드가
|
||||
여전히 "research", `architecture.md` 2곳("Tween은 여전히 research/에
|
||||
있음"), `ui-shorthand-plan.md`의 폐기된 "Tween 핸들러" 용어 — 전부 수정.
|
||||
- **`question.md`/`CLAUDE.md`의 "지금 할 일" 1번**: `pre-implementation-audit.md`
|
||||
우선순위1 중 열려있는 게 "1개(1-3)뿐"이라던 요약이 실제로는 4개(1-3/
|
||||
1-4/1-10/1-11, 원문에 `[해소됨]` 마커 없음)였음 — 개수/목록만 정정,
|
||||
실제 해소는 사용자가 "나중으로 연기" 선택.
|
||||
|
||||
## 2부 — Attribute retract 전면 재설계 (사용자 주도 다단계 정정)
|
||||
|
||||
diff를 직접 보던 사용자가 "AttributeHandler가 retract 없는 게 말이 되나"
|
||||
질문에서 시작해 여러 라운드에 걸쳐 최종 설계에 도달:
|
||||
|
||||
1. **1차**: 그룹 자신의 `(inst,index)` 슬롯도 process/retract 코드가
|
||||
없다는 gap 발견 — `AttributeGroupHandler.process`/`.retract` 스케치.
|
||||
2. **2차 (사용자 catch)**: `retract`의 `v` 타입을 안 보고 `v:NameMap()`을
|
||||
호출하는 게 버그 — `TagHandler.retract`의 `isTag(newv)` 가드 선례와
|
||||
대조해 확인. `Ref`/`Slot`/`AttributeKey`의 기존 retract는 전부
|
||||
identity/nil 비교뿐이라 애초에 이 문제가 없었음(내용을 봐야 하는 건
|
||||
Tag/Attribute뿐).
|
||||
3. **3차 (사용자 catch)**: `AttributeKeyHandler.retract`가 `v==nil`이면
|
||||
`SetAttribute(name,nil)`을 직접 부르는 게 깜빡임(`a→nil→b`) 위험 —
|
||||
"일반 프로퍼티와 동일하게 취급"이라는 기존 원칙대로 retract를 완전
|
||||
no-op으로, 지우는 건 오직 `process(inst,k,nil)`(None/nil 명시)로만
|
||||
통일.
|
||||
4. **4차 (사용자 결정, 가장 큰 정정)**: 그룹이 "사라진 이름"을 자동으로
|
||||
`SetAttribute(nil)` 해주는 것 자체를 기각 — **Attribute는 오직
|
||||
명시적 `None`/`nil`로만 지워진다.** 그룹 바인딩이 통째로 사라져도
|
||||
(컴포넌트 언마운트 등) 자동 청소 없음. 이유: diff로 조용히 빠지는
|
||||
이름과 통째 소멸을 다르게 취급하면 오히려 모호해지고, Attribute는
|
||||
이미 "겹치면 error"로 소유 코드가 명확히 갈리는 설계라 프레임워크가
|
||||
대신 판단할 근거가 불투명함. `Ref`의 "Destroy 무관, 정리는 Effect로"
|
||||
철학과 통일. 이 결정으로 기존 "Tag와 동일하게 확실히 청소" 초안(line
|
||||
308-313)이 뒤집힘.
|
||||
5. **5차**: (4차와 별개 문제) 값을 안 지워도 사라진 이름의 *구독*은
|
||||
끊어야 함 — 안 그러면 그룹이 더 이상 안 쓰는 Source를 향한 StoreBind
|
||||
구독이 인스턴스가 살아있는 동안 계속 살아남아 리소스 누수. `retract`가
|
||||
`Dispatch.retractUnder`만 부르고(`process`는 안 부름 — retract 안에서
|
||||
process 호출은 UB, 아래 참고) 구독만 끊는 걸로 확정 — `SetAttribute`는
|
||||
여기서도 절대 안 일어남.
|
||||
|
||||
**부수적으로 확정된 일반 규칙 2개**(`bind-system-plan.md`에 추가):
|
||||
- retract의 `v`는 타입 보장 안 됨 — 내용을 보려면 `isX(v)` 가드 필수.
|
||||
- retract 안에서 `Dispatch.process` 호출은 UB(`retractUnder`의 체인
|
||||
추적이 꼬임) — retract는 구조적 팝/자원 해제만, 새 등록 트리거 금지.
|
||||
`Dispatch.retractUnder`를 (다른 키에 대해) retract 안에서 부르는 건
|
||||
문제없음.
|
||||
|
||||
**백로그**: 그래도 자동 unset이 필요해지면 `Animate`와 같은 모양의
|
||||
`:Apply` opt-in 유틸(이전 이름 집합과 비교해 사라진 이름을 `None`으로
|
||||
채워주는 콤비네이터)을 나중에 추가 가능 — `research/operator-sugar-plan.md`에
|
||||
"Attribute 그룹 명시적 unset 유틸" 절로 신설, 착수 안 함.
|
||||
|
||||
## 3부 — Slot 소유권 일반화, bindLifetime 스코프 정정
|
||||
|
||||
같은 흐름에서 Slot도 감사:
|
||||
|
||||
- **소유권 레지스트리 이중화 gap(사용자 발견)**: top-level Dispatch
|
||||
마운트(`slotOwner`)와 nested `Add`(문서에만 있고 코드 없던 "전역
|
||||
weak-set")가 서로 다른 레지스트리라, 한쪽으로 먼저 마운트한 걸 다른
|
||||
쪽이 못 보고 이중 마운트를 허용하는 실제 gap이 있었음. `slotOwner`를
|
||||
`elementOwner`(Slot이든 plain Instance든 공용)로 승격, `claimOwner`/
|
||||
`releaseOwner`를 top-level(`SlotHandler`)과 nested(`rawAdd`/`rawRemove`/
|
||||
`rawExtract`)가 동일하게 호출하도록 통합.
|
||||
- **bindLifetime 스코프 정정(사용자 판단)**: `attachSlot`이 재귀 모든
|
||||
레벨에서 `bindLifetime`을 부르던 것을 **top-level에서만**으로 축소 —
|
||||
nested Slot은 담는 outer의 `_elements`(plain strong array)로 이미
|
||||
transitively 살아있어서 별도 anchor가 불필요, State의 노드 연결처럼
|
||||
말단만 실제 엔진 생명주기에 건다는 원칙과 일치. `destroySlotTree`의
|
||||
자기-unbind도 대칭으로 제거, top-level 파괴 지점(`SlotHandler.retract`)
|
||||
에서만 짝 맞춤.
|
||||
- **GC 안전성 확인**: `slot._mountedInst = physicalTarget`은
|
||||
`relate-plan.md`가 이미 안전하다고 확정한 "값이 자기 키를 다시
|
||||
참조하는" 단일-Relate 자기참조 패턴(`Ref.Value=inst`와 동일 부류)이라
|
||||
안전 — `relate-plan.md`에 확인 사례로 추가.
|
||||
|
||||
## 4부 — `and`/`or` 삼항 관용구 전면 금지
|
||||
|
||||
사용자가 성능(진짜 short-circuit이라 매 단계 truthiness 테스트, `if-then-else`는
|
||||
단일 분기)과 안전성(가운데 값 falsy 시 새는 구조적 결함) 둘 다 근거로
|
||||
"쓸 수 있는 곳은 전부 `if-then-else`"를 요청 — 기존에 "가운데 값이
|
||||
항상-truthy면 예외적으로 and/or 허용"이라던 예외 조항을 폐기하고, 코드로
|
||||
실존하던 6곳(`bind-system-plan.md`/`slot-plan.md`/`tag-plan.md`)을 전부
|
||||
`if-then-else`로 교체. `architecture.md`/`bind-system-plan.md`의 규칙
|
||||
설명도 같이 강화. 단순 2항 `A or B` fallback(`props.Modifier or None`류)은
|
||||
애초에 이 문제가 없어 그대로 유지.
|
||||
|
||||
## 반영 완료 확인
|
||||
|
||||
`base/attribute-plan.md`(221줄 변경), `base/slot-plan.md`(147줄),
|
||||
`base/bind-system-plan.md`(67줄), `base/architecture.md`(22줄),
|
||||
`base/tag-plan.md`(24줄), `base/relate-plan.md`, `base/tween-plan.md`,
|
||||
`base/ui-shorthand-plan.md`, `research/operator-sugar-plan.md`,
|
||||
`.claude/question.md`, 루트 `CLAUDE.md` 전부 이 세션 안에서 갱신 완료.
|
||||
열린 설계 질문 없음 — Attribute/Slot 소유권 메커니즘 둘 다 최종 확정.
|
||||
27
CLAUDE.md
27
CLAUDE.md
|
|
@ -119,8 +119,12 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
|
|||
없으면 그대로 M0 실제 코드 작성에 재사용.
|
||||
- **`research/pre-implementation-audit.md` 우선순위1** — 신설 당시
|
||||
11개였으나 대부분 이후 세션에서 해소됨(Tween/Tag 재설계, Slot CRUD,
|
||||
`canExecute` 시그니처, `LifetimeHandle` 로드맵 순서 등). 실제로 아직
|
||||
열려있는 건 "우선순위 스캔 동률/매치실패 처리"(1-3) 하나뿐 —
|
||||
`canExecute` 시그니처, `LifetimeHandle` 로드맵 순서 등). **[2026-08-12
|
||||
감사 세션 정정]** 실제로 아직 열려있는 건 넷(원문에 `[해소됨]` 마커가
|
||||
없는 항목 기준) — 우선순위 스캔 동률/매치실패 처리(1-3), provider
|
||||
미주입 상태 dispatch 처리(1-4), `store.key` 레코드 필드 타이핑을 M0로
|
||||
앞당길지(1-10), Modifier `__index`+`table.clone` 트릭 검증(1-11).
|
||||
이전엔 "1-3 하나뿐"이라 잘못 축약돼 있었음(1-4/1-10/1-11 누락) —
|
||||
`.claude/question.md` 2번이 최신 상태.
|
||||
2. **용어 정리 — 1차 제안 이후 대부분 확정, 소수만 남음.** 최신 소스는
|
||||
`.claude/question.md` 1번(개수 반복 안 함, 항목 추가/해소될 때마다 여기가
|
||||
|
|
@ -645,3 +649,22 @@ vararg 유지(요소 개수가 대개 소수로 고정, 동적이면 Slot-in-Slo
|
|||
가능, `T|{T}`는 `Slot<T>`가 base 레벨에선 `T`가 뭔지 모르는 제네릭이라
|
||||
바깥 `{}`가 단일 T인지 배열인지 원천적으로 판별 불가능해서 오히려
|
||||
모호해짐 — `Slot`의 T에 우연히 Slot이 섞여서가 아님, `slot-plan.md`).
|
||||
|
||||
**2026-08-12 열여섯 번째 세션 — 코퍼스 전체 감사, Attribute retract 전면
|
||||
재설계, Slot 소유권 일반화** (`session/2026-08-12-16-corpus-audit-attribute-retract-slot-owner.md`)
|
||||
7개 에이전트로 `.claude/` 코퍼스 전체를 감사해 stale 서술 다수 정정
|
||||
(retract-always-fires 정정 전파 누락, Tween research→base 승격 반영
|
||||
누락, `Relate` API 인자 개수 버그, `pre-implementation-audit.md` 열린
|
||||
항목 개수 오류 등). 이어서 사용자가 diff를 직접 검토하며 Attribute
|
||||
`retract`를 다단계로 재설계 — **최종: retract는 완전 no-op(SetAttribute는
|
||||
오직 `process(inst,k,nil)`), Attribute는 오직 명시적 `None`/`nil`로만
|
||||
지워짐(그룹이 사라진 이름을 자동으로 안 지워줌 — Ref의 "Destroy 무관"
|
||||
철학과 통일), 단 사라진 이름의 *구독*은 끊음(값은 안 지워도 리소스는
|
||||
안 새게)**. 일반 규칙 2개 신설(retract의 `v` 타입 미보장 → `isX(v)`
|
||||
가드 필수, retract 안에서 `process` 호출은 UB). Slot도 `slotOwner`를
|
||||
`elementOwner`로 일반화해 top-level/nested 이중 마운트 gap 폐쇄,
|
||||
`bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로
|
||||
transitively 생존). `and`/`or` 삼항 관용구 전면 금지(기존 "항상-truthy면
|
||||
예외" 조항 폐기), 코퍼스 전체 실제 코드 6곳을 `if-then-else`로 교체.
|
||||
Attribute 자동 unset이 필요해지면 쓸 `:Apply` opt-in 유틸을
|
||||
`research/operator-sugar-plan.md`에 백로그로 추가(착수 안 함).
|
||||
|
|
|
|||
Loading…
Reference in a new issue