핵심 과정 · 07 / 35

연산자와 값

연산자

연산자(operator)는 프로그래밍 언어로 치면 함수에 해당한다고 생각하면 된다. 인자를 받아서 식으로 평가된다.

EXTENDS Integers

MinutesToSeconds(m) == m * 60

문제 해결

다음과 같은 에러가 보인다면

Was expecting “=====” or more Module Body, encountered "$Operator" at line $n…

연산자를 정의할 때 이중 == 대신 단일 =를 썼기 때문일 가능성이 높다.

연산자는 인자를 몇 개든 받을 수 있다. 기본값도, 오버로딩된 정의도, 선택적 인자도 없다. 매개변수가 두 개인 연산자는 언제나 값 두 개를 받아야 한다. 값을 하나도 받지 않는 연산자는 괄호 없이 쓸 수 있다. 이 경우 사실상 상수다.

SecondsPerMinute == 60

노트

고차 연산자, 재귀 연산자, 람다처럼 특수한 형태의 연산자가 몇 가지 있는데, 이것들은 나중에 다룬다.

연산자의 우변을 식(expression)이라고 부른다.

IF-THEN-ELSE

식을 구성하는 키워드는 세 가지다. LET 문, case 문, 그리고 조건문이다. 앞의 둘은 나중에 소개한다. LET 문은 기본적인 연산자를 몇 개 쓸 수 있게 된 뒤에야 훨씬 쓸모가 있고, case 문은 그리 자주 쓰이지 않는다. 그러면 조건문이 남는다.

Abs(x) == IF x < 0 THEN -x ELSE x

식은 언제나 무언가와 같아야 하므로, ELSE 분기가 반드시 있어야 한다. 그 점만 빼면 예상대로 동작한다.

값

TLA+는 수학에 뿌리를 두고 있어서 “타입이 없는(untyped)” 언어다. 실제로는 모델 체커가 원시 타입 네 가지와 복합 타입 네 가지를 인식한다.

TLA+의 값(value) 타입은 저마다 고유한 연산자를 가지며, 겹치거나 오버로딩되는 것은 없다. 예외는 =와 # 두 가지로, 각각 “같다”와 “같지 않다”이다. 타입이 서로 다른 값끼리는 같은지 비교할 수 없고, 그렇게 하면 에러가 난다.

문제 해결

다음과 같은 에러가 보인다면

Encountered “Beginning of Definition” at line $n…

같은지 비교할 때 단일 = 대신 이중 ==를 썼기 때문일 가능성이 높다.

더 정교한 타입은 나중으로 미루고, 지금은 문자열, 불리언, 정수, 집합에만 집중하자.

뻔한 것들

정수와 문자열이다. 기본적인 덧셈 연산자를 쓰려면 EXTENDS Integers가 필요하다. 문자열은 “큰따옴표”를 써야 하며 작은따옴표는 쓸 수 없다. 문자열용 연산자는 =와 # 말고는 없다. 실제로 문자열은 불투명한 식별자로 쓰이는데, 일부 언어에 있는 :symbol 타입과 비슷하다. 시스템에서 문자열을 조작해야 한다면, 대신 문자열을 시퀀스에 담는다.

부동소수점(float) 타입은 없다는 점에 유의하자. 부동소수점은 의미론이 복잡해서 표현하기가 극도로 어렵다. 보통은 추상화해서 빼 버릴 수 있지만, 부동소수점이 정말로 꼭 필요하다면 TLA+는 그 일에 맞지 않는 도구다.

불리언

불리언(boolean)은 TRUE와 FALSE다.

그런데 왜 불리언에만 따로 절을 할애할까? 우선, 불리언 연산자는 프로그래밍 기호가 아니라 수학 기호처럼 생겼다. 다음과 같다.

논리

TLA+ 기호

수학 기호

그리고(and)

/\

∧

또는(or)

\/

∨

부정(not)

~

¬

간단한 암기법: ~는 뭔가를 뒤집는 작은 꼬부랑이니까 “not”이다. /\는 “A”처럼 생겼으니까 “and”다. \/는 남은 하나다. 이것들로 Xor 같은 다른 연산자를 만들 수 있다.

Xor(A, B) == A = ~B

눈여겨볼 불리언 연산자가 하나 더 있다. =>, 즉 “함의(implication)”다. A => B는 “B는 적어도 A만큼은 참이다”라는 뜻이다. A => B는 A가 참이고 B가 거짓이면 FALSE이고, 그 밖의 경우에는 참이다. 제어 흐름에는 거의 쓸모가 없어서 프로그래밍에서는 자주 볼 일이 없다. 하지만 어떤 종류의 스펙 작업에서든 극도로 중요하다. 이에 대해서는 나중에 더 자세히 다룬다.2

연습 문제: 대우

  1. “흔한 세 가지” 프로그래밍 연산자를 써서 A => B를 다시 써 보라.

풀이

~A \/ B

  1. A와 B가 어떤 값일 때 ~B => ~A가 참인가?

풀이

1번과 같은 변환 규칙을 쓰면 ~~B \/ ~A, 곧 ~A \/ B가 되므로, A => B와 진릿값이 같다. ~B => ~A를 A => B의 대우(contrapositive)라고 부른다.

또 하나는 TLA+에 불리언 논리를 위한 “글머리표 표기법”이 있다는 점이다. A /\ (B \/ C) /\ (D \/ (E /\ F)) 같은 식이 필요하다고 해 보자. 정말 읽기 힘들다! 그래서 TLA+에서는 대신 이렇게 쓸 수 있다.

/\ A
/\ \/ B
   \/ C
/\ \/ D
   \/ /\ E
      /\ F

훨씬 명확해진다. A 앞에 /\가 하나 더 붙어 있다는 점에 주목하자. 꼭 필요하지는 않지만 모양이 더 보기 좋아서 그렇게 쓴다. 또한 이곳은 언어 전체에서 공백이 의미를 갖는 유일한 곳이다. 이를테면 내가 대신 이렇게 썼다고 하자.

/\ A
/\ \/ B
   \/ C
/\ \/ D
   \/ /\ E
/\ F

이러면 뜻이 달라진다! 이제는 A /\ (B \/ C) /\ (D \/ E) /\ F가 된다.

팁

“대체 그런 게 왜 필요한데?” 복잡한 불변식을 훨씬 읽기 쉽게 만들어 주기 때문이다.

시퀀스

시퀀스(sequence)는 다른 언어의 리스트와 같다. <<a, b, c>>처럼 쓰며, 원소는 다른 어떤 값이든 될 수 있다(다른 시퀀스도 포함해서). 대부분의 다른 언어와 마찬가지로 시퀀스의 값은 seq[n]으로 조회한다. 다만 인덱스가 0..Len(seq)-1이 아니라 1..Len(seq)이다. 그러니까 그렇다, 1부터 센다.

경고

1부터 센다고 말했던가? 1부터 세니까 하는 말이다.

Sequences 모듈도 있다. EXTENDS Sequences를 하면 다음 연산자들도 쓸 수 있다(S == <<"a">>라고 하자).

식

결과

Append(S, "b")

<<"a", "b">>

S \o <<"b", "c">>

<<"a", "b", "c">>

Head(S)

"a"

Tail(<<1, 2, 3>>)

<<2, 3>>

Len(S)

1

SubSeq(<<1, 3, 5>>, 1, 2)

<<1, 3>>

문제 해결

다음과 같은 에러가 보인다면

Encountered “EXTENDS” at line 3, column 1 and token “Sequences”

EXTENDS 줄을 두 개로 따로 썼기 때문이다. TLA+에서는 스펙 하나에 EXTENDS 줄을 하나만 둘 수 있지만, 그 줄에 모듈을 (쉼표로 구분해서) 여러 개 적을 수 있다. 그러니 대신 EXTENDS Integers, Sequences라고 쓰면 괜찮다.

시퀀스를 쓰면 24시간제 시계를 <<hour, minute, second>>로 나타낼 수 있다.

EXTENDS Integers, Sequences

ToSeconds(time) == time[1]*3600 + time[2]*60 + time[3]
Earlier(t1, t2) == ToSeconds(t1) < ToSeconds(t2)

노트

길이가 고정된 시퀀스는 “튜플(tuple)”이라고도 부른다. 어느 쪽이든 문법은 같다.

집합

집합(set)은 순서가 없고 중복이 없는 값들의 모음이다. {1, 2, 3}이나 {<<"a">>, <<"b", "c">>}처럼 중괄호로 쓴다. {{1}, {2}, {3}}처럼 집합 안에 다른 집합을 넣을 수도 있다.

집합에는 타입이 서로 다른 원소를 담을 수 없다. {1, "a"}는 유효하지 않다.

집합 연산자

x가 set의 원소인지 확인하려면 x \in set이라고 쓴다. \in은 연산자로만 쓰이는 게 아니라 다른 몇몇 곳에서 문법으로도 쓰인다. 그 역인 \notin도 있다. set1 \subseteq set2는 set1의 모든 원소가 set2의 원소이기도 한지 검사한다.

노트

이것은 “부분집합이거나 같음”이다. “집합은 자기 자신의 부분집합인가?”라는 질문을 비껴가는 방법이다.

집합을 이리저리 자르고 쪼개는 방법도 있다.

  • set1 \union set2는 set1 또는 set2(또는 둘 다)에 있는 모든 원소의 집합이다.

  • set1 \intersect set2는 두 집합 모두에 있는 모든 원소의 집합이다.

  • set1 \ set2, 즉 “차집합”은 set1에는 있지만 set2에는 없는 모든 원소의 집합이다.

노트

\union과 \intersect 대신 \cup과 \cap을 보게 될 수도 있다. 이는 합집합과 교집합을 나타내는 수학 기호 ∪와 ∩에서 온 것이다.

>>> {1, 3} \union {1, 5}

{1, 3, 5}

>>> {1, 3} \intersect {1, 5}

{1}

>>> {1, 3} \ {1, 5}

{3}

EXTEND FiniteSets를 하면 Cardinality(set)도 쓸 수 있다. 집합에 있는 원소의 개수다.

팁

집합이 비었는지 검사하는 가장 쉬운 방법은 set = {}라고 쓰는 것이다. 마찬가지로 시퀀스가 비었는지는 seq = <<>>라고 써서 검사할 수 있다.

값의 집합

이제 시계 값을 쓰는 스펙을 작성하는데, 시각을 더하는 연산자를 간단히 하나 만들고 싶다고 상상해 보자. 나라면 이렇게 쓸 것이다.

AddTimes(t1, t2) == <<t1[1] + t2[1], t1[2] + t2[2], t1[3] + t2[3]>>

그러면 AddTimes(<<2, 0, 1>>, <<1, 2, 3>>) = <<3, 2, 4>>이고, AddTimes(<<2, 0, 1>>, <<1, 2, 80>>) = <<3, 2, 81>>이다.

잠깐, 81초? 우리 시계는 81초를 표시할 수 없다. 답은 <<3, 3, 21>>이어야 한다. <<0, 0, 0>>부터 <<23, 59, 59>>까지 유효한 시계 값들의 집합이 있고, AddTimes는 마치 타입 시그니처가 있는 것처럼 언제나 그 집합 안의 어떤 값을 반환해야 한다고 생각할 수 있다. TLA+에서 이것을 강제할 수 있지만, 그 전에 값으로부터 값의 집합을 만들어 내는 방법이 필요하다. 다행히 TLA+의 모든 값 타입에는 그 타입 값들의 집합을 만들어 내는 방법이 있다.1

가장 쉬운 것부터 시작하자. 모든 불리언의 집합을 얻으려면 그냥 BOOLEAN이라고 쓰면 된다. 이것은 {TRUE, FALSE} 집합이다. 정수의 경우, a..b는 {a, a+1, a+2, ... , b} 집합이다. 이것이 동작하려면 EXTENDS Integers가 필요하다.

팁

a > b이면 a..b는 비어 있다. 덕분에 많은 것이 훨씬 단순해진다. 예를 들어 1..Len(seq)는 seq의 인덱스 집합이다. seq = <<>>이면 1..0 = {}가 되는데, 예상하는 그대로다.

이제 시퀀스 차례다. 두 집합 S와 T의 데카르트 곱(Cartesian product)은 첫 번째 원소가 S에 있고 두 번째 원소가 T에 있는 모든 시퀀스의 집합이다. \X로 쓴다. 예를 들어 누가 로그인하는지, 로그인을 시도한 시각, 그리고 성공했는지 여부를 담는 LoginAttempt를 생각해 보자. 가능한 모든 그런 값의 집합을 LoginAttempt == Person \X Time \X BOOLEAN으로 나타낼 수 있다.

노트

\X는 결합 법칙이 성립하지 않는다.

S == 1..3

>> <<1, 2, 3>> \in S \X S \X S
TRUE

>> <<1, 2, 3>> \in (S \X S) \X S
FALSE

>> <<<<1, 2>>, 3>> \in (S \X S) \X S
TRUE

Time 이야기가 나온 김에, \X와 ..를 조합하면 드디어 시계 타입을 얻을 수 있다.

ClockType == (0..23) \X (0..59) \X (0..59)

간단한 확인 삼아 스크래치 파일 만들기에서 Cardinality(ClockType)를 실행해 보라(EXTENDS FiniteSets가 필요하다는 것을 잊지 말자). 원소가 86400개임을 볼 수 있을 것이다. 이제 AddTimes에 대한 속성(property)에 한 걸음 더 다가갔다. 우리는 그 결과가 언제나 ClockType 안의 값이기를 원한다.

마지막으로, SUBSET S로 한 집합의 모든 부분집합을 얻을 수 있다. SUBSET ClockType은 시계 값을 여러 개 담은 모든 집합이 된다… 무려 2^86400개 전부. 이러지 마라.

팁

초보자들이 “S가 T의 부분집합인지”를 S \in SUBSET T라고 써서 검사하려는 모습을 자주 본다. 동작은 하지만 매우 비효율적이다. 대신 S \subseteq T라고 써라.

맵과 필터

집합은 맵(map)하고 필터(filter)할 수 있다.

\* Map
Squares == {x*x: x \in 1..4}

\* Filter
Evens == {x \in 1..4: x % 2 = 0 }

내 경험상 어느 쪽이 어느 쪽인지 기억하는 가장 좋은 방법은 콜론을 “where”로 읽는 것이다. 그러면 맵은 “x squared where x in 1..4”(x가 1..4에 속할 때의 x의 제곱)이고, 필터는 “x in 1..4 where x is even”(1..4에 속하는 x 중 짝수인 것)이다.

매시 후반 30분에 해당하는 모든 시각을 얻으려면 이렇게 쓸 수 있다.

{t \in ClockType: t[2] >= 30 /\ t[3] = 0}

맵과 필터는 유틸리티로도 훌륭하다. 시퀀스의 치역(range)은 그 시퀀스에 있는 모든 원소의 집합이다. 집합 맵으로 이것을 얻을 수 있다.

Range(seq) == {seq[i]: i \in 1..Len(seq)}

CHOOSE

시계 값에서 자정 이후 몇 초가 지났는지 구하는 것은 간단하다. 그렇다면 반대 방향은 어떨까? 초 단위 시간이 있으면 다음과 같이 시계 시각을 구할 수 있다.

  1. 3600으로 내림 나눗셈을 해서 총 시간(hour)을 구한다.

  2. 그 나머지를 다시 60으로 내림 나눗셈을 해서 총 분을 구한다.

  3. 두 번째 나눗셈의 나머지를 초로 삼는다.

이 방법은 총 초에서 시계 값을 구성한다. 무언가를 하는 알고리즘을 구현하는 프로그래밍 언어에서라면 이렇게 할 것이다. 하지만 이 방법은 실수하기도 쉽다. 90,000을 넣으면 어떻게 될까? 그러면 <<25, 0, 0>>이 나온다. ClockType 바깥의 값이다.

이렇게 할 수도 있다.

  1. 가능한 모든 시계 값의 집합을 가져온다.

  2. 그 집합에서, 초로 변환했을 때 주어진 값이 되는 원소를 고른다.

우리가 이렇게 하지 않는 이유는 “가능한 모든 시계 값의 집합”이 원소가 80,000개가 넘고, 원소 80,000개짜리 리스트에서 검색하는 것은 자원 낭비이기 때문이다. 하지만 이 방법은 변환의 정의에 더 가깝게 들어맞아서 명세(specification)에는 더 유용하다. TLA+에서는 이 선택을 다음과 같이 쓸 수 있다.

ToClock(seconds) == CHOOSE x \in ClockType: ToSeconds(x) = seconds

CHOOSE x \in set: P(x)는 범용 “선택” 문법이다. 스크래치 파일 만들기에서 직접 해 보라.

CHOOSE는 집합에서 값을 하나 꺼내야 할 때면 언제든 유용하다.

그럼 ToClock(86401)이라고 쓰면 어떻게 될까? 86,401초에 해당하는 시계 시각은 없다. 이것을 시도하면 TLC가 에러를 낸다. 이는 엉터리 값을 내놓는 구현 방식의 해법과 대조적이다. 대응하는 집합 원소를 찾지 못한다면, 99%는 스펙의 버그, 즉 미처 고려하지 못한 엣지 케이스다. 연산자를 더 단단하게 만드는 편이 낫다.

ToClock(seconds) == CHOOSE x \in ClockType: ToSeconds(x) = seconds % 86400

문제 해결

다음과 같은 에러가 보인다면

Attempted to compute the value of an expression of form
CHOOSE x in S: P, but no element of S satisfied P.

아무 값도 찾을 수 없는 CHOOSE를 썼기 때문이다. 그냥 식을 잘못 쓴 것일 때도 있다. 하지만 시스템의 실제 결함을 가리킬 때도 있다. 값이 존재하리라 기대했는데 존재하지 않은 것이다. 에러 처리 로직을 작성해 두는 편이 좋다. 그러지 않으면 프로덕션에서 불쾌한 일을 겪게 된다.

경고

여러 값이 CHOOSE를 만족하면 어떻게 될까? 이 경우 유일한 요구 사항은 결과가 결정적이어야 한다는 것이다. 즉 엔진은 무슨 일이 있어도 항상 같은 값을 반환해야 한다. 실제로는 TLC가 항상 집합에서 조건에 맞는 가장 작은 값을 고른다는 뜻이다.

LET

짐작하겠지만 TLA+ 연산자는 꽤 복잡해질 수 있다! 따라가기 쉽도록 LET을 써서 하위 연산자로 쪼갤 수 있다.

ToClock(seconds) ==
  LET seconds_per_day == 86400
  IN CHOOSE x \in ClockType: ToSeconds(x) = seconds % seconds_per_day

LET은 ToClock 안으로 지역 스코프가 한정된 새 정의를 제공한다. seconds_per_day는 이 연산자의 정의 안에서만 존재하는 연산자다.

잠깐, 연산자라고? 그렇다. LET 안에 매개변수가 있는 연산자도 추가할 수 있다!

ThreeMax(a, b, c) ==
   LET
     Max(x, y) == IF x > y THEN x ELSE y
   IN
     Max(Max(a, b), c)

그리고 한 LET 안에 연산자를 여러 개 정의할 수도 있다.

ThreeMax(a, b, c) ==
   LET
     Max(x, y) == IF x > y THEN x ELSE y
     maxab == Max(a, b)
   IN
     Max(maxab, c)

LET 안의 각 연산자는 같은 스코프에서 앞서 정의된 연산자를 참조할 수 있다. 이를 이용하면 해법을 한 단계씩 쌓아 올릴 수 있다.

ToClock을 “프로그래밍 방식”으로 계산해 보자.

ToClock2(seconds) ==
  LET
    h == seconds \div 3600
    h_left == seconds % 3600
    m == h_left \div 60
    m_left == h_left % 60
    s == m_left
  IN
    <<h, m, s>>
>>> ToClock2(90000)

<<25, 0, 0>>

복잡한 연산자를 작성해야 한다면, LET으로 단계를 나누는 것이 더 이해하기 쉽게 만드는 훌륭한 방법이다.

요약

  • 연산자는 최상위 “함수”이며, 식으로 평가된다. Op(a, b) == expr처럼 등호 두 개로 쓴다.

    • 연산자는 IF-THEN-ELSE로 조건을, LET-IN으로 하위 연산자를 가질 수 있다.

  • 시퀀스는 순서가 있는 값들의 모음이며, 1부터 센다.

  • 논리는 “그리고”가 /\, “또는”이 \/, “부정”이 ~이다.

    • 논리 문장은 “글머리표” 스타일로 쓸 수 있다.

  • 집합은 순서가 없고 중복이 없는 값들의 모음이다.

    • 원소가 집합에 \in인지, 한 집합이 다른 집합의 \subseteq인지 검사할 수 있다.

    • 두 집합을 \union하고, \intersect하고, (차집합)할 수 있다.

    • 집합의 원소를 CHOOSE할 수 있다.

  • 모든 타입에는 그 타입의 “집합”이 있다. 정수는 a..b, 불리언은 BOOLEAN, 집합은 SUBSET, 시퀀스는 S1 \X S2다.

    • 집합은 맵하고 필터할 수 있다.

1

문자열만 빼고. 사실 STRING이라는 키워드가 있긴 하다. 하지만 가능한 모든 문자열을 나타내므로 무한히 큰 집합이라서…

2

“A와 B가 둘 다 참이거나 둘 다 거짓”을 뜻하는 A <=> B도 있지만, A = B라고 쓰는 것과 똑같다.