EXQT EXQT
cover

[Lua] Class 구현

2020-10-25

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가 지정되어 있으므로 MonstertakeDamage를 호출하게 됩니다. self는 호출한 m1입니다. 따라서 m1hp를 주어진 만큼 감소시킵니다.

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를 가지고 있네요. SlimetakeDamage가 있는지 봅시다.
  • Slime에는 takeDamage가 없습니다. 대신 Slime의 메타테이블이 Monster(=Monster Class) 이고 __index를 가지고 있네요. MonstertakeDamage가 있는지 봅시다.
  • MonstertakeDamage함수를 가지고 있네요. s1takeDamage를 호출합니다.
  • takeDamage호출 될 때 첫 번째 인자 self는 호출한 s1입니다. 따라서 s1hp를 주어진 만큼 감소 시킵니다.

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)