핵심 과정 · 11 / 35

구조화된 데이터

구조체

시퀀스(sequence)는 튜플(tuple)과 배열을 다룬다. 이제 문자열 해시맵을 표현할 무언가가 필요하다. TLA+에서는 구조체(struct)가 그 역할을 한다:

struct == [a |-> 1, b |-> {}]

구조체는 시퀀스와 같은 방식으로 인덱싱하므로 struct["a"] = 1이다. 대신 점을 써서 좀 더 “객체”처럼 쓸 수도 있다: struct.a = 1.

문제 해결

다음과 같은 에러가 나온다면

Encountered “|->” at line X, column Y and token “key”

["key" |-> val]이라고 썼기 때문이다. 올바른 형태는 [key |-> val]이다.

놀랄 것도 없이, 구조체는 주로 정리된 데이터 묶음을 표현하는 데 쓰인다. 예를 들어 BankTransaction은 계좌, 금액, 그리고 입금인지 출금인지로 구성될 수 있다.

시퀀스, 집합(set), 원시 값과 마찬가지로, 구조체의 집합을 생성할 방법도 필요하다. 그래야 타입 불변식(type invariant)에 넣을 수 있다. 방법은 이렇다:

\* Accounts is a set of model values
BankTransactionType == [acct: Accounts, amnt: 1..10, type: {"deposit", "withdraw"}]

이것은 s.acct \in Accounts, s.amnt \in 1..10 등을 만족하는 모든 구조체의 집합이다.

팁

특히 복잡한 타입이라면, 집합에 원소가 몇 개 있는지 주기적으로 어림해 보는 것이 좋다. 여기서는 계좌가 세 개인 모델이라면 BankTransactionType의 원소가 3 · 10 · 2 = 60개가 된다.

문제 해결

다음과 같은 에러가

Attempted to compute the number of elements in the string “val”

모델 체킹(model checking) 시점에 나온다면, [key: "val"]이라고 썼기 때문이다. 올바른 형태는 [key: {"val"}]이다.

구조체의 키 얻기

시퀀스의 모든 값을 구하고 싶다면 이렇게 하면 된다:

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

구조체의 모든 값은 어떻게 구할까? Len은 구조체에 정의되어 있지 않다. 대신 특별한 키워드 DOMAIN이 있다. DOMAIN struct는 구조체의 모든 키의 집합이다.

RangeStruct(struct) == {struct[key]: key \in DOMAIN struct}

이제 재미있는 부분이다. 시퀀스를 RangeStruct에 넘기면 어떻게 될까?

>>> RangeStruct(<<"a", "b", "a">>)

{"a", "b"}

DOMAIN seq == 1..Len(seq)인 것처럼 보인다! 사실은 그 반대다: Len이 DOMAIN을 이용해 정의되어 있다!

자, 반전이다: 시퀀스와 구조체는 둘 다 문법적 설탕(syntactic sugar)일 뿐이다. TLA+에는 “진짜” 컬렉션이 집합과 함수(function), 두 가지뿐이다. 시퀀스와 구조체는 둘 다 함수의 특정한 부류로, 우리 프로그래머에게 가장 익숙한 것들이다. 드디어 진짜 데이터 타입을 소개할 때가 왔다.

함수

우선, 프로그래밍에서 말하는 “함수”의 정의는 버려라. TLA+에서 프로그래밍의 함수에 가장 가까운 것은 연산자(operator)다. 함수는 수학적 정의, 즉 한 집합의 값을 다른 집합으로 대응시키는 사상(mapping)을 따른다.

F == [x \in S |-> expr]

대응의 출발점이 되는 집합 S는 함수의 정의역(domain)이며, DOMAIN F라고 써서 얻을 수 있다. 그래서 시퀀스와 구조체에도 DOMAIN을 쓸 수 있었던 것이다:

  1. 시퀀스는 정의역이 1..n인 함수일 뿐이다.

  2. 구조체는 정의역이 문자열의 집합인 함수일 뿐이다.

하지만 함수는 그보다 더 일반적이어서, 어떤 값의 집합이든 대응시킬 수 있다. 예를 들어 함수의 정의역에 숫자 쌍을 둘 수도 있다.

Prod ==
  LET S == 1..10 IN
  [p \in S \X S |-> p[1] * p[2]]

\* Prod[<<3, 5>>] = 15

팁

이것은 Prod == [x \in S, y \in S |-> x * y]나 G == [x, y \in S |-> x * y]로 쓸 수도 있다. 또 꺾쇠괄호를 빼고 Prod[3, 5]로 함수를 호출할 수도 있다.

(내부적으로 TLA+는 이것을 튜플로 표현하므로 DOMAIN F = S \X T이다.)

나는 다양한 입력에 대한 식(expression)의 결과를 보여 주는 용도로 함수를 즐겨 쓴다. P와 Q가 어떤 값일 때 P => Q가 참일까?

TruthTable == [p, q \in BOOLEAN |-> p => q]

이것을 스크래치 파일에서 실행하면 결과가 나오는데, 형식이 좀 특이하다:

>>> TruthTable

( <<FALSE, FALSE>> :> TRUE @@
<<FALSE, TRUE>> :> TRUE @@
<<TRUE, FALSE>> :> FALSE @@
<<TRUE, TRUE>> :> TRUE )

이것은 “펼친 형태(expanded form)”다: x :> y는 x를 y로 대응시키는 단일 값 함수이고(즉 [s \in {x} |-> y]), @@는 두 함수를 병합한다. 두 함수가 키를 공유하면 @@는 왼쪽의 값을 유지한다.

노트

@@와 :>는 스펙(spec)에서 TLC를 확장(extend)해야만 쓸 수 있다.

이것으로 시퀀스와 구조체가 그저 함수일 뿐이라는 것을 확인할 수 있다:

>>> 1 :> "a" @@ 2 :> "b"
<<"a", "b">>

>>> "a" :> 1 @@ "b" :> 2
[a |-> 1, b |-> 2]

예제: Zip

Python에는 zip이라는 함수가 있다. 이 함수는 이터러블 두 개를 받아 시퀀스 하나를 반환하는데, 그 원소는 두 입력의 원소를 짝지은 쌍이다. 한쪽이 다른 쪽보다 길면 짧은 쪽의 길이까지만 처리한다.

>>> list(zip([1, 2], ["a", "b", "c"]))
[(1, 'a'), (2, 'b')]

보통 프로그래밍 언어는 zip을 반복이나 재귀로 구현한다. 여기서는 그럴 필요가 없다. 시퀀스 전체를 한 번에 “볼” 수 있기 때문이다.

Zip1(seq1, seq2) ==
  LET Min(a, b) == IF a < b THEN a ELSE b
      N == Min(Len(seq1), Len(seq2))
  IN
    [i \in 1..N |-> <<seq1[i], seq2[i]>>]

다르게 쓰는 방법은 교집합을 이용하는 것이다. 1..a와 1..b의 교집합이 1..Min(a,b)라는 점에 주목하면 된다. 그러면 Zip을 이렇게 단순화할 수 있다:

Zip2(seq1, seq2) ==
  LET N == (DOMAIN seq1) \intersect (DOMAIN seq2)
  IN
    [i \in N |-> <<seq1[i], seq2[i]>>]

한정자(quantifier)를 이용한 검사를 작성해서 두 정의가 동등한지 확인할 수 있다:

LET
  S == 1..4
  Input == (S \X S \X S) \union (S \X S)
IN
  \A s1, s2 \in Input:
    Zip1(s1, s2) = Zip2(s1, s2)

함수 활용하기

왜 연산자 대신 함수를 쓸까? 계산에 함수를 쓰는 일은 드물다 — 그 용도라면 연산자가 훨씬 낫다. 함수는 값으로서 중요하다. 함수는 변수에 할당할 수 있고, 다른 값과 똑같이 조작할 수 있다.

예전에 쓴 어떤 스펙에서 나는 작업(task)을 CPU에 할당해야 했다. 어떤 작업은 여러 CPU에 할당되어야 했지만, 각 CPU는 작업을 하나만 가져야 했다. 그 스펙에서는 각 작업을 CPU의 집합에 대응시키는 함수로 할당을 저장하는 것이 최선의 해법이었다.

variables
  assignments = [t \in Tasks |-> {}]

그러면 assignments[t] := assignments[t] \union {cpu}라고 써서 cpu를 작업 t에 할당할 수 있었다. 불변식(invariant)으로는 어떤 두 작업도 CPU 할당을 공유하지 않는다고 명시했다.

OnlyOneTaskPerCpu ==
  \A t1, t2 \in Tasks, c \in CPU:
    /\ (t1 # t2)
    /\ c \in assignments[t1]
    => c \notin assignments[t2]

“작업들이 CPU를 공유하지 않는다”는 말이 “할당 집합들이 서로소다”라는 말과 같다는 점에 주목하면, 이 불변식을 이렇게 쓸 수도 있다:

OnlyOneTaskPerCpu ==
  \A t1, t2 \in Tasks:
    (t1 # t2)
    => assignments[t1] \intersect assignments[t2] = {}

함수 집합

어떤 흐름인지 알 것이다: 새로운 부류의 값이 생기면, 그 값의 집합을 생성할 방법이 새로 필요하다. 함수 값도 타입 불변식에 넣어야 한다!

함수 집합(function set)의 문법은 [S -> T]이며, 뜻은 “정의역이 S이고 모든 값이 T에 속하는 모든 함수”다.1 앞의 작업 예제에서 assignments는 항상 함수 집합 [Tasks -> SUBSET CPUs]에 속하는 함수였다.

팁

[A -> B] 꼴의 함수 집합에는 원소가 #B#A개 있다. 작업이 두 개, CPU가 세 개라면 가능한 함수는 (23)2 = 64개다.

이렇게 기억하면 좋다: [1..n -> BOOLEAN]은 길이가 n인 모든 이진 문자열의 집합이고, 그런 문자열이 2n개라는 것은 우리가 이미 안다.

여기서도 집합 맵과 필터를 쓸 수 있다. 작업 하나가 최대 두 개의 CPU에만 할당될 수 있다고 해 보자. 원한다면 함수 집합을 이용해 이 조건을 타입 불변식에 녹여 넣을 수 있다:

TypeInvariant ==
  \* ...
  /\ assignments \in
    LET LeqTwoCPUs == {set \in SUBSET CPUs: Cardinality(set) <= 2}
    IN [Tasks -> LeqTwoCPUs]

다만 이 경우라면 나는 타입 불변식은 단순하게 두고, 추가 제약을 담은 두 번째 불변식을 쓰는 쪽을 택하겠다:

TypeInvariant ==
  /\ assignments \in [Tasks -> SUBSET CPUs]

AnotherInvariant ==
  \A t \in Tasks: Cardinality(assignments[t]) <= 2

함수 집합의 예를 몇 가지 더 들어 보자:

  1. 서버의 집합이 있고, 각 서버는 세 가지 상태 중 하나를 가질 수 있다. 그러면 status \in [Server -> {"online", "booting", "offline"}]이다.

  2. 방향 그래프를 점의 쌍에 대한 함수로 표현하는데, 이 함수는 두 점 사이에 간선이 있을 때 그리고 그때에만 참이다. 그러면 graph \in [Node \X Node -> BOOLEAN]이다.

  3. 앞의 집합을 연산자 GraphType으로 정의하면, 모든 무방향 그래프의 집합은 {g \in GraphType: \A n1, n2 \in Node: g[n1,n2] = g[n2,n1]}로 얻을 수 있다.

  4. 사용자와 자원의 집합이 있다면, 가능한 모든 할당의 집합은 [Resource -> User]일 수 있다. 일부 자원이 할당되지 않을 수도 있다면, 대신 [Resource -> User \union {NULL}]이 된다(여기서 NULL은 모델 값(model value)이다).

문제 해결

다음과 같은 에러가

Encountered “|->” in line X, column Y

함수 집합에서 나온다면, 아마 [S |-> T]라고 썼을 것이다. 올바른 형태는 [S -> T]다. 마찬가지로 다음과 같은 에러가

Encountered “->” in line X, column Y

함수에서 나온다면, 아마 [x \in S -> T]라고 썼을 것이다. 올바른 형태는 [x \in S |-> T]다. 걱정 마라, 누구나 언젠가 한 번은 둘을 헷갈린다.

예제: 정렬

함수 집합을 제대로 활용해 보자. 시퀀스가 오름차순으로 정렬되어 있는지 검사하고 싶다면 이렇게 쓸 수 있다:

IsSorted(seq) ==
  \A i, j \in 1..Len(seq):
    i < j => seq[i] <= seq[j]

그럼 시퀀스를 정렬하는 연산자는 어떨까? 구체적으로는 IsSorted(SortSeq(seq))가 항상 참이 되는 연산자 말이다. 그건 쉽다:

Sort(seq) ==
  <<>>

정의를 조금 고쳐서, 출력 시퀀스가 원소도 전부 똑같이 가지도록 해야 했다.

CHOOSE를 다룰 때 이야기했듯이, 원하는 속성(property)을 가진 시퀀스를 손으로 구성하기보다는 시퀀스의 집합을 가져와 원하는 속성을 가진 것을 골라내는 편이 더 쉽다는 것을 기억할 것이다.

시퀀스의 원소 집합은 이렇게 구할 수 있다는 것을 이미 알고 있다:

Range(f) == {f[x] : x \in DOMAIN f}

그러면 [DOMAIN seq -> Range(seq)]는 seq와 같은 원소들로 이루어진 모든 시퀀스의 집합이다. 우리가 만들 연산자는 대략 이런 모양이 된다:

Sort(seq) ==
  CHOOSE sorted \in [DOMAIN seq -> Range(seq)]:
    /\ \* sorted has the same number of each element as seq
    /\ IsSorted(sorted)

두 시퀀스가 각 원소를 같은 개수만큼 갖는지 알아내기 위해 CountMatching(f, val) 연산자를 정의하자. 이 연산자는 val과 일치하는 입력의 개수를 알려 준다. 집합의 크기를 구하려면 Cardinality가 필요한데, 이는 FiniteSets 모듈(module)에 있다.

CountMatching(f, val) ==
  Cardinality({key \in DOMAIN f: f[key] = val})

그다음에는 시퀀스의 모든 원소에 대해 이것을 검사하기만 하면 된다:

Sort(seq) ==
  CHOOSE sorted \in [DOMAIN seq -> Range(seq)]:
    /\ \A i \in DOMAIN seq:
      CountMatching(seq, seq[i]) = CountMatching(sorted, seq[i])
    /\ IsSorted(sorted)

어떤 입력에 대해 시험해 보자:

>>> Sort(<<8, 2, 7, 4, 3, 1, 3>>)
<<1, 2, 3, 3, 4, 7, 8>>

완벽하다!

다시, 중복 검사기

이번이 마지막이다, 약속한다.

중복 검사기의 지난 버전은 다음과 같았다:

노트

이 예제들에서는 모두 S <- 1..10이다.

---- MODULE duplicates ----
EXTENDS Integers, Sequences, TLC, FiniteSets
CONSTANT S
ASSUME Cardinality(S) >= 4

(*--algorithm dup
  variable seq \in S \X S \X S \X S;
  index = 1;
  seen = {};
  is_unique = TRUE;

define
  TypeInvariant ==
    /\ is_unique \in BOOLEAN
    /\ seen \subseteq S
    /\ index \in 1..Len(seq)+1
    
  IsUnique(s) == 
    \A i, j \in 1..Len(s): 
      i # j => seq[i] # seq[j] 

  IsCorrect == pc = "Done" => is_unique = IsUnique(seq)
end define; 

begin
  Iterate:
    while index <= Len(seq) do
      if seq[index] \notin seen then
        seen := seen \union {seq[index]};
      else
        is_unique := FALSE;
      end if;
      index := index + 1;
    end while;
end algorithm; *)
====

현재 S의 값은 모델마다 제어할 수 있는데, seq의 길이도 제어할 수 있으면 좋겠다. 그러면 원소 2개짜리 시퀀스와 20개짜리 시퀀스를 모두 테스트할 수 있다. 하지만 지금은 길이가 우리가 쓴 \X 곱의 개수로 하드코딩되어 있다.

함수 집합을 쓰면 이것을 단순화할 수 있다. S \X S \X S는 3-튜플의 집합이 된다. 이제 우리는 3-튜플이 정의역이 1..3인 함수라는 것을 안다. 그러면 [1..3 -> S] = S \X S \X S이다: 각 튜플의 모든 원소가 S의 값인 모든 3-튜플의 집합이다.

여기서부터 다섯 원소짜리 시퀀스로 확장하는 것은 간단하다:

 ASSUME Cardinality(S) >= 4
 
 (*--algorithm dup
-  variable seq \in S \X S \X S \X S;
+variable
+  seq \in [1..5 -> S];
   index = 1;
   seen = {};
   is_unique = TRUE;
상태 800000개 / 고유 상태 700000개 spec

S \X S \X S는 길이가 하드코딩되어 있지만 [1..3 -> S]는 값 — 정의역 집합의 크기 — 에 기반한다는 점에 주목하자. 이는 이것을 상수(constant)로 빼낼 수 있다는 뜻이다!

 ---- MODULE duplicates ----
 EXTENDS Integers, Sequences, TLC, FiniteSets
-CONSTANT S
+CONSTANT S, Size
 ASSUME Cardinality(S) >= 4
+ASSUME Size > 0
 
 (*--algorithm dup
 variable
-  seq \in [1..5 -> S];
+  seq \in [1..Size -> S];
   index = 1;
   seen = {};
   is_unique = TRUE;

팁

상태 스위핑(state sweeping)은 초기 시작 상태의 변수 하나로 다른 변수들의 매개변수를 제어하는 기법이다. 예를 들어 변수 하나가 입력 시퀀스의 길이나, 크기 제한 버퍼의 최대 크기를 결정하게 할 수 있다.

 
 (*--algorithm dup
 variable
-  seq \in [1..Size -> S];
+  n \in 1..Size;
+  seq \in [1..n -> S];
   index = 1;
   seen = {};
   is_unique = TRUE;
상태 876540개 / 고유 상태 765430개 spec

이제 길이 5인 시퀀스 전부가 아니라, 길이 5 이하인 시퀀스 전부를 검사한다!

엄밀히 말해 스위핑이 필수는 아니다: 충분히 영리하다면 같은 일을 하는 복잡한 연산자를 구성할 수 있다. 하지만 스위핑이 그렇게 하는 것보다 훨씬 쉬운 경우가 많고, 여러분의 두뇌를 실제 명세 과정에 쓸 수 있게 해 준다.

요약

  • 함수는 값의 집합을 다른 값의 집합으로 대응시킨다. [x \in set |-> Expr(x)]로 쓰고 f[value]로 호출한다.

    • 함수는 [x, y \in Set1, z \in Set2 |-> P(x, y, z)]처럼 쓰고 f[a, b, c](또는 f[<<a, b, c>>])로 호출할 수도 있다.

  • 함수의 정의역, 즉 대응의 출발점이 되는 집합은 DOMAIN f다.

    • a :> b는 함수 [x \in {a} |-> b]다.

    • f @@ g는 f와 g를 병합하며, f의 키를 우선한다.

  • 시퀀스는 정의역이 1..n인 특별한 종류의 함수일 뿐이다.

  • 구조체는 또 다른 특별한 종류의 함수로, [key1 |-> val1, key2 |-> val2]로 쓴다. struct["key1"](또는 struct.key1)로 호출한다.

  • 함수와 구조체는 둘 다 전용 집합 문법이 있다. 구조체는 [key1: set1]이다. 함수는 [A -> B]다.

1

T를 “공역(codomain)”이라고 부르기도 한다.