EXQT EXQT
cover

[Lua] Class 구현

Lua는 프로토타입 기반 프로그래밍 언어로 클래스가 존재하지 않는다. 아래는 위키피디아에서 발췌한 프로토타입 기반 프로그래밍의 설명이다.

프로토타입 기반 프로그래밍은 객체지향 프로그래밍의 한 형태의 갈래로 클래스가 없고, 클래스 기반 언어에서 상속을 사용하는 것과는 다르게, 객체를 원형(프로토타입)으로 하여 복제의 과정을 통하여 객체의 동작 방식을 다시 사용할 수 있다.

이 글에선 Lua에서 어떻게 클래스(Class)와 상속(Inheritance)을 구현하는지 간단하게 살펴본다. 꼭 이 포스트처럼 구현해야 하는 것은 아니고 다르게 구현할 수도 있다. Lua의 컨셉들이 어떻게 사용되는지에 초첨을 맞춰보자.

사전지식

  • Lua의 기본적인 문법
  • OOP, Class에 대한 개념

table

Lua에는 테이블(table)이라는 타입이 존재한다. 키(Key)와 키에 해당 되는 값을 저장할 수 있는 자료구조이다. JS의 Object와 매우 유사하다. 키의 타입은 string이 될 수도 있고 number가 될 수도 있고 table이 될 수 있다. Lua에는 따로 배열이 없고 배열도 테이블로 만든다. 키로 1, 2 .. n 을 설정하여 배열로 쓰고 이 때 #연산자를 사용하여 해당 배열의 길이를 알 수 있다.

local t = { 1, 4, 9, a = "foo" }
print(t) -- table: 0x001e22b8
print(t[2]) -- 4
print(t.a) -- foo
print(#t) -- 3
local c = {}
t[c] = "bar" -- table을 키로 쓸 수 있다.
print(t[c]) -- bar

Lua에서 함수도 number, string 타입과 마찬가지로 table에 값으로 넣을 수 있다. table에서 함수를 선언하는 하는 방법에는 아래와 같이 두 가지 방법이 있다.

local foo = {}
foo.bar = function(a) print(a) end -- 1
function foo.bar(a) print(a) end -- 2
foo.bar() -- 호출

method

테이블에서 함수를 정의 할 때 .대신 :쓸 수 있는데 이렇게 쓰면 매개변수 첫 번재로 self가 들어온다. 그리고 호출 할 때 :으로 호출하면 호출한 테이블이 self로 들어가게 된다. 아래의 코드에서 2, 3 번째 줄을 서로 동치이고 4, 5번째 줄도 서로 동치이다.

local foo = {x = "foo"}
function foo.bar(self, a) print(self.x .. a) end
function foo:bar(a) print(self.x .. a) end
foo:bar("!") -- "foo!"
foo.bar(foo, "!") -- "foo!"

metatable

메타테이블(metatable)은 어떤 테이블이 특정 연산에서 어떻게 행동해야 하는지를 나타내는 또 다른 테이블이다. 예를 들어 테이블 a, b가 있고 a + b를 수행하다고 했을 때 보통의 테이블이라면 에러를 뱉겠지만 a의 메타테이블에 덧셈 연산이 정의되어 있으면 해당 연산을 수행한다. 메타테이블은 setmetatable로 설정할 수 있고 첫 번째 인자로 주어진 테이블을 반환한다. 만약 어떤 테이블의 메타테이블을 알고싶다면 getmetatable(table)을 호출하여 알 수 있다.

local a = {n = 3}
local b = {n = 4}
local mt = {
  __add = function(self, other)
    return {n = self.n + other.n}
  end
}
setmetatable(a, mt)
print(getmetatable(a) == mt) -- true
local c = a + b
print(c.n) -- 7
local d = c + b -- Error: c에는 metatable이 없음

위 코드에서 덧셈연산 __add가 정의된 mt테이블을 a의 메타테이블로 지정했다. a + b를 실행하게 되면 a가 테이블이라 메타테이블에 __add가 있는지 보고 이를 호출하게 된다. 첫 번째 매개변수 self로 왼쪽 피연산자인 a가 들어오고 두 번째 매개변수 other에는 오른쪽 피연산자인 b가 들어가게 된다.

__add이외에도 아래와 같은 metamethod들이 있다.

  • __add, __sub, __le등등 각종 산술/비교 연산
  • __len # 길이 연산자
  • __concat .. 문자열 연결 연산자
  • __tostring 문자열로 변환하려 할 때
  • __call table을 함수처럼 호출 했을 때
  • __index key로 접근할 때
  • __newindex key로 값이 할당 될 때

이 포스트에서 핵심이 되는 것은 __index이다.

local a = {x = 3}
local mt = {
  __index = function(self, key)
    error("Key Error: " .. tostring(key))
  end
}
setmetatable(a, mt)
print(a.x) -- 3
print(a.y) -- Error!

보통 테이블이라면 없는 키값에 접근하면 nil을 반환한다. 대신 위 코드에선 해당 키값이 없으면 에러를 발생하도록 했다. __index는 함수말고도 테이블로 지정할 수 있는데 이 경우 해당 테이블에서 값을 가져오게 된다.

이제 본격적으로 클래스를 구현해보자.

클래스(Class) 구현

local Monster = {name = "Monster"}
Monster.__index = Monster

function Monster.new(hp)
  local t = setmetatable({}, Monster)
  t.hp = hp
  return t
end

Monster라는 클래스를 하나 만들어보자. Monster는 Monster 클래스 정보를 가지고 있는 하나의 테이블이며 new라는 키로 함수를 가지고 있다. 그리고 __index로는 자기 자신을 가르키고 있다.

Monster 인스턴스를 만드는 Monster.new함수를 살펴보자. Monster.new함수에서 setmetatable을 이용하여 새로 빈 테이블 t를 생성하고 Monster를 메타테이블로 지정한다. (setmetatable의 반환값은 첫 번째 인자로 들어간 테이블이다.) 그리고 맴버 변수인 hp를 매개 변수로 들어온 hp로 지정한다. 마지막으로 완성된 Monster 인스턴스 t를 반환하게 된다.

이제 메소드를 추가하고 인스턴스를 만들어 호출해보자.

function Monster:__tostring()
  return string.format("%s{hp=%d}", self.name, self.hp)
end

function Monster:takeDamage(amount)
  self.hp = self.hp - amount
end

local m1 = Monster.new(100)
local m2 = Monster.new(50)
m1:takeDamage(30)
m2:takeDamage(30)
print(m1, m2) -- Monster{hp=70}   Monster{hp=20}

m1:takeDamage(30)을 실행하면 takeDamage는 인스턴스 m1에 존재하지 않는 키이고 m1의 메타테이블인 Monster를 보게 된다. Monster에는 __index로 자기자신인 Monster가 지정되어 있으므로 Monster의 takeDamage를 호출하게 된다. self는 호출한 m1이다. 따라서 m1의 hp를 주어진 만큼 감소시킨다.

print(m1)을 실행하면 m1은 테이블이므로 문자열로 변환을 시도한다. 메타테이블에 __tostring이 정의되어 있으므로 이를 호출하여 받아온 문자열을 출력한다. self.name의 경우 name은 인스턴스에는 없으므로 클래스 테이블에서 받아오며 hp는 인스턴스 테이블에 있는 것을 사용하게 된다.

정리하자면.. 맴버변수는 인스턴스 테이블에, 메소드와 정적변수는 클래스 테이블에 저장하고 인스턴스가 메소드를 호출하면 인스턴스 테이블에 없으므로 메타테이블을 통하여 클래스 테이블에서 찾아 호출하게 된다.

이제 Monster를 상속 받아 Slime이라는 클래스를 만들어 보자.

상속(Inheritance) 구현

local Slime = {name = "Slime"}
Slime.__index = Slime
setmetatable(Slime, Monster)

function Slime.new(hp, color)
  local t = setmetatable(Monster.new(hp), Slime)
  t.color = color
  return t
end

function Slime:__tostring()
  return string.format("%s{hp=%d, color=%s}",
    self.name, self.hp, self.color)
end

local s1 = Slime.new(100, "green")
local s2 = Slime.new(130, "gray")
s1:takeDamage(50)
s2:takeDamage(100)
print(s1, s2) -- Slime{hp=50, color=green}   Slime{hp=30, color=gray}

Slime.new로 Slime 인스턴스를 만들게 되면 Monster 생성자로 인스턴스를 생성하고 메타테이블로 Slime을 지정하여 Slime 인스턴스로 바꾸어 주었다.

Slime 클래스 테이블 선언에 setmetatable(Slime, Monster)이 추가 되었다. 이를 통하여 Monster의 메소드들을 가져올 수 있다. takeDamage 메소드를 호출하면 어떻게 되는지 살펴보자.

  • s1(=Slime instance) 에는 takeDamage가 없다. 대신 s1의 메타테이블이 Slime(=Slime Class) 이고 __index를 가지고 있다. Slime에 takeDamage가 있는지 보자.
  • Slime에는 takeDamage가 없다. 대신 Slime의 메타테이블이 Monster(=Monster Class) 이고 __index를 가지고 있다. Monster에 takeDamage가 있는지 보자.
  • Monster는 takeDamage함수를 가지고 있다. s1은 takeDamage를 호출한다.
  • takeDamage호출 될 때 첫 번째 인자 self는 호출한 s1이다. 따라서 s1의 hp를 주어진 만큼 감소 시킨다.

print를 통해 호출되는 __tostring의 경우 Slime:__tostring가 존재하여 Monster 클래스에 있는 메소드까지 가지 않고 Slime:__tostring이 호출된다.

Mixin

언어 특성상 Mixin도 쉽게 구현이 가능하다. 관련하여 좋은 글이 있으므로 상황에 맞게 적용하면 좋을 것 같다. 링크

OOP 관련 라이브러리

이렇게 직접 짜면 매번 메타테이블을 붙이는 것도 일이다. 따라서 누가 만들어둔 라이브러리를 사용할 수도 있다. 아래는 유명한 Lua OOP 라이브러리들이다. 각각 기능과 추구하는 바가 다르므로 한번 살펴보고 프로젝트에 적합한 라이브러리를 골라서 사용하면 된다.

  • 30log - Minified framework for object-orientation in Lua. It features named (and unnamed) classes, single inheritance and a basic support for mixins
  • classic - Tiny class module for Lua. Attempts to stay simple and provide decent performance by avoiding unnecessary over-abstraction
  • hump.class - Small, fast class/prototype implementation with multiple inheritance (class-commons)
  • knife.base - Extremely minimal base class providing single inheritance and constructors.
  • middleclass - Simple OOP library for Lua; has inheritance, metamethods (operators), class variables and weak mixin support (class-commons)