본문 바로가기
카테고리 없음

클래스와 모듈1

by 경자꿈사 2024. 8. 6.

이전에 작성했던 '객체와 클래스' 시리즈 글의 마지막 부분에 다음 글에서는 모듈에 대해 알아보기로 했는데 이제서야 글을 쓰게 됐다.

시간이 좀 지났으므로 리마인드 차원에서 '객체와 클래스' 에 대해 간단히 요약 정리를 해보자.

우선 '객체와 클래스' 시리즈의 첫 번째 글에서 '객체와 클래스' 에 대한 설명을 그대로 옮겨와 봤다.

 

"컴퓨터 프로그래밍에서 말하는 '객체' 는 속성(데이터)과 메서드를 포함하는 일종의 데이터 구조인데 '클래스'라는 것을 이용해 원하는 객체를 표현하고 그 '클래스'의 인스턴스(instance : 사례, 경우) 생성을 통해 실제 프로그램에서 사용할 수 있는 '객체'를 만들게 된다."

 

그리고 클래스는 상속이라는 기능을 통해 부모 클래스로부터 속성과 메서드를 물려 받아 기능을 쉽게 확장할 수도 있다.

class Person
  attr_reader :name
  
  def initialize(name)
    @name = name
  end
end

class Worker < Person
  attr_reader :job
  
  def initialize(name, job)
    @job = job
    super(name)
  end
end

worker = Worker.new("홍길동", "개발자")
p worker.name   # "홍길동"
p worker.job    # "개발자"

p worker.class  # Worker
p worker.class.ancestors  # [Worker, Person, Object, Kernel, BasicObject]

p String.ancestors  #  [String, Comparable, Object, Kernel, BasicObject]
p Array.ancestors   #  [Array, Enumerable, Object, Kernel, BasicObject]
p Hash.ancestors    #  [Hash, Enumerable, Object, Kernel, BasicObject]

루비에서 기본으로 제공하는 클래스(String, Array, Hash 등)들뿐만 아니라 우리가 직접 정의한 클래스들 모두 Object, Kernel, BasicObject 로 이어지는 같은 조상을 갖고 있기 때문에 객체라면 갖고 있는 공통의 특성들을 공유하게 된다.

그리고 이러한 클래스들 역시 Class 클래스의 인스턴스 즉 객체라는 얘기도 했었는데 이와 관련하여 '국화빵' 과 '붕어빵'을 예로 들어 설명했었다. 아래 그대로 옮겨 적었다.

 

"우리가 시장에서 사 먹는 붕어빵이나 국화빵을 생각해 보면 모양과 맛이 다른데 (일단 모양에만 집중) 이것은 붕어빵과 국화빵을 만드는 틀의 모양이 서로 달라서이다. 여기서 붕어빵 틀과 국화빵 틀을 '클래스'라고 한다면 해당 틀에서 만들어져 나온 붕어빵과 국화빵이 '객체' 가 되는 것이다. 그리고 같은 틀을 사용하여 만든 붕어빵이라 해도 만들 때 속재료로 팥앙금을 쓰냐 슈크림을 쓰냐에 따라 맛이 달라지는데 여기서 속재료는 '속성' 에 비유할 수 있겠다.

그리고 생각해 보면 저 붕어빵 틀이나 국화빵 틀 역시 그냥 어디서 생겨나는 것이 아니라 붕어빵 모양의 금형을 먼저 만들고 그것을 이용해서 붕어빵 틀을 주조해야 만들어지는 것이다. 여기서 어떠한 모양의 틀이라도 만들어 낼 수 있는 금형이 있다고 한다면 그것이 바로 'Class' 클래스라고 할 수 있겠다."

 

우리가 일반적으로 사용하는 객체의 메서드는 클래스에서 정의한 인스턴스 메서드이고 클래스를 대상으로 호출하는 메서드를 클래스 메서드라고 부르는데 이러한 클래스도 결국엔 Class 클래스의 인스턴스이므로 Class 클래스 입장에서 보면 클래스 메서드 역시 Class 클래스의 인스턴스 메서드일 뿐이다. 아래 코드 실행 결과를 보면 확실히 알 수 있다.

?> class Person
?>   attr_reader :name
>> end
=> nil
>>
>> Person.instance_methods(false)
=> [:name]
>> Person.class
=> Class
>> Class.instance_methods.grep(/attr/)
=> [:attr, :attr_reader, :attr_writer, :attr_accessor]
>> Class.class
=> Class
>> Class.object_id
=> 260
>> Class.class.object_id
=> 260
>> Class.ancestors
=> [Class, Module, Object, Kernel, BasicObject]

Person 클래스를 정의하면서 attr_reader 클래스 메서드를 이용하여 'name' 속성에 대한 getter 메서드를 만들었다.

Person 클래스가 정의한 인스턴스 메서드 목록을 보기 위해 instance_methods 클래스 메서드를 호출하였고 이때 상속 받은 메서드는 제외시키기 위해 false 를 인자로 주었다. 예상한대로 우리가 만든 name getter 메서드 하나가 보인다.

Person.class 를 이용하여 Person 클래스가 Class 클래스의 객체임을 알 수 있고, Person 클래스에서 사용했던 attr_reader 클래스 메서드가 Class 클래스에서 인스턴스 메서드 목록에 나오는 것을 볼 수 있다. 따라서 Class 클래스의 인스턴스인 Person 에서 Class 클래스의 인스턴스 메서드를 사용할 수 있는 것이다.

 

그렇다면 Class 클래스는 진짜 순수한(?) '클래스' 인 걸까? 대답은 '아니다' 이다!

Class 클래스 역시 객체일 뿐이다. 다만 이렇게 되면 계속해서 꼬리에 꼬리를 무는, 닭이 먼저냐 달걀이 먼저냐 라는 식의 질문이 있을 수 있는데 'Class 클래스의 클래스는 자신이다' 라고 정의하면 이 질문을 종결지을 수 있다.

위의 그림을 보면 Class 클래스의 object_id 값과 Class.class 의 object_id 값이 같음을 알 수 있다.

그리고 Class.ancestors 결과를 보면 Class 클래스도 모든 클래스들과 마찬가지로 Object, Kernel, BasicObject 로 이어지는 조상을 갖는 걸 볼 수 있다. Object 클래스가 Class 클래스의 인스턴스인데 Class 클래스의 조상이 Object 클래스라니.. 알쏭달쏭하겠지만 이로써 Class 클래스도 '클래스' 라는 게 명확해졌다. Object 클래스와 Class 클래스의 관계를 보여주는 예를 하나 보도록 하자.

루비에서는 Object 와 같은 내부 클래스에 대해서도 개발자가 메서드를 추가할 수 있게 해 허용해 주는데 아래 그림과 같이 Object 클래스에 인스턴스 메서드를 추가하면 Class 클래스는 Object 클래스를 조상으로 갖기에 이 메서드를 상속받게 되고 Person 클래스는 Class 클래스의 인스턴스이므로 이 메서드를 Person 클래스의 클래스 메서드로서 호출할 수 있게 된다.

그러나 이것은 단지 예를 보여주기 위한 것일 뿐이며 되도록이면 루비가 정의한 내부 클래스는 수정하지 않는 것이 좋다.

특히 Object 클래스와 같이 모든 클래스에게 영향을 미칠 수 있는 클래스를 변경하는 일은 피하도록 하자.

?> class Object
?>   def dont_do_this
?>     puts "이렇게 하지 마세요!"
?>   end
>> end
=> :dont_do_this
>>
>> Class.instance_methods.grep(/dont/)
=> [:dont_do_this]
?> class Person
?>   dont_do_this
>> end
이렇게 하지 마세요!
=> nil

이제 모듈(Module) 얘기를 해보도록 하겠다. 컴퓨터 프로그래밍에서 일반적으로 말하는 '모듈' 은 특정 기능과 관련 데이터들을 묶어 놓은 것을 말하는데 프로그램을 개발할 때 전체 기능을 하나의 소스에 두지 않고 '모듈' 등의 개념을 적용하여 분리하면 프로그램 소스를 관리하기가 그렇지 않을 때보다 상대적으로 수월해지고 특정 기능의 '모듈' 은 다른 프로그램에서 그대로 재사용할 수도 있게 된다.

'특정 기능과 관련 데이터들을 묶어 놓은 것' 이 얘기를 들으면 뭔가 생각나는 게 있을 것이다.

'클래스' 를 떠올렸다면 훌륭하다. 그렇다면 이미 '클래스' 라는 데이터(속성)와 기능(메서드)을 묶어서 관리할 수 있게 해주는 좋은 도구를 가지고 있는데 '모듈' 은 왜 필요할까? 그리고 '클래스' 와는 무슨 차이가 있는 걸까?

앞의 그림에서 Class.ancestors 의 결과를 다시 한 번 보도록 하자. Class 클래스의 조상 중 바로 위 그러니까 부모 클래스가 Module 클래스인 게 보일 것이다. 이것은 모듈로 할 수 있는 일은 클래스도 할 수 있고 또 모듈이 할 수 없는 일 중 어떤 건 클래스가 할 수 있다는 거다. 물론 모든 게 다 성립하지는 않는다. 클래스로는 할 수 없는 일이 모듈로는 가능한 것도 있다.

그럼 다시 본론으로 돌아가서 '클래스' 와 '모듈' 의 차이점부터 살펴보자.

아래 그림을 보면 클래스에서 인스턴스(객체)를 생성하기 위해 사용하는 생성자 메서드인 new 를 Class 클래스에서 정의한 게 보인다. 그래서 아래 내가 만든 MyModule 모듈에 대해 'new' 를 호출하니 예외가 발생하였다. 즉 모듈로부터는 인스턴스를 생성할 수가 없다.

그러나 Module.instance_methods(false) 결과를 보면 'attr_xxx' 메서드들을 비롯해 우리가 클래스를 대상으로 사용하던 여러 메서드들이 실제로는 Module 클래스에서 정의한 것임을 알 수 있다.

즉 모듈을 통해 클래스처럼 인스턴스 변수를 정의하거나 메서드를 정의하는 일은 가능하지만 객체를 생성할 수는 없다는 건데 객체를 생성해서 사용할 수 없는데 왜 모듈을 만들어야 할까?

이유는 '모듈' 에 대해 처음 설명했던 대로 특정 기능과 관련 데이터를 분리하여 필요한 곳에서 사용할 수 있도록 하기 위해서이다. 이제 정말 '모듈'을 사용해보자!

>> Class.instance_methods(false)
=> [:new, :allocate, :superclass]
>> Module.instance_methods(false)
=> [:<=>, :<=, :>=, :==, :===, :included_modules, 
    :include?, :ancestors, :attr, :attr_reader, :attr_writer, :attr_accessor, ...생략]

?> module MyModule
>> end
=> nil
>> MyModule.new
...생략
NoMethodError (undefined method `new' for MyModule:Module)

아래 코드를 보면 Saveable 모듈과 Compressible 모듈 두 개를 정의했고 Task 클래스 안에서는 앞서 만든 두 개의 모듈을 include 메서드를 이용하여 포함시켰다. 이렇게 클래스가 특정 모듈을 include하게 되면 해당 모듈에서 정의한 인스턴스 변수와 인스턴스 메서드를 클래스의 인스턴스에서 사용할 수 있게 된다.

?> module Saveable
?>   def save
?>     puts "파일 저장..."
?>   end
>> end
=> :save
>>
?> module Compressible
?>   def compress
?>     puts "파일 압축..."
?>   end
>> end
=> :compress
>>
?> class Task
?>   include Saveable
?>   include Compressible
?>
?>   def process
?>     puts "처리..."
?>     save
?>     compress
?>   end
>> end
=> :process
>>
>> Task.new.process
처리...
파일 저장...
파일 압축...
=> nil
>> Task.ancestors
=> [Task, Compressible, Saveable, Object, Kernel, BasicObject]

그런데 이것은 상속이라는 기능을 통해서도 가능한 게 아닌가 라는 생각이 들 것이다. 그러나 클래스를 통한 상속말고 모듈을 이용한 믹스인(include 등을 통해 모듈의 기능을 주입)이란 것이 존재하는 데에는 중요한 이유가 있다.

루비에서는 두 개 이상의 부모 클래스로부터 동시에 상속 받는 것(다중 상속)이 불가능하기 때문이다.

아래 코드처럼 다중 상속을 시도하면 구문 에러가 발생한다.

?> class Saveable
>> end
=> nil
>>
?> class Compressible
>> end
=> nil
>>
?> class Task < Saveable, Comparable
>> end
<internal:kernel>:187:in `loop': (irb):7: syntax error, unexpected ',', expecting ';' or '\n' (SyntaxError)
class Task < Saveable, Comparable
                     ^
...생략

이는 전통적인 다중 상속 문제와 관련이 있는데 만약 다중 상속이 허용되어 있고 상속 받으려고 하는 두 부모 클래스에 어떤 동일한 메서드가 정의되어 있다면 자식 클래스의 인스턴스에 대해 그 메서드를 호출하였을 경우 두 부모 클래스 중 어느 부모 클래스의 메서드를 호출해야 하는지에 대한 복잡한 문제를 처리해야 한다.

그래서 루비에서는 이러한 복잡한 문제를 제거하면서도 다중 상속과 유사한 기능을 제공하기 위해 믹스인(Mixin)이란 개념을 도입시켰다. 루비에서의 믹스인 기능에 대해서는 다음 글에서 좀 더 자세히 살펴 보자.

 

다시 위의 예에서 Task.ancestors 를 실행한 결과를 보면 Task 클래스의 조상 목록에 Compressible 모듈과 Saveable 모듈이 들어간 걸 볼 수 있다. Kernel 역시 모듈이고 Object 와 BasicObject 는 클래스이므로 모듈과 클래스 간에 서로 상속 관계를 맺을 수 있는 것으로 보인다. 그러나 아래 그림을 보면 모듈은 클래스를 직접 상속하거나 include 할 수 없고 클래스도 모듈을 include 할 수 있지만 직접적인 상속은 할 수 없다. 즉 직접적인 상속은 클래스 간에만 가능하며 모듈은 다른 모듈 또는 클래스에 include 등을 통해서만 상속 계층에 포함될 수 있다.

?> class C
>> end
=> nil
>>
?> module M
>> end
=> nil
>>
?> class C2 < M
>> end
(irb):7:in `<main>': superclass must be an instance of Class (given an instance of Module) (TypeError)
...생략
?> module M2 < M
>> end
<internal:kernel>:187:in `loop': (irb):10: syntax error, unexpected '<' (SyntaxError)
module M2 < M
          ^
...생략
?> module M2 < C
>> end
<internal:kernel>:187:in `loop': (irb):13: syntax error, unexpected '<' (SyntaxError)
module M2 < C
          ^
...생략
?> class C2
?>   include C
>> end
(irb):17:in `include': wrong argument type Class (expected Module) (TypeError)

  include C
          ^
...생략
?> module M2
?>   include M
>> end
=> M2

클래스와 모듈 두 번째 글에서는 믹스인에 대해 알아보면서 모듈을 좀 더 이해할 수 있는 시간이 가져보려고 한다.

See you again~~