Operator
state:Compute(function(h) return not h:Get() end) 같은 보일러플레이트를 없애고, 의존성 캡처가 어긋날 수 없는 재사용 가능한 이름을 주는 콤비네이터 모음입니다. 전부 state:Apply(factory)로 붙습니다.
이 페이지의 심볼: q.Operator.Not · q.Operator.Sum(...) · q.Operator.Product(...) · q.Operator.Min(...) · q.Operator.Max(...) · q.Operator.Clamp(lo, hi) · q.Operator.Band(...) · q.Operator.Bor(...) · q.Operator.Bxor(...) · q.Operator.Bnot · q.Operator.Shl(n) · q.Operator.Shr(n) · q.Operator.Alternative(default) · q.Operator.Indexed<<V>>(key)
quad-base에 있으므로 백엔드와 무관하게 존재합니다.
-- 01장의 설정 모듈: quad_base에 quad_roblox를 설치하고 타입을 다시 내보낸다(시작하기 01 참고)local q = require("@game/ReplicatedStorage/Client/UI/Quad")local Op = q.Operatortype State<T> = q.State<T>공통 계약
섹션 제목: “공통 계약”모양은 둘뿐입니다.
- 단항(
Not/Bnot) — 팩토리 자체가 곧 연산이므로 호출하지 않고 그대로 넘깁니다:state:Apply(Op.Not). - 나머지 — 인자를 받아 팩토리를 돌려주는 커링 형태:
state:Apply(Op.Sum(tax, 500)).
왜 :Compute가 아니라 :Apply인가. 팩토리가 자기 deps를 직접 :Compute에 넘기므로, 한 번 이름 붙인 연산자를 여러 State에 :Apply해도 deps가 따라갑니다 — 이름과 의존성이 갈라질 수 없습니다.
local tax, shipping = q.Source(2), q.Source(3)local basePrice, otherPrice = q.Source(10), q.Source(100)
local addTaxAndShipping = Op.Sum(tax, shipping) -- 한 번 이름 붙이고local a: State<number> = basePrice:Apply(addTaxAndShipping) -- 15local b: State<number> = otherPrice:Apply(addTaxAndShipping) -- 105tax:Set(5) -- 재사용한 쪽까지 같이 갱신된다: 18 / 108숫자 인자는 plain 숫자든 State<number>든 됩니다. plain 값은 상수로 캡처되고, State는 의존성으로 등록돼 바뀌면 결과가 갱신됩니다. 보조 타입 별칭 둘이 그 계약입니다.
export type NumArg = number | StateData<number>export type NumOp = (self: StateData<number>) -> State<number>결과 State의 타입은 호출부가 명시합니다 — local total: State<number> = price:Apply(Op.Sum(tax)).
에러가 나는 시점은 셋으로 갈립니다.
| 무엇 | 언제 | 예 |
|---|---|---|
인자 타입·nil 인자 |
팩토리를 부르는 줄 | Quad0119 Operator.Sum: argument #1 must be a number or a State<number> (got string)Quad0118 Operator.Sum: argument #2 is nil |
:Apply 대상이 State가 아님 |
:Apply 하는 줄(커링 팩토리를 Op.Sum(1)처럼 부른 뒤 넘긴 경우. tag:Apply(Op.Not)처럼 단항 팩토리가 다른 :Apply 소유자 안에서 불리면 그 소유자의 내부 파일이, 커링 안쪽 팩토리를 Op.Sum(1)(5)처럼 직접 부르면 Operator.luau가 blame됩니다 — [2026-09-27 기준]) |
Quad0018 Operator.Sum: Apply target must be a State (got table) |
값이 계약에 안 맞음(Indexed만) |
값을 읽는 시점 | Quad0127 Operator.Indexed: value is not a table (got number) - cannot read [x] |
nil 인자가 조용히 사라져 뒤 인자를 당겨오는 일은 없습니다 — 자리마다 검사해서 argument #N is nil로 던집니다. 인자로 넘긴 State의 현재값이 nil이거나 숫자가 아닌 경우도 마찬가지입니다 — 읽는 시점에 Quad0120 Operator.Sum: argument #N is a State whose current value is nil(또는 … must be a number (got string))로 던지고, 그 항을 건너뛰어 틀린 합을 돌려주지 않습니다(Apply 대상 자신의 값도 같은 검사). 갓 만든 store:Of(...)나 없는 키를 읽은 Indexed처럼 값이 아직 nil인 State를 연산에 넣으면 그 :Get()이 던집니다.
한 번 읽을 때 둘 이상이 동시에 틀렸으면 어느 에러가 나는지는 연산마다 다릅니다. Clamp는 경계를 먼저 봐서, 대상 값이 nil이고 경계도 역전돼 있으면 Quad0124입니다. Sum/Product/Min/Max와 비트 연산은 대상을 먼저 봐서, 대상 값과 인자 State의 값이 둘 다 nil이면 Quad0122입니다([2026-09-27 mock 실측]). 에러 하나를 고쳤더니 다른 에러가 새로 나는 것은 이 순서 때문일 수 있습니다.
연산자를 이어 붙인 체인에서 안쪽이 던지면, 바깥의 인자만 바꿔서는 풀리지 않습니다. 안쪽 연산자가 읽는 시점에 던진 뒤 바깥 연산자의 인자 State만 :Set하면, 바깥이 다시 읽는 안쪽 노드는 아직 같은 세대라 원래 원인 대신 Quad0184가 납니다 — 던진 노드는 자기 상류가 바뀌어야 다시 돕니다(state:Compute의 규칙 그대로). 관측(계약 아님): 안쪽 연산자의 대상이나 인자를 고쳐 :Set하면 지금은 회복됩니다 — 던진 노드가 새 세대에 다시 도는 것은 지금 구현의 결과이지 약속이 아닙니다([2026-09-27 mock 실측]). 아래 Clamp의 경계 역전이 가장 흔한 예입니다.
부호 있는 0과 무한대. 0과 -0처럼 값이 같은 두 수 사이에서는 앞 피연산자(대상 쪽)가 남습니다 — 대상이 0이고 인자가 -0이면 Min은 0, 대상이 -0이고 인자가 0이면 -0이고, 대상 -0에 Clamp(0, 1)을 걸면 -0입니다. Sum/Product는 결과가 NaN이 되는 것(inf와 -inf의 합, 0과 inf의 곱)을 막지 않습니다([2026-09-27 mock 실측]).
산술·비트 연산자는 숫자 전용입니다. Sum/Product/Min/Max/Clamp와 Band~Shr는 인자뿐 아니라 :Apply를 받는 State의 값도 숫자여야 합니다. UDim2나 Color3 같은 타입에 쓰면 타입 검사에서 None of the overloads for function that accept 2 arguments are compatible.로 막힙니다 — 그런 연산은 state:Compute로 직접 쓰십시오. 값 타입을 가리지 않는 것은 Not·Alternative·Indexed 셋입니다.
전체 목록
섹션 제목: “전체 목록”| 연산자 | 모양 | 뜻 |
|---|---|---|
Not |
state:Apply(Op.Not) |
논리 부정(단항) |
Sum |
Op.Sum(...) |
self + 인자들 |
Product |
Op.Product(...) |
self × 인자들 |
Min |
Op.Min(...) |
self와 인자들의 최소 |
Max |
Op.Max(...) |
self와 인자들의 최대 |
Clamp |
Op.Clamp(lo, hi) |
math.clamp |
Band |
Op.Band(...) |
bit32.band 폴딩 |
Bor |
Op.Bor(...) |
bit32.bor 폴딩 |
Bxor |
Op.Bxor(...) |
bit32.bxor 폴딩 |
Bnot |
state:Apply(Op.Bnot) |
bit32.bnot(단항) |
Shl |
Op.Shl(n) |
bit32.lshift |
Shr |
Op.Shr(n) |
bit32.rshift |
Alternative |
Op.Alternative(default) |
널 병합 — State<T?> → State<T> |
Indexed |
Op.Indexed<<V>>(key) |
반응형 필드 읽기(키 자체는 반응형이 아님 — State 키는 거부) |
q.Operator.Not
섹션 제목: “q.Operator.Not”시그니처
Not: (self: any) -> State<boolean>단항이라 팩토리 자체를 넘깁니다. 값 타입을 가리지 않습니다 — 무엇이 오든 Luau의 not을 적용해 State<boolean>을 냅니다.
local isVisible = q.Source(true)local isHidden: State<boolean> = isVisible:Apply(Op.Not) -- falseq.Operator.Sum(...)
섹션 제목: “q.Operator.Sum(...)”시그니처
Sum: (...NumArg) -> NumOpself를 시작값으로 인자들을 순서대로 더합니다. 인자 개수 제한은 없습니다.
local basePrice, tax, shipping = q.Source(1000), q.Source(100), q.Source(50)local total: State<number> = basePrice:Apply(Op.Sum(tax, shipping, 500)) -- 1650tax:Set(200) -- total:Get() == 1750q.Operator.Product(...)
섹션 제목: “q.Operator.Product(...)”시그니처
Product: (...NumArg) -> NumOpself에 인자들을 순서대로 곱합니다.
q.Operator.Min(...)
섹션 제목: “q.Operator.Min(...)”시그니처
Min: (...NumArg) -> NumOpself와 인자들의 최소값(math.min 폴딩).
q.Operator.Max(...)
섹션 제목: “q.Operator.Max(...)”시그니처
Max: (...NumArg) -> NumOpself와 인자들의 최대값(math.max 폴딩).
q.Operator.Clamp(lo, hi)
섹션 제목: “q.Operator.Clamp(lo, hi)”시그니처
Clamp: (lo: NumArg, hi: NumArg) -> NumOpmath.clamp(self, lo, hi). 인자가 정확히 둘이라 하나만 주면 Quad0118 Operator.Clamp: argument #2 is nil입니다(셋째부터는 다른 Lua 함수처럼 무시합니다 — Shl·Shr의 둘째부터도 같고, strict 타입 검사는 넘친 인자를 잡습니다). 읽는 시점에 lo > hi이면(반응형 경계가 한 프레임 엇갈림) 그 :Get() 줄에서 Quad0124 Operator.Clamp: min must be <= max and neither NaN (got min 0, max -5)로 던집니다. 관측(계약 아님): 지금은 경계가 돌아오면 다음 세대에 다시 계산됩니다(노드만 — 던질 때 읽던 Effect는 죽은 채, 그 사이 재읽기는 Quad0184). 경계가 NaN(0/0 — 나누기 결과가 흔한 원인)이어도 같은 문구입니다.
local posX, maxX = q.Source(150), q.Source(100)local boundX: State<number> = posX:Apply(Op.Clamp(0, maxX)) -- 100q.Operator.Band(...)
섹션 제목: “q.Operator.Band(...)”시그니처
Band: (...NumArg) -> NumOpbit32.band 폴딩. Luau엔 비트 연산자가 없어 bit32 위의 얇은 층입니다. 인자 없이 Op.Band()로 써도 값을 bit32 규칙으로 정규화합니다(-1 → 4294967295 — 인자 하나일 때·Bnot과 같음; Bor()·Bxor()도 같습니다).
비트 계열 여섯(Band/Bor/Bxor/Bnot/Shl/Shr)은 bit32 계약을 그대로 물려받고 게이트를 두지 않습니다 — 소수는 잘리고(1.5 → 1), 음수는 2^32 모듈로(-1 → 4294967295), 2^32 이상은 2^63 미만까지만 접히며(2^53+2 → 2; Luau VM이 (unsigned)(long long)n C 캐스트로 변환하므로(luau-lang/luau@3fc82b1:VM/src/lnumutils.h·VM/src/lapi.cpp) 절댓값 2^63 이상·NaN·±inf는 C에서 정의되지 않은 변환이라 플랫폼에 달립니다 — x86에선 0(Bnot(NaN)은 4294967295), ARM64는 포화 변환이라 +inf가 4294967295가 될 수 있음, 미실측), 이동량도 같은 캐스트를 거쳐 32 이상이면 0(NaN·inf·2^31 이상은 플랫폼 의존), 음수 이동량은 방향이 뒤집힙니다. 수치 계열의 NaN도 검사하지 않습니다 — Clamp는 경계의 NaN만 거부하고 대상이 NaN이면 NaN을 그대로 돌려주며, Min/Max는 어느 쪽이 NaN이냐에 따라 결과가 갈립니다(대상이 NaN이면 NaN, 인자가 NaN이면 대상 값).
local flags = q.Source(0b1100)local masked: State<number> = flags:Apply(Op.Band(0b1010)) -- 8q.Operator.Bor(...)
섹션 제목: “q.Operator.Bor(...)”시그니처
Bor: (...NumArg) -> NumOpbit32.bor 폴딩.
q.Operator.Bxor(...)
섹션 제목: “q.Operator.Bxor(...)”시그니처
Bxor: (...NumArg) -> NumOpbit32.bxor 폴딩.
q.Operator.Bnot
섹션 제목: “q.Operator.Bnot”시그니처
Bnot: NumOpbit32.bnot. 단항이므로 호출하지 않고 그대로 넘깁니다 — flags:Apply(Op.Bnot).
q.Operator.Shl(n)
섹션 제목: “q.Operator.Shl(n)”시그니처
Shl: (n: NumArg) -> NumOpbit32.lshift(self, n). n은 plain 숫자든 State<number>든 됩니다.
local flags = q.Source(0b1100)local shifted: State<number> = flags:Apply(Op.Shl(2)) -- 0b110000q.Operator.Shr(n)
섹션 제목: “q.Operator.Shr(n)”시그니처
Shr: (n: NumArg) -> NumOpbit32.rshift(self, n).
q.Operator.Alternative(default)
섹션 제목: “q.Operator.Alternative(default)”시그니처
Alternative: <T>(default: T | StateData<T>) -> (self: StateData<T?>) -> State<T>널 병합입니다 — self가 nil이면 default, 아니면 self. default가 State면 의존성으로 등록되므로 기본값 쪽이 바뀌어도 결과가 따라옵니다. 값 타입을 가리지 않습니다.
default가 nil이면 팩토리 호출 줄에서 Quad0128 Operator.Alternative: default must not be nil입니다. default가 State인데 그 현재값이 nil이면(아직 안 채운 store:Of) 읽는 줄에서 Quad0129 Operator.Alternative: the default State's current value is nil로 던집니다 — 기본값 자리에는 값이 있어야 합니다.
q.None은 nil이 아닙니다 — self가 None이면 기본값으로 바뀌지 않고 None이 그대로 내려갑니다(프로퍼티 자리에서는 프로퍼티 핸들러가 nil을 씁니다 — nil을 못 받는 프로퍼티(Text 등)면 엔진이 던집니다, q.Elem의 값 대수 표). Indexed도 None을 테이블로 보고 조용히 nil을 돌려주며(예외 하나 — 센티널의 표시 필드 이름 "__quadNone"을 키로 주면 true), 산술·비트 연산자는 None을 must be a number (got table)로 거부합니다.
local optionalName = q.Source<<string?>>(nil)local safeName: State<string> = optionalName:Apply(Op.Alternative("Guest")) -- "Guest"q.Operator.Indexed<<V>>(key)
섹션 제목: “q.Operator.Indexed<<V>>(key)”시그니처
Indexed: <V>(key: any) -> (self: any) -> State<V>반응형 필드 읽기입니다. 결과 타입만 계약이 다릅니다 — 키에서 값 타입을 추론하는 형태는 지금 솔버로 표현할 수 없어서, 값 타입을 호출자가 명시적 타입 인자로 직접 적습니다.
런타임은 상류 값에 [key]를 한 번 읽는 것입니다. 상류 State 전체가 바뀔 때 갱신되고, 없는 키는 nil입니다 — 게이트가 없습니다(타입을 적은 사람이 책임집니다).
type Palette = { Primary: string, Size: number }local palette = q.Source<<Palette>>({ Primary = "red", Size = 10 })
local primary: State<string> = palette:Apply(Op.Indexed<<string>>("Primary"))local size: State<number> = palette:Apply(Op.Indexed<<number>>("Size"))local missing: State<string?> = palette:Apply(Op.Indexed<<string?>>("Nope")) -- nil에러 셋. key가 nil이거나 State이면 팩토리 호출 즉시입니다(키는 반응형이 아닙니다).
Quad0125 Operator.Indexed: key must not be nilQuad0126 Operator.Indexed: key must be a plain value, not a State (the key is not reactive)상류 값이 테이블이 아니면 읽는 시점에 던집니다([...] 자리엔 그 키가 들어갑니다).
Quad0127 Operator.Indexed: value is not a table (got number) - cannot read [x]의도적으로 없는 것
섹션 제목: “의도적으로 없는 것”And/Or— 상태 결합은if/else를 쓰던 이유와 부딪힙니다.- 비교 연산(
Eq/Lt…) — 결과를 먹일 분기가 없으면 의미가 없습니다. Sub/Div— 이름 붙은 콤비네이터로서의 선례가 없습니다.
셋 다 state:Compute로 직접 쓰면 됩니다.
- 컴포넌트 경계 규약 —
:Apply자리와 컴포넌트 경계 - v1에서 옮겨오기 — v1의 연산 헬퍼가 어디로 갔는지
답변함: 0 / 0 (0%)