콘텐츠로 이동

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.Operator
type 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) -- 15
local b: State<number> = otherPrice:Apply(addTaxAndShipping) -- 105
tax: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 키는 거부)

시그니처

Not: (self: any) -> State<boolean>

단항이라 팩토리 자체를 넘깁니다. 값 타입을 가리지 않습니다 — 무엇이 오든 Luau의 not을 적용해 State<boolean>을 냅니다.

local isVisible = q.Source(true)
local isHidden: State<boolean> = isVisible:Apply(Op.Not) -- false

시그니처

Sum: (...NumArg) -> NumOp

self를 시작값으로 인자들을 순서대로 더합니다. 인자 개수 제한은 없습니다.

local basePrice, tax, shipping = q.Source(1000), q.Source(100), q.Source(50)
local total: State<number> = basePrice:Apply(Op.Sum(tax, shipping, 500)) -- 1650
tax:Set(200) -- total:Get() == 1750

시그니처

Product: (...NumArg) -> NumOp

self에 인자들을 순서대로 곱합니다.

시그니처

Min: (...NumArg) -> NumOp

self와 인자들의 최소값(math.min 폴딩).

시그니처

Max: (...NumArg) -> NumOp

self와 인자들의 최대값(math.max 폴딩).

시그니처

Clamp: (lo: NumArg, hi: NumArg) -> NumOp

math.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)) -- 100

시그니처

Band: (...NumArg) -> NumOp

bit32.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)) -- 8

시그니처

Bor: (...NumArg) -> NumOp

bit32.bor 폴딩.

시그니처

Bxor: (...NumArg) -> NumOp

bit32.bxor 폴딩.

시그니처

Bnot: NumOp

bit32.bnot. 단항이므로 호출하지 않고 그대로 넘깁니다 — flags:Apply(Op.Bnot).

시그니처

Shl: (n: NumArg) -> NumOp

bit32.lshift(self, n). n은 plain 숫자든 State<number>든 됩니다.

local flags = q.Source(0b1100)
local shifted: State<number> = flags:Apply(Op.Shl(2)) -- 0b110000

시그니처

Shr: (n: NumArg) -> NumOp

bit32.rshift(self, n).

시그니처

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"

시그니처

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 nil
Quad0126 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로 직접 쓰면 됩니다.