루아 프로그래밍 팁

이 문서는 몇몇 루아 프로그래밍 개념과 기법들을 담고 있다. 이 문서는 원래 Game Development with Lua 번역서의 역자 부록으로 수록된 것이다. 따라서 문서에 나오는 본문은 그 번역서를 가리킨다.

(2005년에 루아 5.0을 기준으로 작성한 문서임을 주의하시길! —류광, 2022-10-30)

그 책이 루아 언어와 API를 충분히 다루고 있긴 하나, 루아의 모든 측면을 이야기하는 것은 아니다. 원서 독자라면 루아 프로그래밍의 바이블이라 할 수 있는 Programming in Lua[1] (이하 PIL)로 루아 언어와 루아 C API에 관련된 부족한 부분을 메울 수 있겠지만, 안타깝게도 국내에는 아직 PIL의 번역서가 출판되지 않은 상태이다.

그런 점을 감안해서 역자와 출판사(사이텍미디어)는 국내 독자들에게 도움이 될 수 있도록 몇 가지 주제들과 기법들을 담은, 원서에는 없는 부록을 제공하기로 했으며, 또한 부록의 내용을 이처럼 온라인 상에 공개하기로 했다. 물론 Game Development with Lua 번역서의 본문과 이 문서의 조합이 Programming in Lua(이하 PIL)를 대신할 수 있는 것은 절대 아니다.

지금 이 문서는 크리에이티브 커먼즈 저작자표시-동일조건변경허락 2.5의 조건 하에 자유로이 사용할 수 있으며, 이 문서의 최신 버전은 https://occamsrazr.net/view/GameDevWithLua에서 찾을 수 있다.

함수 인수의 단축 표기

루아 안에서 함수를 호출할 때, 함수의 유일한 인수가 문자열이나 테이블이면 괄호를 생략할 수 있다. 예를 들면 다음과 같다.

print"안녕하세요"  -- print("안녕하세요") 대신.

set{x=10, y=20} -- set({x=10, y=20}) 대신

문자열이나 테이블이 아닌 형식의 인수에서는 불가능하다. 예를 들어 다음은 구문 오류가 된다.

 print 10

이런 표기는 타이핑을 줄일 뿐만 아니라, 테이블에 사용하는 경우에는 루아 코드를 '자료 파일'과 좀 더 비슷하게 만들기 때문에 루아를 자료 설정의 용도로 사용할 때 유용하다. 예를 들어 본문 10장 '루아 기반 자료 파일'에 나온 예는 다음과 같이 고쳐 쓸 수 있다.

파일 players.lua:

entry{
    Name = "Ralph Hollywood",
    Gender = "Male",
    Model = "poker_player_02.mlm",
    -- 나머지 필드들도 마찬가지로....
}

entry{
    name = "Holly Ralphwood",
    Gender = "Female",
    Model = "poker_player_03.mlm",
    -- 나머지 필드들도 마찬가지로....
}

파일 entry.lua:

pokerPlayers = {}

function entry(t)
    table.insert(pokerPlayers, t)
end

dofile"players.lua" -- 항목들을 pokerPlayers에 추가

for i, t in ipairs(pokerPlayers) do
    for k, v in pairs(t) do
        print(k, v)
    end
end

프로그래밍에 익숙하지 못한 팀원이라면 본문 10장에 나온 예제의 것보다 이 players.lua의 형태가 훨씬 더 친숙하게 느껴질 것이다.

함수의 기본 인수

기본 인수란 함수를 호출할 때 명시적으로 값을 지정하지 않으면 어떤 기본값이 적용되는 인수를 말한다. 루아의 경우 기본 인수를 직접 지원하지는 않지만, 간단히 흉내 낼 수 있다. 다음은 who가 지정되지 않으면 "아무개"를 기본값으로 하는 예이다.

function hello(who)
    who = who or "아무개" -- 이것이 기본 인수 관용구
    print(who .. "님 안녕하세요?")
end
hello("박지성") -- 출력: 박지성님 안녕하세요?
hello() -- 출력: 아무개님 안녕하세요?

hello("박지성")의 경우 whonil이나 false가 아니므로 who = who에 의해 원래 값 "박지성"을 유지한다. hello()의 경우 whonil이므로 who = nil or "아무개"가 되고, 결국 who는 첫 번째의 참인 값인 "아무개"가 된다.

이런 관용구는 지정되지 않은 변수는 nil이 된다는 점과 본문 4장에서 이야기하는 논리 연산자 or의 작동 방식을 결합한 것이다. 4장에서는 가변 인수를 이용해서 기본 인수를 흉내 내는데, 지금 이 방법이 보다 명시적이며 arg 테이블을 만들지 않는다는 점에서 더 효율적이다.

클래스 흉내 내기

본문의 코드는 C 코드를 그대로 옮겨온 듯이 절차적인 스타일로 작성되어 있는 경우가 많다. 예를 들어 이런 스타일의 코드가 주로 쓰이는데,

for indx = 1, EnemyCount do
    UpdateEnemy(indx)
end

객체지향이나 프로그램 설계 부분에 관한 지식을 갖추고 있는 독자라면 이런 코드에서 전역 변수의 존재, 색인 관리의 번거로움과 실수 가능성 따위의 숨겨진 문제점을 발견할 수 있을 것이다.

객체지향 + 루아의 편리한 일반형 for 구문을 활용한다면 좀 더 현대적인 코드를 작성할 수 있다.

for _, enemy in ipair(Enemies) do
    enemy:update()
end

일반형 for에 관한 내용은 본문에서 다루고 있으므로, 여기서는 enemy:update()같은 표현을 어떻게 가능하게 하는지 이야기하겠다.

메서드 흉내 내기

enemy:update()라는 문장은 어떤 클래스의 enemy라는 인스턴스(객체)에 대해 update()라는 메서드를 호출한다는 뜻이다. 그런데 사실 루아에는 클래스, 인스턴스, 메서드 같은 개념이 없다. 루아에 있는 자료구조 개념은 오직 테이블뿐이다(실제로 enemy는 하나의 테이블일 뿐이다). 거기에, 함수 역시 테이블의 요소로 저장될 수 있고, 또 테이블에 속한 한 함수를 호출할 때 그 테이블 자신이 첫 번째 인수인 경우에는 : 기호를 이용해서 첫 번째 인수를 생략할 수 있다는 점에 의해 위와 같은 표현이 가능해진다. 좀 더 자세히 살펴보면:

enemy = {x = 1, y = 1, direction = "N"}
enemy.update = function(self)
    -- 실제 갱신 코드
    if self.direction == "N" then
        self.y = self.y - 1
    end
    -- 기타 등등
end

enemy:update()는 간단히 말해서 enemy.update(enemy)와 같다. 그리고 함수 정의 자체에서 :를 이용할 수도 있다. 이 경우 함수 서명의 self는 생략한다. 즉:

function enemy:update()
    -- 실제 갱신 코드
    if self.direction == "N" then
        self.y = self.y - 1
    end
    -- 기타 등등
end

클래스로부터 인스턴스 생성

그런데 위의 예가 온전한 클래스 개념을 보여주고 있는 것은 아니다. 무엇보다도, 위의 예는 enemy가 유일한 인스턴스이다. 다른 enemy 인스턴스를 만들려면 같은 코드를 반복해야한다. 클래스와 인스턴스를 제대로 흉내 내는 방법은 여러 가지가 있겠지만, 여기서는 PIL 16장에 나온 __index 메타메서드 방법을 소개해 보겠다.

-- 클래스로 쓰일 테이블. 멤버들과 그 기본값을 지정하는 의미로 봐도 된다.
Enemy = {x = 0, y = 0, direction = "N"}

-- 생성자에 해당
function Enemy:new(inst)
    inst = inst or {} -- new를 호출했을 때 인스턴스로 쓰일 테이블을 지정했다면
                      --그것을 사용하고, 아니면 빈 테이블을 사용한다.
    setmetatable(inst, Enemy) -- 인스턴스의 메타테이블로 Enemy를 설정
    self.__index = self -- __index 메타메서드를 self, 즉 Enemy 자신으로 설정
    return inst
end

-- 메서드들...
function Enemy:update()
    if self.direction == "N" then
        self.y = self.y - 1
    end
    -- ....
end

function Enemy:init()
    -- ...
end

메타테이블이란 테이블에 주어진 연산을 커스텀화할 수 있는, 일종의 연산자 중복적재 메커니즘이라고 할 수 있다. __index는 메타테이블이 가질 수 있는 메타메서드들 중 하나로, 주어진 테이블에 요청된 요소가 그 테이블에 없을 때 어디서 그 요소를 찾아야 하는지를 지정한다. 그럼 실제 사례를 가지고 설명해보자.

enemy = Enemy:new()

...

enemy:update()
print(enemy.x)

enemy = Enemy:new()라는 문장이 수행되면 enemy라는 인스턴스(사실은 테이블)가 생성된다. new() 호출 시 인자를 주지 않았으므로 enemy는 하나의 빈 테이블일 뿐이다. 이제 enemy:update()라는 문장을 만나면 루아는 enemy에서 update라는 이름의 요소를 찾는다. 그런데 enemy에는 그런 요소가 없으므로, enemy의 메타테이블의 __index가 가리키는 것, 즉 enemy를 본다. 거기에 update가 있으므로 그것을 호출한다. 이 때 :가 쓰였으므로 update() 안의 self는 enemy를 가리킨다.

이러한 __index 메커니즘은 테이블 요소에 접근하는 경우, 즉 읽는 경우에만 작동한다. 배정의 경우에는 적용되지 않는다. 예를 들어서 enemy.x = 100이라는 문장을 만나면 루아는 enemyx라는 요소가 있는지 점검하고, 없으면 새로 생성할 뿐이다(이때 __newindex라는 메타메서드가 쓰일 수 있는데, 이에 대해서는 PIL 13.4.2절을 참고하기 바란다). 따라서 enemy.x = 100이 실행되어도 Enemy.x의 값은 변하지 않는다.

C++에 경험이 있는 독자라면 인스턴스 생성 시 멤버들을 직접 지정하는 방법도 있어야 할 것이라고 생각할 텐데, 그런 용도로 생성자를 여러 개 만들 필요는 없다. inst = inst or {} 에 이미 그런 메커니즘이 갖추어져 있다. 예를 들어 x = 10, y = 20, direction = "S"인 인스턴스를 만들고 싶다면 다음과 같이 하면 된다.

enemy = Enemy:new{x=10, y=20, direction="S"}

상속

다음은 상속을 흉내 내는 예이다.

Monster = Enemy:new{name = 'unnamed'} -- Monster는 Enemy를 상속한다.
                                      -- 새로운 속성 name도 추가했다.
function Monster:update() -- 부모의 메서드를 재정의하는 예이다.
    print(self.name, "update")
end

function Monster:draw()   -- 부모에 없는 새로운 메서드를 추가하는 예.
     print(self.name, "draw")
end

__index의 메커니즘을 이해했다면 이러한 방식의 상속이 어떤 식으로 구현되는지 분석할 수 있을 것이므로, 설명은 생략하겠다. 연습 삼아, 다음 코드의 실행 흐름을 따라가 보기 바란다.

orc = Monster:new{name = 'Orc'}
print(orc.name)
orc:update()
orc:draw()

loadfile/loadstring과 assert

루아 표준 라이브러리 함수 loadfileloadstring은 루아 스크립트가 담긴 파일 또는 루아 스크립트 문자열을 불러와서 컴파일한다(두 함수는 불러올 것이 파일이냐 문자열이냐만 빼고는 동일하게 작동한다). 이 함수들의 중요한 특징은, 주어진 스크립트 코드를 컴파일 할 뿐 실행은 하지 않는다는 점이다. 예를 들어서 다음은:

loadstring("print(100)")

아무 것도 출력하지 않는다. 단지 print(100)이라는 스크립트 코드를 컴파일 할 뿐이다. print 함수가 실제로 실행되게 하려면 다음처럼 해야 한다.

loadstring("print(100)")() -- 100을 출력

이렇게 되는 이유는, loadstringloadfile이 주어진 스크립트 코드를 하나의 이름 없는 함수로 감싸서 돌려주기 때문이다. 다음을 보면 좀 더 확실해 질 것이다.

f = loadstring("print(100)")
print(type(f)) -- function을 출력
f() -- 100을 출력

본문의 5장 "표준 라이브러리" 절에 나오는 assert의 용법은 이상과 같은 loadstring(그리고 loadfile)의 특징을 이용한 것이다. 그렇다면, 그냥 loadstring만 사용하는 것과 assert를 사용하는 것의 차이는 무엇일까? 본문에서도 잠깐 언급되어 있듯이, 디버깅에는 assert 쪽이 더 도움이 된다.

assert 자체는 다른 언어들의 assert 명령 또는 함수와 마찬가지로 그냥 반드시 만족해야 할 조건을 단언(assertion)하는 수단일 뿐이다. 루아의 assert는 첫 번째 인수가 유효한 루아 값(nilfalse를 제외한 모든 것)인지를 점검한 후, nil 또는 false이면 두 번째 인수로 주어진 메시지를 출력하고 스크립트를 종료한다.

a = -1
...
assert(a > 0, "a가 0보다 작을 수는 없음")

한편, loadstring()은 주어진 스크립트 코드를 컴파일하는 과정에서 오류가 있으면 nil을 돌려주며, 두 번째 반환값으로는 컴파일 오류 메시지를 돌려준다. 본문에서 이야기한 "디버깅에 도움이 된다"는 바로 그 두 번째 오류 메시지 때문이다. 만일 loadstring만 사용한다면, 잘못된 스크립트 문자열이 주어져도 루아는 그냥 nil에 대한 오류만을 낸다.

> loadstring("print(")()
stdin:1: attempt to call a nil value
stack traceback:
        stdin:1: in main chunk
        [C]: ?
>

이 문장이 실패한 진짜 이유는 "print("가 유효한 루아 문장이 아니라는 데 있다. 이 때문에 loadstring은 컴파일에 실패하고, 그래서 nil과 오류 메시지를 돌려준다. 그런데 지금처럼 loadstring만 사용하면 두 번째 반환값인 오류 메시지는 그냥 폐기되고(받는 곳이 없으므로), 첫 번째 반환값 nil에 대해 함수 호출 연산자 ()가 적용된다. nil은 유효한 함수가 아니므로 루아는 그냥 nil을 호출하려 했다는, 잘못된 코드 문자열의 실제 문제와는 거리가 먼 오류 메시지를 내는 것이다.

반면 assert를 사용할 때에는 assertloadstring의 두 번째 반환값을 표시해 주므로, 다음 예처럼 진짜 이유를 직접적으로 알 수 있게 된다.

> assert(loadstring("print("))()
stdin:1: [string "print("]:1: unexpected symbol near `<eof>'
stack traceback:
        [C]: in function `assert'
        stdin:1: in main chunk
        [C]: ?
>

dofile과 require

루아 스크립트에서 다른 루아 스크립트를 불러올 때 사용할 수 있는 루아 표준 함수로, 본문에서 주로 쓰인 dofile 이외에 require가 있다. 둘 다 인수로 주어진 파일을 불러와서 컴파일하고 실행한다. requiredofile과 다른 점이 두 가지 있다. 첫째는, 해당 루아 환경이 실행된 이후 한 번이라도 불러온 적이 있는(따라서 이미 메모리에 컴파일된 상태로 존재하는) 스크립트라면 다시 불러오지 않는다는 점이다. 예를 들어서 다음과 같은 코드가 실행된다고 할 때,

require"enemy.lua"
...
require"enemy.lua"

두 번째 require"enemy.lua"는 아무런 일도 하지 않는다. 스크립트 중에는 실행 도중 한 번만 적재되면 되는(또는 한 번만 적재되어야 하는) 것이 있다. 예를 들어 본문 예제들의 LuaSupport.lua처럼 여러 스크립트들이 공통적으로 사용하는 스크립트라면 이처럼 require를 사용하는 것이 효율적이다. 또한, 경우에 따라서는 불필요한 논리적인 오류를 방지할 수도 있다. 둘째는, require는 미리 지정된 경로들에서 파일을 찾는다는 점이다. dofile은 현재 디렉터리를 기준으로 파일을 찾고 없으면 오류를 내지만, require는 전역 변수 LUA_PATH[2](만일 설정되어 있지 않으면 운영체제의 환경 변수 LUA_PATH, 거기에도 없으면 컴파일 시 지정된 값인 ?;?.lua)에 지정된 경로들을 차례로 탐색해서 파일을 찾는다. 다음은 LUA_PATH에 지정할 수 있는 경로 문자열의 예이다.

?;?.lua;c:\lua\mylib\?;c:\lua\mylib\?.lua

?require에 주어진 인수로 확장된다. ;는 각 경로를 구분하는 표시이다. 예를 들어 require"enemy"라고 했다면 루아는 다음과 같은 파일들을 차례로 찾으면서 가장 먼저 발견한 것을 적재한다.

  1. enemy (?에 해당)
  2. enemy.lua (?.lua에 해당)
  3. c:\\lua\\mylib\\enemy (c:\\lua\\mylib\\?에 해당)
  4. c:\\lua\\mylib\\enemy.lua (c:\\lua\\mylib\\?.lua에 해당)

정리하자면, 루아의 require는 다른 언어의 '라이브러리' 또는 '패키지' 개념을 기초적이나마 지원하는 수단이라고 할 수 있다. 루아 5.1에서는 require가 루아 스크립트뿐만 아니라 공유 라이브러리(DLL, SO) 형태로 만들어진 루아글루 함수들의 라이브러리도 불러올 수 있도록 개선된다고 한다. 예를 들어 루아글루 함수들과 적절한 라이브러리 초기화 함수를 담은 glue.dll이라는 파일이 있다고 할 때, require"glue"는 그 DLL의 라이브러리들을 루아 환경으로 불러들인다. 루아 5.0에서도 그런 기능을 사용할 수 있도록 해주는 Compat-5.1이라는 확장 라이브러리도 있는데, 이에 대해서는 Compat-5.1 홈페이지를 참고하기 바란다.

테이블을 루아 파일로 익스포트

본문 10장 후반부의 예제들에서는 비슷한 문장이 계속 반복되는 코드가 나오는데, 그런 코드를 직접 작성하게 되면 지겨울 뿐만 아니라 실수를 범하기도 쉽다. 임의의 테이블로부터 그 테이블을 다시 복원하는 코드를 자동으로 생성한다면 그와 같은 문제를 해결할 수 있다.

다행히, 키라는 것은 문자열 색인일 뿐이며(예를 들어 enemy.IDenemy["ID"]이다) pairs() 함수를 이용해서 테이블의 모든 색인들을 얻는 것이 가능하다는 점을 이용한다면 임의의 테이블을 파일에 기록하는 작업을 거의 완전히 자동화할 수 있다. 이번 절에서는 바로 그런 자동화 함수를 만들어 보겠다.

우선 테이블의 각 요소와 값을 모두 나열하는 가장 기본적인 방법을 살펴보자.

t = { 100, x = 10, alive = true, "문자열",  }
for k, v in pairs(t) do
    print(k, v)
end

이를 실행하면 다음과 같은 출력이 나온다.

1       100
2       문자열
x       10
alive   true

테이블에 지정된 순서와는 달리 숫자 색인 요소들이 가장 먼저 나왔는데 문제될 것은 없다. 이제 이런 코드를 함수로 만들고, 또 단순히 요소 색인과 값을 출력하는 것이 아니라 하나의 루아 배정문이 되도록 해보자.

function serialize(table_name)
    local t = _G[table_name]
    for k, v in pairs(t) do
        print(table_name .. "[" .. k .. "] = " .. v)
    end
end

함수 첫 줄의 local t = _G[table_name]을 주목하자. 루아 배정문을 만들기 위해서는 테이블 이름이 필요한데, 테이블 변수 자체에는 자신의 이름이 없다. 그래서 테이블 변수의 '이름'을 매개변수로 받고 루아 전역 테이블 _G에서 그 이름에 해당하는 테이블을 가져온다. 루아의 모든 전역 변수와 전역 함수는 전역 테이블 _G의 문자열 색인 요소라는 점을 이용하는 것이다. 이제 이 함수를 시험해 보자.

t = { 100, x = 10, "문자열"}
serialize("t")

이를 실행하면, 다음과 같은 출력이 나온다.

t[1] = 100
t[2] = 문자열
t[x] = 10

세 가지 문제점이 있다. 첫째는 문자열이 따옴표로 감싸이지 않았고, 그래서 유효한 루아 문장이 아니게 되었다는 것이다. 둘째는, 심각한 문제는 아니지만 문자열 색인 요소를 숫자 색인 요소와 동일하게 취급한다는 것이다. t\[x\] = 10도 유효한 문장이긴 하지만, t.x = 10 쪽의 가독성이 더 좋다. 셋째는, 위의 코드에는 드러나지 않지만 테이블에 그 값이 수치와 문자열이 아닌 요소, 예를 들어 부울이나 테이블 요소가 있으면 오류가 난다는 것이다. 예를 들어 원래의 예였던

t = { 100, x = 10, alive = true, "문자열"}

serialize()를 호출하면 부울 값인 true를 문자열과 결합하려 한다는 오류가 난다.

그럼 첫째 문제부터 해결해 보자. 요소가 문자열이면 그것을 따옴표로 감싸주면 된다.

function serialize(table_name)
    local t = _G[table_name]
    for k, v in pairs(t) do
        if type(v) == "string" then
            v = string.format("%q", v)
        end
        print(table_name .. "[" .. k .. "] = " .. v)
    end
end

string.format("%q", v)는 문자열 안에 큰따옴표가 있는 경우를 처리하기 위한 것으로, 문자열 안에 큰따옴표가 있으면 그 앞에 역슬래시가 붙게 된다. 이제 t = { 100, x = 10, "문자열"}serialize()를 호출하면:

t[1] = 100
t[2] = "문자열"
t[x] = 10

둘째 문제 역시 비슷한 방식으로 해결할 수 있다. 색인이 문자열이면 다른 식으로(대괄호 대신 마침표를 이용해서) 배정문을 만들면 되는 것이다.

function serialize(table_name)
    local t = _G[table_name]
    for k, v in pairs(t) do
        if type(v) == "string" then
            v = string.format("%q", v)
        end
        if type(k) == "number" then
            print(table_name .. "[" .. k .. "] = " .. v)
        else
            print(table_name .. "." .. k .. " = " .. v)
        end
    end
end

이제 다음과 같은 출력을 얻을 수 있다.

t[1] = 100
t[2] = "문자열"
t.x = 10

색인이 숫자도 아니고 문자열도 아닌 경우도 있다. 예를 들어 부울 값이나 함수일 수도 있고 테이블일 수도 있는데, 색인이 함수인 경우는 지금의 '게임 저장 자료를 위한 코드 생성 자동화'라는 맥락에서는 드문 일이므로 생각하지 않기로 하자. 테이블들의 테이블은 흔히 쓰이므로 테이블 요소는 반드시 처리해야 하는데, 잠시 후에 이야기하기로 하고 일단 간단한 부울 값부터 처리해보자.

function serialize(table_name)
    local t = _G[table_name]
    for k, v in pairs(t) do
        if type(v) == "string" then
            v = string.format("%q", v)
        end
        if type(v) == "boolean" then
            if v then v = "true"
            else v = "false"
            end
        end

        if type(k) == "number" then
            print(table_name .. "[" .. k .. "] = " .. v)
        else
            print(table_name .. "." .. k .. " = " .. v)
        end
    end
end

이제 t = { 100, x = 10, alive = true, "문자열"}에 대해 다음과 같이 제대로 된 결과를 얻을 수 있다.

t[1] = 100
t[2] = "문자열"
t.x = 10
t.alive = true

그럼 테이블 요소를 다루는 방법에 대해서 살펴보자. 아마 짐작했겠지만, 지금까지 만든 함수를 재활용하면 된다. 즉, 재귀 호출을 사용하면 되는 것이다. 다음이 내부 테이블을 재귀적으로 출력하는 기능이 추가된 serialize() 함수이다.

function serialize(table_name, t)
    local t = t or _G[table_name]
    for k, v in pairs(t) do
        if type(v) == "string" then
            v = string.format("%q", v)
        end
        if type(v) == "boolean" then
            if v then v = "true"
            else v = "false"
            end
        end

        if type(v) == "table" then
            if type(k) == "number" then
                serialize(table_name .. "[" .. k .. "]", v)
            else
                serialize(table_name .. "." .. k, v)
            end
        else
            if type(k) == "number" then
                print(table_name .. "[" .. k .. "] = " .. v)
            else
                print(table_name .. "." .. k .. " = " .. v)
            end
        end
    end
end

재귀 호출 시의 테이블은 더 이상 전역 테이블에 존재하는 것이 아니므로 테이블 이름과 함께 테이블 자체도 직접 지정해 주었다는 점만 주의한다면 이해하기가 어렵지 않을 것이다. print 문을 파일 출력 함수로 바꾼다면 임의의 형태의 테이블을 파일에 저장하는 함수가 된다.

이 함수는 재귀 호출을 사용하는데, 테이블이 순환 참조되어 있는 경우(예를 들면 a[1] = a 등)에는 무한 재귀에 빠질 위험이 있다. 무한 재귀에 빠지지 않으려면 이미 한 번 출력한 테이블의 이름을 기억해 두어야 하는데, PIL 12.1.2절에 좋은 예가 나와 있으니 참고하기 바란다.

그 외에 결과의 가독성 측면에서 개선할 점도 하나 있다. 이 함수는 예를 들어서 다음과 같은 형태의 결과를 출력하는데,

tt[1] = 100
tt[2][1] = 1
tt[2][2] = 2
tt[2][3] = 3
tt.inner[1] = 100
tt.inner[2] = "문자열"
tt.inner.x = 10
tt.inner.alive = true
tt.x = 10

가독성 측면에서는 다음과 같은 형태가 더 바람직할 것이다.

tt = {
    100,
    {
        1,
        2,
        3
    },
    inner = {
        100,
        "문자열",
        x = 10,
        alive = true
    },
    x = 10
}

이런 형태의 출력을 만들어 내도록 serialize()를 고치는 것이 조금 까다로울 수는 있지만, 그래도 어려운 일은 아닐 것이다. 이 부분은 독자의 숙제로 남겨 두겠다.

테이블 요소 개수 얻기

앞 절의 숙제를 풀기 위해서는 테이블 요소 개수를 알아야 할 수도 있을 것이다. 그런데 테이블에 담긴 요소 개수를 얻을 때 table.getn()에만 의존해서는 안 된다. 예를 들어 다음과 같은 테이블이 있다고 할 때,

t = { 1, 2, 3, a = 10}
t[10] = "abc"

table.getn(t)는 3을 돌려줄 뿐이다. 이는 table.getn()이 테이블에 n이라는 이름의 수치 요소가 있으면 그 값을 돌려주고, 그런 요소가 없으면 값이 nil인 최초의 수치 색인 요소가 나올 때까지만 요소를 세어서 그 개수를 돌려주기 때문이다. 이 예의 경우 t[4]가 최초의 nil이므로 3을 돌려주는 것이다.

테이블의 모든 요소들의 개수를 구하는 방법은 여러 가지가 있겠지만, 다음 함수처럼 해도 나쁘지 않을 것이다. for ... in pairs() 구문을 알고 있는 독자에게는 자명한 코드이므로 설명은 생략한다.

function getTableSize(t)
    local size = 0
    for _, _ in pairs(t) do
        size = size + 1
    end
    return size
end

다중 반환, 다중 배정

C, C++ 같은 전통적인 프로그래밍 언어들과 달리, 루아에서는 함수가 여러 개의 값들을 돌려줄 수 있다. 또한 하나의 배정문으로 여러 개의 값들을 배정할 수도 있다. 이 덕분에 몇 가지 재미있는 기법들이 가능하다.

변수 교환

정렬(sort) 등의 작업에서는 두 변수의 값을 교환해야 하는 경우가 생긴다. C, C++에서 변수의 값을 서로 교환할 때에는 다음과 같이 임시 변수가 필요하다.

void swap(int a, int b)
{
    int temp = a;
    a = b;
    b = temp;
}

루아에서는 그냥 이렇게 하면 된다.

a, b = b, a

등호 좌변에서는 b의 값과 a의 값을 차례로 스택에 쌓고, 등호 우변에서는 스택에서 차례로 두 값을 뽑아서 a와 b에 넣는다고 생각하면 이해할 수 있을 것이다.

본문 11장의 SortScoreLists() 함수를 보면 정렬 도중 테이블의 두 요소를 교환하기 위해 다음과 같은 코드를 사용하는데, 이는 위에 나온 C++ 방식을 루아에 그대로 적용한 것이다.

         t =  myHighScoresAmount[i]
         myHighScoresAmount[i] = myHighScoresAmount[i-1]
         myHighScoresAmount[i-1] = t

루아 고유의 스타일로 작성한다면 다음과 같이 좀 더 간단한 코드가 된다.

         myHighScoresAmount[i], myHighScoresAmount[i-1] =
                  myHighScoresAmount[i-1], myHighScoresAmount[i]

반환값들을 인수 목록 또는 테이블 초기화에 사용

본문의 목록 12.3에 나온 GetBoardLocation(myX, myY) 함수는 두 개의 인수를 받는다. 그런데 MakeMove() 첫머리에는 다음처럼 인수 하나만을 지정하는 듯한 호출문이 있다.

thePos = GetBoardLocation(GetMousePosition())

C++ 같은 전통적인 언어에 익숙한 독자라면 좀 의아할 수도 있겠지만, 루아에서 이것은 유효한 코드이다. 왜냐하면 GetMousePosition() 함수가 두 개의 반환값을 돌려주며 그 두 반환값들이 차례로 GetBoardLocation()의 두 인수가 되기 때문이다. 좀 더 간단한 함수들로 예를 들자면 다음과 같다.

function getTwoValues()
    return 1, 2  --- (1)
end

function takeTwoArgs(a, b)
    print(a, b)
end
takeTwoArgs(getTwoValues())

takeTwoArgs(getTwoValues())는 (1)에 의해 takeTwoArgs(1, 2)가 된다.

다중 반환을 이용한 테이블 초기화와 다중 변수 배정

이러한 기능을 테이블 초기화에도 사용할 수 있다. 일반적인 테이블 초기화는 다음과 같은 형태이다.

a = {1, 2, 3}

그런데 {와 } 안에 함수 호출문을 집어넣으면, 그 함수의 반환값들이 차례로 배열의 요소가 된다. 예를 들면 다음과 같다.

funcition foo()
    return 1, 2, 3
end

a = {foo()}          -- a = {1, 2, 3}과 동일한 효과

테이블 요소들을 인수들로 - unpack()

반대로, 여러 개의 인수들을 받는 함수를 호출할 때 테이블의 각 요소를 각각의 인수가 되도록 만들 수 있다. unpack()이라는 루아 표준 라이브러리 함수를 사용하면 된다.

t = {10, 20, 30}
print(unpack(t))    -- print(10, 20, 30)과 동일한 효과

배정문에서도 사용할 수 있다.

t = {10, 20, 30}
a,b = unpack(t)     -- a=10, b=20과 동일한 효과. 30은 폐기된다.

print(t[1], t[2], t[3])이나 a = t[1], b = t[2]에 비해 훨씬 간결한 코드가 되었다.

고급 테이블 정렬

본문 11장의 SortScoreLists()는 정렬 알고리즘을 직접 구현한다. 단순히 하나의 테이블을 정렬하는 것이 아니라 서로 연관된 두 테이블(점수들을 담은 테이블과 그에 해당하는 날짜를 담은 테이블)을 정렬해야 하기 때문이다. 그러나 서로 연관된 자료를 그렇게 개별적인 테이블에 담아 두는 것은 바람직하지 않다. 자료 구조의 차원에서 볼 때에는 다음과 같은 형태가 더 바람직할 것이다.

hiscores = {
    { score = 100, date = '2005-10-01 18:01'},
    { score = 2000, date = '2005-10-01 11:20'},
    { score = 132, date = '2005-10-02 09:20'}
}

문제는, 단순한 table.sort(table) 형태로는 이런 테이블을 정렬할 수 없다는 것이다. 그냥 table.sort(hiscores)라고 하면 두 테이블을 비교하려 했다는 오류 메시지가 나올 뿐이다. 이런 테이블을 원하는 방식대로(이 예의 경우 내부 테이블의 score의 내림차순으로) 정렬하기 위해서는 두 번째 인수로 비교에 쓰이는 함수를 받는 table.sort(table, compare_function) 형태를 사용해야 한다.

table.sort(table, compare_function) 형태의 경우 table.sort 함수는 비교할 두 요소를 인수들로 해서 compare_function을 호출하고, compare_function이 true를 돌려주면 두 요소를 교환한다. 위의 hiscores 배열을 점수의 내림차순으로(즉, 가장 큰 점수가 가장 앞에 나오도록) 정렬하기 위한 비교함수는 다음과 같다.

function comp(a, b)
    if a.score > b.score then
        return true
    end
end

이제 다음을 실행하면:

table.sort(hiscores, comp)

for k, v in hiscores do print(v.score, v.date) end

hiscores 테이블이 원하는 방식대로 정렬되었음을 확인할 수 있다.

2000    2005-10-01 11:20
132    2005-10-02 09:20
100    2005-10-01 18:01

만일 오름차순으로 정렬한다면 comp 함수의 if 문에 있는 >를 \<로 바꾸면 된다. 그리고 날짜순으로 정렬한다면 score 대신 date를 사용하면 된다.

C++에서 루아 테이블 다루기

본문 13장의 루아글루 함수 GetMove()는 게임판 테이블의 각 요소를 일일이 인수로 받는다.

thePos = GetMove(myBoard[1],myBoard[2],myBoard[3],myBoard[4],
    myBoard[5],myBoard[6],myBoard[7],myBoard[8],myBoard[9],OH)

이보다는 그냥 테이블 자체만 지정하면 되도록 하는 게 더 깔끔할 것이다.

thePos = GetMove(myBoard,OH)

이런 호출이 가능하려면 C++로 된 루아글루 함수 쪽에서 테이블의 요소들에 직접 접근할 수 있어야 하는데, 그리 어렵지 않다. C++에서 루아 테이블의 요소를 읽을 때에는 lua_gettable() API 함수를 사용한다. 이 함수의 원형은 다음과 같다.

void lua_gettable (lua_State *L, int index);

index는 루아 스텍에서 테이블(요소 값을 얻고자 하는)이 있는 곳을 가리킨다. 이 함수는 루아 스택 최상위에서 값을 뽑고, 그것을 키로 해서 테이블의 요소를 찾고, 그 요소의 값을 루아 스택에 쌓는다. 따라서 테이블의 한 요소의 값을 얻는 과정은 다음과 같이 요약할 수 있다.

  1. 얻고자 하는 요소가 있는 테이블을 스택에 쌓고(테이블을 인수로 하는 루아글루 함수가 호출된 상황이라면 이미 쌓여 있다)

  2. 얻고자 하는 요소의 키를 스택에 쌓고,

  3. lua_gettable(L, index)를 호출하고,

  4. 스택에서 요소의 값을 읽고 스택에서 제거한다.

  5. 마지막으로 필요하다면 테이블 자체도 스택에서 제거한다.

3에서의 index는 테이블을 스택에 직접 쌓는다면 -2가 되고(최상위는 키, 그 다음이 테이블), 루아글루 함수 호출에 의해 테이블이 이미 쌓여 있다면 테이블 인수가 어디인가에 따라 달라진다. 예를 들어 thePos = GetMove(myBoard,OH)가 호출된 경우 스택 최상위에는 OH, 그 아래에 myBoard가 있는 셈이므로, 키를 스택에 쌓고 나면 myBoard는 위에서 세 번째 위치(-3)가 된다.

다음은 루아글루 함수 GetMove의 C++ 구현인 TTT_GetMove() 코드 중에서 thePos = GetMove(myBoard,OH)에 맞게 두 인수들을 처리하는 부분이다.

    ...

    for(int i=0; i<9; i++)
    {
        lua_pushnumber(L, i+1);
        lua_gettable(L, -3);
        board[i] = (int)lua_tonumber(L, -1);
        lua_pop(L, 1);  // 요소 제거
    }
    lua_pop(L, 1); // 테이블 자체를 스택에서 제거

    // 이제 스택 최상위에는 GetMove의 두 번째 인수가 있다.
    int sidetomove = (int) lua_tonumber(L, -1);

    ...

이것을 실제로 컴파일하려면 해당 소스 파일 상단에서 루아 API 관련 헤더들을 포함시켜야 한다(cLua.cpp를 참고할 것). 그리고 이 코드는 오류 점검을 전혀 수행하지 않는다는 점에 주의하기 바란다. 실제 코드라면 예를 들어 lua_gettable()을 호출하기 전에 스택의 위에서 세 번째(-3)에 있는 것이 정말로 테이블인지를 lua_istable 함수로 점검하는 것이 바람직할 것이다. 루아 매뉴얼 3.4절에 여러 가지 점검 함수들이 나와 있다.

debug.getinfo() 활용

본문 15장 "디버그 정보 파일" 출력에 DebugMsg라는 루아 함수가 나온다. 이 함수는 다음과 같은 형태로 사용한다.

DebugMsg(string.format("%s%d","function: CalculateRange(), curRange: ",
           curRange))

이것은 현재의 함수(DebugMsg를 호출한 함수)의 이름과 추적하고자 하는 변수 이름, 그리고 그 변수의 값을 로그 파일에 기록한다. 그런데 DebugMsg를 호출할 때마다 현재 함수 이름을 일일이 매개변수에 지정한다면 지겨울 뿐만 아니라 실수를 하기도 쉽다. 이 문제는 실행 시점에서 함수 호출에 대한 정보를 돌려주는 루아 표준 함수 debug.getinfo()로 해결할 수 있다. 이 함수의 원형은 다음과 같다.

debug.getinfo (function [, what])

매개변수 function은 정보를 얻고자 하는 함수의 호출 깊이를 결정하는 정수이다. 0은 debug.getinfo() 자체, 1은 debug.getinfo()를 호출한 함수, 2는 debug.getinfo()를 호출한 함수를 호출한 함수 등등이다.

매개변수 what은 함수에 대해 어떤 것을 알고 싶은지를 결정하는데, 생략하면 debug.getinfo()는 자신이 알려줄 수 있는 모든 정보를 테이블에 담아서 돌려준다. 지금 문맥에서 유용한 필드는 함수의 이름에 해당하는 name이다. 그리고 함수가 정의되어 있는 소스 파일 이름에 해당하는 source와 함수가 호출된 행번호를 의미하는 currentline도 유용하다. 다음은 이들을 이용해서 DebugMsg()를 좀 더 편하게 호출할 수 있도록 하는 함수이다.

function HERE()
    local info = debug.getinfo(2)
    return info.source .. ":" .. info.currentline .. " "
        .. info.name .. "()"
end

debug.getinfo()에 2를 지정했으므로, debug.getinfo()는 이 HERE()를 호출한 함수에 대한 정보를 돌려준다.(이는 이 HERE()를 반드시 다른 어떤 함수의 안에서 호출해야 한다는 뜻이기도 하다.) HERE()는 그 정보에서 필요한 것만을 뽑아 적당히 결합해서 자신을 호출한 함수에 돌려준다. 그럼 사용례를 보자.

 9: ....
10: function f(curRange)
11:     local curRange = 10
12:     DebugMsg(HERE() .. ", curRange : " .. curRange))
13: end

함수 f()가 C:\Lua\test.lua에 있다고 하면 출력은 다음과 같다.

@C:\Lua\test.lua:12 f(), curRange: 10

DebugMsg를 호출한 곳의 소스 파일 및 줄 번호, 함수 이름이 제대로 나와 있음을 알 수 있다.

함수 고급 기법 소개

본문의 내용과 직접적으로 관련된 것은 아니지만, 좀 더 진보된 또는 정교한 루아 코드의 작성에 도움이 되는 기법들이 있다. 닫힘, 코루틴, 반복자 등이 그에 속한다. 이들은 위에서 이야기한 것들에 비해 용법이나 배경에 깔린 이론이 보다 넓고 깊기 때문에 이 부록에서 상세히 이야기하기는 힘들다(그렇게 한다면 PIL의 해당 부분을 번역하는 일과 별 차이가 없을 것이다). 여기서는 독자의 학습 의욕을 이끌어내는 수준에서 간단한 소개와 예제만을 제시한다. 이후 여건이 된다면 좀 더 자세한 내용을 작성해서 온라인으로 공개하겠다.

닫힘

루아에서 함수는 배정과 전달, 복사가 가능한 '일급 객체(first-class)'이다. 간단히 말하면 함수를 그냥 하나의 값처럼 사용할 수 있다. 특히 함수에 이름을 붙이지 않고 즉석에서 함수(의 정의)를 함수 호출의 인수로 사용하거나 함수의 반환값으로 사용할 수 있다. 다음은 이 부록의 '고급 테이블 정렬'에 나온 예제를 이름 없는 함수를 이용해서 좀 더 간결하게 작성한 것이다.

table.sort(hiscores,
    function (a, b)    -- 함수의 이름이 없음을 주목.
        if a.score > b.score then
            return true
        end
    end
)

닫힘(closure)은 이런 이름 없는 함수의 확장으로, 아주 단순하게 이야기하자면 함수 외부의 지역 변수를 함수와 한데 묶음으로써(그러한 외부 지역 변수를 윗쪽값upvalue이라고 부른다) 함수가 자신만의 '상태'를 가질 수 있게 하는 것이다. 다음에 간단한 예가 있다.

function makeCounter(incr)
    local cnt = 0
    return function()
        cnt = cnt + incr
        return cnt
    end
end

myCounter = makeCounter(2)
anotherCounter = makeCounter(1)

print(myCounter())      -- 2를 출력
print(myCounter())      -- 4를 출력
print(anotherCounter()) -- 1을 출력

makeCounter()는 매번 개별적인 함수 '객체'를 돌려주며 그 객체는 자신만의 cnt와 incr 변수를 유지한다는 점이 핵심이다. 출력을 보면 myCounter 함수의 cnt와 incr가 호출들 사이에서 값을 유지함을 알 수 있으며(그렇지 않았다면 두 번째 호출에서 여전히 2를 출력했을 것이다), 또한 anotherCounter의 cnt, incr는 myCounter의 것과는 무관함을 알 수 있다.

닫힘은 PIL 6.1절에 자세히 나와 있다.

협동루틴

일반적으로 함수는 호출되고 반환되는 주기를 거친다. 호출된 함수는 모든 것을 처음부터 다시 시작하며, 반환되면 그 전까지의 일은 모두 잊어버린다. 협동루틴(coroutine, 코루틴)은 그런 엄격한 호출-반환 구조에서 벗어나서 함수 실행 도중에 제어권을 호출자에게 임시로 돌려주고, 나중에 그 지점으로부터 실행을 다시 재개하게 하는 기능이다. 이를 통해서 두 함수가 어떠한 일을 '협동적으로' 수행할 수 있게 된다. 여러 가지 용법이 가능하지만, 여기서는 일종의 '생산자-소비자' 모형을 반영하는 간단한 예제 하나로 마무리하겠다. 테이블에서 문자열 요소만을 출력하는 코드인데, 두 개의 루프(for와 while)가 맞물려 돌아가는 부분을 유심히 살펴보기 바란다.

function getStringElem(t)
    for k, v in pairs(t) do
        if type(v) == "string" then
             coroutine.yield(v) -- 잠시 제어권을 양보한다.
        end
     end
end

t = {1, 2, "hello", "문자열", 3}

co = coroutine.create(getStringElem) -- 함수를 협동루틴으로 만든다

while true do
     -- 협동루틴을 실행(또는 재개)한다. resume이 돌려주는 첫 번째 반환값은
     -- 협동루틴의 실행 가능 여부이다.(협동루틴 함수가 보통의 return 또는
     -- 마지막 end에 도달하면 협동루틴으로서의 수명이 끝난다.)
    status, v = coroutine.resume(co, t)

    if status and v then
        print(v)
    else
        break -- 협동루틴이 더 이상 유효하지 않으므로 루프를 벗어난다.
    end
end

협동루틴에 대한 내용은 PIL 9장에 자세히 나와 있다.

반복자

반복자(iterator)는 일련의 값들을 차례로 다룰 때 매우 유용한 개념으로, 일반형 for에서 자주 쓰이는 pairs()가 바로 대표적인 반복자이다. C++의 반복자는 컨테이너 안의 요소를 가리키는 일종의 일반화된 포인터에 해당하지만, 루아에서 말하는 반복자는 일반형 for 문에서 사용하는 것이 주된 목적인, 호출할 때마다 다음 번 값을 돌려주는 함수이다.

반복자는 닫힘을 이용해서 만들 수도 있고 협동루틴을 이용해서 만들 수도 있다. 다음은 임의의 수의 배수들을 n개 돌려주는 간단한 반복자를 닫힘을 이용해서 구현한 것이다.

function list_multiples(base, n)
    local current = 0
    local count = 0
    return function ()
        count = count + 1
        if count > n then
            return
        end
        current = current + base
        return current
    end
end

-- 3의 배수 10개를 출력
for i in list_multiples(3, 10) do
    print(i)
end

반복자와 일반형 for는 PIL 7장에 자세히 나와 있다.

반복자는 협동루틴으로도 구현할 수 있다. 다음이 그러한 예로, 닫힘을 이용할 때보다 코드가 더 간결하다. coroutine.wrap()은 협동루틴의 생성과 사용을 단순화하는 용도로 쓰였다.

function list_multiples(base, n)
    return coroutine.wrap(
        function()
            for i = base, n * base, base do
                coroutine.yield(i)
            end
        end
    )
end

협동루틴을 이용한 반복자 작성에 대한 내용은 PIL 9.3절에 잘 나와 있다.


  1. Roberto Ierusalimschy, Lua.org, 2003-12. 온라인 버전은 http://www.lua.org/pil/ 

  2. 루아 5.1부터는 package.path를 사용한다.