EXQT EXQT
cover

[Lua] Closure와 Coroutine

2021-01-04

이 글에선 Closure와 Coroutine이 뭔지 간략하게 알아보고 Lua에서 어떻게 사용하는지와 게임 개발에 어떻게 적용 될 수 있는지를 알아봅니다.

Closure

(Lexical) Closure는 함수가 선언될 당시의 환경(environment)을 기억했다가 나중에 호출되었을때 원래의 환경에 따라 수행되는 함수이다. 이름이 클로져인 이유는 함수 선언 시의 scope(lexical scope)를 포섭(closure)하여 실행될 때 이용하기 때문이다. 자주 ‘이름 없는 함수(익명함수)‘와 혼동되곤 한다. 많은 언어의 익명함수가 closure를 포함하기 때문에 편하게 부를땐 서로 구분없이 부르기도 한다.

이렇게 설명해도 처음들으면 잘 이해가 가지않습니다. 아래의 예제를 봅시다.

function newCounter ()
  local i = 0
  return function ()
    i = i + 1
    return i
  end
end

c1 = newCounter()
print(c1())  -- 1
print(c1())  -- 2
c2 = newCounter()
print(c2())  -- 1
print(c1())  -- 3

newCounter를 호출하면 익명함수를 하나 반환합니다. newCounter 가 종료 되었지만 반환 될 때의 상태를 그대로 기억있음을 알 수 있습니다.

Coroutine

코루틴(Coroutine)은 서브 루틴을 일시 정지하고 재개할 수 있는 구성 요소를 말한다. 쉽게 말해 필요에 따라 일시 정지할 수 있는 함수를 말한다.

Lua에서 코루틴 문법은 없고 모듈로 지원합니다. 아래의 예제를 봅시다.

local co = coroutine.create(function ()
  for i=1, 3 do
    coroutine.yield(i*i)
  end
end)

print(coroutine.resume(co)) -- true    1
print(coroutine.resume(co)) -- true    4
print(coroutine.resume(co)) -- true    9
print(coroutine.resume(co)) -- true
print(coroutine.resume(co)) -- false   cannot resume dead coroutine

wrap

코루린을 재실행 할 때 매번 coroutine.resume을 해주어야 해서 불편한데 coroutine.wrap을 쓰면 조금 더 편하게 쓸 수 있습니다. coroutine.wrap은 함수를 받아 호출 가능한 코루틴을 반환하는데 코루틴을 (재)시작 하려면 반환된 코루틴을 함수 호출 하듯이 호출하면 됩니다. 이 때 코루틴에서 yield된 값도 반환됩니다.

마지막에는 nil을 반환하며 한번 더 호출시에는 에러가 발생합니다.

local co = coroutine.wrap(function ()
  for i=1, 5 do
    coroutine.yield(i*i)
  end
end)

print(co(), co(), co()) -- 1 4 9
print(co(), co(), co() == nil) -- 16 25 true

또한 for문을 활용하여 코루틴을 실행할 수 있습니다.

for x in co do print(x) end -- 1 4 9 16 25

실전 예제

폭탄을 설치하면 일정주기로 “Tick” 소리를 내며 시간이 다 되면 “Bomb!” 하면서 터지는 폭탄을 만든다고 가정해봅시다.

로직을 적어보자면

  1. “Tick” 소리를 낸다.
  2. T초 기다린다.
  3. 1,2 를 N번 수행하지 않았다면 다시 1로 돌아간다.
  4. “Bomb!” 하고 터진다.

T초를 기다리는 동안 프로그램을 멈춰둘 수는 없습니다. 그렇다고 매번 폭탄 로직에 시간을 체크한다면 코드가 복잡해집니다. 2번에서 기다리는 동안 알아서 다른 루틴이 실행되고 시간이 되면 다시 폭탄 루틴이 수행되도록 하고 싶습니다.

Timer

우선 타이머를 구현해 봅시다.

local timers = {}
local currentTime = 0.0

function setTimer(time, f) -- time초 후에 f를 실행
  table.insert(timers, {time = time, onComplete = f, isCompleted = false})
end

function updateTime(dt)
  for _, timer in ipairs(timers) do
    timer.time = timer.time - dt
    if timer.time <= 0 and not timer.isCompleted then
      timer.onComplete()
      timer.isCompleted = true
    end
  end

  local activeTimers = {}
  for _, timer in ipairs(timers) do
    if not timer.isCompleted then
      table.insert(activeTimers, timer)
    end
  end

  timers = activeTimers
  currentTime = currentTime + dt
end

이제 타이머가 잘 작동하는지 간단하게 테스트 코드를 실행해봅시다.

setTimer(3, function print("3") end)
setTimer(1, function print("1") end)
setTimer(2, function print("2") end)
local dt = 1/60
for t=0, 4, dt do
  updateTime(dt)
end

타이머를 걸어둔 순서는 3, 1, 2 이지만 1, 2, 3 순으로 출력되는 것을 볼 수 있습니다.

runCoroutine

function runCoroutine(f)
  local co = coroutine.wrap(f)
  local wait = function(time)
    setTimer(time, co)
    coroutine.yield()
  end
  co(wait)
end

코루틴이 위에서 만든 타이머를 사용할 수 있도록 하는 함수를 하나 만들었습니다. 위에서 언급은 안했는데 wrap이 된 코루틴을 처음 실행할 때 매개변수로 뭔가를 넘길 수도 있습니다. wait함수를 넘겼는데 wait을 호출하게 된다면 코루틴이 멈추고 일정시간 뒤에 해당 코루틴이 다시 재시작됩니다.

이제 폭탄을 설치해봅시다.

function setBomb(name, tickInterval, nRepeat)
  runCoroutine(function(wait)
    for i=1, nRepeat do
      print(string.format("%s %.2f %s", name, currentTime, "Tick"))
      wait(tickInterval)
    end
    print(string.format("%s %.2f %s", name, currentTime, "Boom!"))
  end)
end
setBomb("B1", 1/2, 3)
setBomb("B2", 1/3, 2)
B1 0.00 Tick
B2 0.00 Tick
B2 0.33 Tick
B1 0.50 Tick
B2 0.67 Boom!
B1 1.00 Tick
B1 1.50 Boom!