quad/.claude/research/operator-sugar-plan.md
qwreey 9909aca227
decide(operator): add Operator sugar plan, unify combinators on :Apply
Sketch Sum/Not/Product-style :Compute/:Apply sugar (research/operator-sugar-plan.md,
implementation deferred). Reusable curried combinators (Sum, Animate) must
go through :Apply, not :Compute — quad rejected implicit auto-tracking, so
a factory's closed-over deps only register if the factory re-declares them
via self:Compute(...) internally; plugging a pre-built factory straight
into :Compute silently drops reactivity. Flip Animate's call site
accordingly in tween-plan.md, add the convention to bind-system-plan.md's
:Apply section, and fix a stale two-arg Animate signature comment in
architecture.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 11:54:09 +09:00

12 KiB

Operator 콤비네이터 슈가 — Sum/Product/Not/비트연산 등

상태: research — 사용자 요청(2026-08-12 세션)으로 신설, 같은 세션 후속 논의에서 :Apply 경유로 확정(아래 "왜 :Apply인가" 절). 구현은 맨 마지막으로 미룸(사용자 본인이 명시): 순수 슈가라 없어도 quad 기능상 문제없고, 각 함수가 서로 의존이 없어 나중에 통째로 추가하거나 개별 함수를 지우고 고쳐도 안전 — 우선순위가 낮은 이유. 지금 이 문서를 쓰는 목적은 구현 착수가 아니라 설계/네이밍 논의를 미리 남겨두는 것뿐.

동기

:Compute/:Applyfn(self, ...) 람다를 요구하는데, not self:Get()처럼 정말 단순한 연산에도 매번 function(self) return not self:Get() end를 쓰는 건 번거롭고, 같은 패턴이 코드 여기저기 반복되면 가독성도 떨어짐. 기본 연산(산술/논리/비트)을 미리 만들어둔 콤비네이터로 표현하면 보기도 간결해지고 유지보수도 쉬워짐 — 예:

-- 지금(람다 매번 작성)
reduceMotion:Compute(function(self) return not self:Get() end)

-- 슈가로
reduceMotion:Apply(Operator.Not)  -- 네임스페이스 이름은 미정, 아래 참고

메커니즘 — 새 프리미티브 아님, :Apply(factory) 위의 순수 함수

모든 Operator.*는 항상 factory(self) -> State<U> 모양이고 항상 :Apply로 붙인다 — 인자 없는 것(Not)과 커링되는 것(Sum(a,b,c))을 구분하지 않고 하나의 규칙으로 통일(아래 "왜 :Apply인가" 절 근거). 내부적으로는 self:Compute(...)를 호출해 실제 반응형 노드를 만드는 것뿐 — 새 State/Handler 카테고리 불필요.

-- 0항(자기 자신만 변환) — 그 자체로 이미 factory(self) 모양
Operator.Not = function(self)
    return self:Compute(function(h)
        return not h:Get()
    end)
end

-- 사용
reduceMotion:Apply(Operator.Not)
-- N항(self + 다른 state들을 결합) — 커링: Sum(a,b,...)가 factory를 반환
function Operator.Sum(...: State<number>)
    local deps = {...}
    return function(self: State<number>)
        return self:Compute(function(selfH, previous, ...)
            local total = selfH:Get()
            for _, h in {...} do
                total += h:Get()
            end
            return total
        end, table.unpack(deps))
    end
end

-- 사용 — 한 번 만들어 이름 붙여 재사용 가능
local addTaxAndShipping = Operator.Sum(tax, shipping)
price:Apply(addTaxAndShipping)

Product/And/Or/Xor/Band/Bor/Bxor/Bnot/Shl/Shr 등도 전부 같은 두 형태(0항은 그 자체가 factory(self), N항은 커링해서 factory(self)를 반환) — 어느 쪽이든 항상 :Apply로 붙임. 비트 연산은 Luau에 연산자가 없어 bit32 라이브러리 위에 얇게 얹는 형태가 됨.

:Apply인가 — 스타일이 아니라 정합성 문제 (2026-08-12 세션, 후속 논의)

처음엔 "0항은 :Compute에 바로, N항만 :Apply"로 나눠 썼으나 사용자가 일관성 문제로 재검토를 요청, 논의 중 실제 정합성 문제까지 발견되어 :Apply 통일로 확정.

  1. 재사용 가능한 커링 팩토리는 :Compute로는 안전하게 못 만든다 — 진짜 버그 가능성. quad는 Vide식 암묵적 자동 추적을 이미 기각했음 (bind-system-plan.md "암묵적 자동 추적 기각") — 의존성은 오직 :With/:Compute그 호출문 자체에 나열된 trailing args로만 등록됨. 그래서 local addTax = Sum(tax, shipping)처럼 한 번 만들어 재사용하고 싶은 값을 price:Compute(addTax)처럼 바로 꽂으면, addTax 내부에서 클로저로 캡처한 tax/shipping을 아무리 :Get() 해도 :Compute의 구독 목록엔 안 걸림tax/shipping이 바뀌어도 조용히 재계산이 안 일어나는 버그가 됨. 이걸 피하려면 price:Compute(addTax, tax, shipping)처럼 이미 Sum(...)에 넘긴 deps를 호출부에서 또 나열해야 하는데, 이게 바로 2026-08-11 세션에서 "trailing deps를 fn 위치 인자로 노출"하게 만든 그 중복/드리프트 위험(bind-system-plan.md 해당 절)과 완전히 같은 클래스의 문제 — 재사용 가능한 이름을 만드는 의미 자체가 없어짐. :Apply는 이 문제가 원천적으로 없음: factory가 내부에서 self:Compute(fn, tax, shipping)을 직접 호출해 자기가 캡처한 deps를 스스로 다시 넘기므로(호출자가 재입력하는 게 아니라 factory 자신이 한 번 캡처한 값을 그대로 전달), 중복 없이 안전하게 재사용됨 — price:Apply(addTax), otherPrice:Apply(addTax) 둘 다 안전.
  2. 기존 문서 관용구와 일치. bind-system-plan.md:Apply 절이 이미 state:Apply(makeFormatter("ko-KR"))를 "커링 팩토리 + :Apply"의 정석 예시로 들어둠 — Operator.*/Animate가 이 관용구를 따르는 게 자연스러움. Animate:Compute를 골랐던 건 오히려 이 기존 관용구에서 벗어난 예외였다는 게 이번 논의에서 드러남(research/ tween-plan.md "왜 :Apply인가로 정정" 절 참고).
  3. 일관성 — 0항/N항을 나누지 않음. Not은 deps가 없어서 위 1번 문제와 무관하지만, "이 라이브러리의 콤비네이터는 항상 :Apply로 붙인다"는 단일 규칙을 지키는 게 "0항만 예외적으로 :Compute에 바로 꽂는다"는 케이스 분기를 사용자가 매번 기억해야 하는 것보다 낫다는 게 사용자 판단. 비용은 Not이 내부적으로 self:Compute(...) 한 겹을 더 감싸는 것뿐 — 무시할 만한 오버헤드.
  4. 의미론도 더 맞음. "값에 연산자를 적용한다"는 게 "값으로부터 완전히 새로운 파생값을 계산한다"보다 더 정확한 표현 — :Compute가 v1/Fusion류 "매 스텝 능동적으로 값을 갱신"하는 것처럼 읽힐 수 있다는 우려도 사용자가 제기(오해일 뿐 실제 동작은 pull-recompute지만, 읽는 사람에게 주는 인상까지 고려).

:Compute의 역할 재확인: 이걸로 :Compute가 필요 없어지는 게 아니라, 역할이 명확해짐 — :Compute(fn, ...deps)그 자리에서 한 번 쓰고 마는 인라인 람다(deps도 그 호출문에 바로 나열) 전용 저수준 프리미티브로 남고, 이름 붙여 재사용하는 콤비네이터(라이브러리가 제공하는 것이든 사용자가 직접 만드는 것이든)는 전부 factory(self) 모양

  • :Apply로 통일. Operator.*/Animate의 내부 구현은 여전히 :Compute를 쓴다 — 사용자에게 노출되는 표면만 :Apply.

미래 고려사항 (보류) — 중첩 결합 Sum(a, b, Sum(c, d)) flatten 최적화

사용자 제기(2026-08-12 세션), 지금은 착수 안 함 — 실사용 사례가 나오면 재검토. Sum(a, b, Sum(c, d))처럼 콤비네이터를 중첩하는 것 자체는 지금 설계로도 이미 가능Sum(c, d)를 먼저 실제 self에 적용해 구체적인 State<number>로 만든 뒤(c:Apply(Operator.Sum(d))), 그 결과를 바깥 Sum의 평범한 operand로 넘기면 됨. 다만 이러면 안쪽 Sum(c,d)가 독립된 State 노드를 하나 더 만들어서(중첩 :Compute), 바깥 Suma+b+c+d를 한 번에 계산하는 것보다 그래프 레이어가 한 겹 더 생김.

사용자가 제안한 최적화 방향: Sum(...)이 리턴하는 클로저가 자기가 캡처한 operand 목록(local keep = {c, d})을 약한 릴레이션(Relate, base/relate-plan.md)으로 그 클로저 자신에 붙여두면, 나중에 다른 Sum 호출이 자신의 operand 중 하나가 "이미 Operator 콤비네이터가 만든 클로저"임을 감지해서 그 안에 보관된 operand들을 꺼내 자기 자신의 operand 목록에 합쳐 넣을 수 있음(Sum(a, b, Sum(c, d)) → 실질적으로 Sum(a, b, c, d)와 동일한 단일 :Compute 노드로 flatten) — 클로저가 GC되면 약한 릴레이션도 같이 사라지므로 메모리 누수 없음.

보류 이유: 순수 최적화(그래프 노드 한 겹 줄이기)일 뿐 기능 격차가 아님 — 지금도 위 방법으로 중첩 자체는 문제없이 됨. Sum류 생성 팩토리에 introspection 로직을 추가하는 거라 라이브러리 복잡도가 늘어남 — 사용자 본인이 "실제 사용사례를 보고 필요한지 나중에 검토"로 명시적으로 유보. 나중에 착수하게 되면 Sum/Product 등 각 생성 팩토리를 개별적으로 살짝 고치면 되는 수준이라, 지금 다른 설계에 영향 주지 않음.

패키지 배치

quad-base — Store/State 계층 위에서만 동작하는 순수 함수라 엔진 종속 없음(quad-roblox 아님). AnimateTween과 함께 어디에 배치됐는지와 같은 결로 맞추면 됨(research/tween-plan.md 참고, 단 Animate 자체는 Tween이 quad-roblox 개념(PropertyHandler)에 연결되므로 quad-roblox 배치 — Operator 슈가는 그런 엔진 종속이 없다는 점이 다름).

열린 질문 — 네임스페이스 이름 (미정)

Not/Sum/And/Or 같은 이름은 흔한 단어라 top-level에 그냥 두면 충돌 위험이 큼 — Tag/Attribute처럼 네임스페이스로 묶여야 함 (Operator.Not처럼). 문제는 짧으면서 "이 연산자 콤비네이터 슈가 모음"이라는 목적을 잘 담는 이름을 아직 못 찾음 — 사용자가 직접 이 문제를 제기(2026-08-12 세션). 코퍼스 전체에 Operator/Op 이름 충돌은 없음을 확인함(grep 결과 없음), 아래는 후보:

  • Operator — 의미는 제일 정확(산술/논리/비트 전부 "연산자"로 포괄). 다만 다소 길어서 Operator.Sum(a, b)처럼 매번 타이핑하기엔 무거울 수 있음.
  • Op — 짧지만 무엇의 축약인지 처음 보면 바로 안 와닿을 수 있음.
  • OpsOp의 복수형, 뉘앙스는 비슷.
  • Combinator — 코퍼스 전반에서 :Apply/Animate 같은 패턴을 설명할 때 이미 일반명사로 "콤비네이터"라는 말을 자주 써서(예: modifier-plan.md 8번 절), 네임스페이스 이름으로 쓰면 "이 특정 모듈"과 "패턴을 가리키는 일반 용어"가 헷갈릴 수 있어 후보에서 제외.

.claude/question.md 3번(낮은 우선순위)에도 반영. 용어 정리 라운드 (question.md 1번, Brand/Tag류)와 같은 카테고리로 나중에 같이 검토해도 됨 — 급하지 않음.

열린 질문 — 포함 범위

  • 산술(Sum/Product/Sub/Div?)·논리(Not/And/Or/Xor)·비트 (Band/Bor/Bxor/Bnot/Shl/Shr)까지는 비교적 명확한데, 비교 연산자(Eq/Lt/Gt/Lte/Gte)까지 포함할지는 미정 — 포함해도 같은 패턴(커링 팩토리 + :Apply)으로 자연스럽게 들어감.
  • Sum(a, b, ...)가 self까지 포함해서 더하는 형태(위 예시)로 확정 — 사용자 원 예시(:Apply(Sum(state, state...)))와 일치. self 없이 여러 state를 독립적으로 합치는 형태가 따로 필요한지는 실사용 사례가 나오면 재검토(지금은 Store.Combine류로 이미 커버된다고 봄).

우선순위

맨 마지막. 없어도 quad는 기능상 완전하고, 함수 간 의존이 없어 나중에 일부만 먼저 만들거나 전부 미뤄도 리스크가 없음. 지금은 이 문서로 동기/모양/열린 질문만 남겨두고, 실제 구현은 다른 마일스톤이 다 끝난 뒤로 미룸.