[Python] 클래스 구현 예제 – Vector

지난 시간에 이어서 간단한 클래스 예제를 통해서 클래스를 작성하고 활용하는 방법을 살펴보도록 하겠다. 그리고 객체가 가지는 추가적인 비밀스런(?) 메소드나 속성에 대해서도 조금 더 알아보겠다.

개인적으로 데이터나 함수를 묶어두는 용도로 굳이 클래스를 써야하는가에 대해서는 회의적인 입장이다. 모든 객체인스턴스는 속성이나 메소드를 참조하기 위해서는 내부적인 lookup 과정을 거치기 때문에 사실상 사전으로 대체하는 것도 무방하다고 생각한다. 그럼에도 불구하고 이번에는 클래스로 정의해두는 것이 훨씬 더 사용성을 높이고 편리하게 사용될 수 있는 케이스에 대해서 접근해보겠다.

3차원 벡터를 표현하는 클래스

3차원 벡터를 표현하는 클래스인 Vector를 작성해보자. 3차원 벡터는 간단하게 실수값 3개의 튜플로도 구성할 수 있지만 벡터는 몇 가지 특별한 연산이 요구된다.

  1. 벡터의 크기에 해당하는 norm 을 계산한다.
  2. 벡터는 스칼라곱을 계산할 수 있다.
  3. 차원이 같은 두 벡터는 더하기 혹은 빼기 연산을 수행한다. 어떤 백터는 -1을 곱하여 역벡터를 만드는데, 1에 -만 붙여서 -1을 표현하듯이 벡터 v의 역벡터인 -v 를 바로 구할 수 있으면 편하겠다.
  4. 차원이 같은 두 벡터는 내적을 계산할 수 있다.
  5. 두 3차원 벡터는 외적(가위곱, 크로스곱)을 계산할 수 있다.

이 모든 연산은 각각 함수로도 충분히 구현할 수 있지만, add(v1, v2) 라고 쓰는 것 보다는 v1 + v2 라고 쓰는 것이 보다 사용하기 쉽기 때문에 이러한 연산을 지원하는 클래스를 만들어보려고 한다.

참고로 이 글에서는 순차적으로 내부 메소드를 추가하고 변경하는 작업을 계속할 것이다. 따라서 파이썬 쉘보다는 주피터 노트북등을 사용하는 것을 권장한다.

생성자를 정의하자

먼저 기본적으로 새로운 벡터를 정의하는 방법이다. Vector 클래스에는 별다른 클래스 속성은 필요하지 않다. x, y, z의 3개의 성분을 인자로 받는데, z인자는 생략된 경우 0으로 대체한다. 참고로 각 인자는 int 타입이거나 float 타입인 경우만 상정해야 하기에 초기화 메소드 내에서 전달된 인자에 대한 타입체크를 실시한다.

참고로 타입 체크는 빈번하게 사용되는 기능이라 별도의 함수로 하나 작성해둔다. 이를 통해 모든 인자가 정수이거나 실수(부동소수점 숫자)인 경우가 아니면 생성시 ValueError를 내도록 한다.

class Vector:
  # 클래스 속성으로 정수/실수를 판단하는 함수
  _isrealnumber = lambda x : isinstance(x, int) or isintance(x, float)
  # 클래스에서 사용될 속성을 고정한다. 
  __slots__ = ('x', 'y', 'z')

  def __init__(self, x, y, z=0.0):
    if all(map(Vector._isrealnumber, (x, y, z))):
      # 생성 시점에 모든 요소값들은 실수형으로 변환한다.
      self.x, self.y, self.z = map(float, (x, y, z))
    else:
      raise ValueError("각 성분은 정수 혹은 실수여야 합니다.")

참고로 __slots__ 속성을 지정하여 실행시간에 임의의 속성값을 추가할 수 없도록 했다. 또한 이 속성을 사용하면 속성의 이름이 고정되기 때문에 메모리 사용량을 줄이는데 도움을 줄 수 있다. (참고)

클래스를 표현하는 형식을 정의하기

이렇게 정의한 클래스에 대해서 인스턴스를 만들고 이를 파이썬 쉘 상에서 출력해보면 타입정보와 해당 인스턴스 객체의 메모리 주소 정보가 표시된다.

>>> v = Vector(1, 2)
>>> v
<__main__.Vector at 0x2051f951040>

이정보로는 객체가 위치하는 메모리와 타입 정도만 식별이 가능하다. 우리는 REPL 환경에서 이 객체에 대해서 이러한 기계스러운(?) 정보보다는 해당 벡터의 좌표를 통해 벡터의 내용을 출력해주는 것이 좀 더 도움이 될 것 같다. 이를 테면 a = 1 이었다면 a를 입력했을 때 1을 표시하지, <__buintins__.int at 0x….> 이런 식의 메시지를 표시하지 않지 않는가.

어쨌든 보다 사용자 친화적인 출력을 위해서는 __repr__ 메소드를 정의할 필요가 있다. 이 메소드는 객체를 쉘에 출력하거나 문자열로 변환할 때 사용할 값을 리턴하게끔 미리 약속된 메소드이다. 정의되지 않은 경우 클래스와 포인터를 리턴하게끔 동작한다. 단순 속성이 아닌 메소드라는 점에 주의하자. 다음과 같이 __repr__ 메소드를 정의한다. 이 메소드는 self 외에 다른 인자는 받지 않으며 문자열을 리턴한다.

참고로 문자열 내삽시에 %r 포맷문자는 이 __repr__ 속성에 의존한다.

참고로 코드 내의 …. 은 앞서 작성한 다른 코드들이 있다는 의미이며, 코드 작성 시 입력하지 않아야 한다.

class Vector:
  ....
  def __repr__(self):
    return f'Vector(x={self.x:.02f}, y={self.y:.02f}, z={self.z:.02f})'

v = Vector(1, 2, 3)
v
# Vector(x=1, y=2, z=3)

norm() 메소드 추가

norm은 절대값이라고도 하고 크기라고도 하는데, 각 좌표 성분을 제곱하여 합산하고 다시 그 제곱근을 구하는 것이다. 우선 메소드로 해당 벡터의 값을 구할 수 있게 다음 메소드를 추가한다.

class Vector:
  ....
  def norm(self):
    return (self.x**2 + self.y**2 + self.z)**0.5

v = Vector(1,2,3)
v.norm()
# 3.7416573867739413

프로퍼티로 만들기

norm은 먼저 메소드로 작성하였다. 벡터 인스턴스를 만들고 norm() 이라고 호출하는 것은 조금 어색해보인다. 왜냐하면 이건 벡터의 norm 속성값을 액세스하는 것과 비슷하기 때문이다. 보통 메소드는 특정한 동작이나 연산을 수행하는 것을 가정하기 때문에 메소드로 만든다면 getNorm() 등의 이름이 더 어울릴 것이다. 여기서는 norm 속성이 x, y, z 좌표값에 의존하는 다른 속성처럼 취급하기를 원한다. 이럴때에는 해당 메소드를 프로퍼티로 정의할 수 있다. 프로퍼티로 정의하는 방법은 @property를 해당 메소드에 적용해주면 된다.

class Vector:
  ....
  @property
  def norm(self):
    return (self.x**2 + self.y**2 + self.z)**0.5

v = Vector(1,2,3)
v.norm  # <- 일반 속성처럼 액세스한다.
# 3.7416573867739413

벡터의 경우 norm 값을 변경하는 것은 이치에 맞지 않는다. 대신에 프로퍼티로 사용되는 속성을 변경할 수 있도록 해야 하는 경우도 있을 수 있다. 이 때에는 @프로퍼티이름.setter 를 사용해서 setter 접근자를 작성할 수도 있다.

실제 사용할 예는 아니지만 다음과 같이 norm 값을 지정하여 x 축 성분만으로 된 벡터로 바꾸는 기능을 추가할 수 있다. 프로퍼티를 선언한 경우, 이에 대한 쓰기 접근을 구현하는 방법으로 참고만 해 두자.

class Vector:
  ...
  @norm.setter
  def norm(self, value):  # 접근자 이름은 getter와 동일하게
    self.x, self.y, self.z = value, 0, 0

이 메소드를 구현하기에 앞서서 벡터의 각 성분을 리스트나 튜플의 형태로 얻어서 다룰 일이 많기 때문에, 각 성분의 튜플을 얻는 프로퍼티를 하나 미리 정의하여 사용하겠다.

class Vector:
  ...
  @property 
  def _comps(self):
    return (self.x, self.y, self.z)

이 속성을 사용하면 __repr__ 이나 norm 속성은 조금 더 간단하게 쓸 수 있다.

class Vector:
  ...
  @property
  def norm(self):
    return sum(i*i for i in self._comps) ** 0.5

  def __repr__(self):
    return "Vector(x={:.02f}, y={:.02f}, z={:.02f})".format(*self._comps)

벡터의 실수 배 계산하기

벡터 v(x_1, x_2, x_3) 와 실수 k 에 대해서 kv를 계산하는 것은 v의 각 성분에 k를 곱하는 것과 같다. 이 때 관심있게 봐야할 부분은 이것은 ‘연산’이며 ‘결과’를 만들어낸다는 것이다. 즉 벡터 v의 각 성분이 변경되는 것이 아니라 벡터 v를 k배 한 새로운 v’ 를 만들어낸다.

이것은 벡터 v가 k 배된 새로운 벡터를 구하는 것이므로 multiplied(k) 라는 메소드로 정의할 수 있겠다.

class Vector:
  def multiplied(k):
    return Vector(i * k for i in self._comps)

v = vector(1, 2, 3)
w = v.multiplied(2)
w
# Vector(x=2.00, y=4.00, z=6.00)

그런데 v.multiplied(2) 라고 쓰는 것 보다 v * 2 라고 쓰는 것이 조금 더 편하지 않겠나? 이렇게 새로운 클래스를 기존의 사칙 연산에 적용하기 위해 몇 가지 비밀(!!) 메소드를 사용해보자.

파이썬에서 각 사칙연산을 쓰면, 내부적으로 연산자의 좌변에 있는 객체에 대해 약속된 메소드를 호출하면서 우변을 전달한다.

그 예로 덧셈이 적용되는 int 타입의 속성을 dir() 함수로 찾아보면 __mul__ 라는 속성을 찾을 수 있다. 이 __mul__ 이 곱하기 연산시에 호출되는 메소드이다. 즉 x * y 라고 썼다면 내부적으로는 x.__mul__(y) 로 호출되는 것이다. 따라서 다음 메소드를 또 추가해주자. 비슷하게 __add__ 는 덧셈, __sub__ 는 뺄셈, __div__ 는 나눗셈에 해당되는 연산이다.

class Vector:
  ....
  # 곱셈 계산
  def __mul__(self, k):
    if Vector._isrealnumber(k):
      return self.multiplied(other)
    raise ValueError("실수만 곱할 수 있음")

v = Vector(1, 2, 3)
w = v * 2
w
# Vector(x=2.00, y=4.00, z=6.00)

그런데 곱셈은 v * 2가 아니라 2 * v로도 계산할 수 있다. 만약 2 * v 를 계산할 수 있게 하려면 어떻게 해야할까? 앞서 곱하기 연산은 좌변의 __mul__ 메소드를 호출한다고 했다. 그런데 int 는 이미 기존에 작성된 타입이며, __mul__ 에 Vector 인스턴스를 넘겨받는 상황이 고려되지 않았다. 그래서 ValueError 등의 에러가 날 것이다.

이런 경우에는 Vector에 __rmul__을 추가해준다. __rmul__ 은 곱셈에서 우변이 되었을 때에 호출되는 메소드이다. 즉 파이썬은 곱하기를 계산하려 할 때 좌변에서 __mul__을 호출하여 연산을 시도하고, 실패하는 경우 다시 우변에서 __rmul__을 호출하는 순서로 동작하는 것이다.

비슷하게 __radd__도 존재한다. 이미 __mul__ 이 정의되어 있기 때문에 이 메소드 내에서는 self * k 를 리턴하도록 작성해두면 된다.

class Vector:
  ....
  def __rmul__(self, k):
    return self * k 

또한 -v 와 같이 음수 부호를 붙여 역벡터를 반들기 위해서는 __neg__ 메소드를 작성해주면 된다. 정수와 실수는 모두 __neg__ 메소드가 존재하기 때문에 -self.x 와 같은 표현을 쓸 수 있다.

class Vector:
  def __neg__(self):
    return Vector(-i for i in self._comps)

벡터끼리의 곱셈을 구현해야하는 작업이 남았지만, 일단은 여기까지 하고 넘어가도록 한다.

벡터의 덧셈, 뺄셈 구현

이제 벡터와 벡터의 덧셈을 구현해보자. 벡터의 덧셈은 각 성분끼리의 합이다. 따라서 덧셈의 대상은 다른 벡터여야 하며 벡터 + 실수의 연산은 수행할 수 없다. 앞서 만들어놓은 _comps 속성을 이용하면 성가신 타이핑을 줄일 수 있다.

class Vector:
  def __add__(self, other):
    if not isinstance(other, Vector):
      raise ValueError("Vector끼리만 더할 수 있음")
    return Vector(*(i+j for i, j in \
                    zip(self._comps, other._comps)))

빼기는 역시 각 성분끼리 뺀 성분으로된 벡터를 구하면 되는데, 대신에 이미 만들어진 기능을 사용해보자. 7 – 4 = 7 + (-4) 인것처럼, 벡터끼리의 빼기는 대상 벡터의 역벡터를 더한 것과 같다. 따라서 간단히 다음과 같이 쓸 수 있다.

class Vector:
  def __sub__(self, other):
    return self + -other

참고로 뺄셈은 교환법칙이 성립하지 않기 때문에 __rsub__와 같은 메소드는 허용되지 않을 것 같지만, 앞서 __rmul__의 경우와 마찬가지로 ‘실제로는 계산 가능하지만, 좌변 타입이 지원하지 않을 때’를 대비해서 구현할 수 있다. 여기서도 정수 – 벡터를 사용했을 때 원하는 에러메시지를 내려한다면 __radd__, __rsub__를 각각 작성해주는 것도 좋을 것이다.

벡터의 곱셈

벡터는 두 가지 방식의 곱셈을 할 수 있다. 외적과 내적이라고 부르는 것이 그것이다. 내적은 각 성분끼리의 곱을 더한 것으로 계산된다. (선형대수학에서는 이것을 두 벡터의 선형결합이라고도 부른다.) 즉 두 벡터의 각 성분끼리 곱하고 그것을 모두 합산한 값이다. 내적은 보통 dot product 라 부른다.

외적은 3차원 이상의 벡터에 대해서 계산되는 연산으로 cross product라 하며 번역으로는 가위곱 혹은 크로스곱, 벡터곱등으로 불리운다. 곱해지는 두 벡터 모두에 직교하는 새로운 벡터가 만들어진다.

그런데 곱의 맥락으로 사용되는 연산은 두 개인데, 연산자는 * 하나 밖에 없다. 어떡할까? 일단 외적의 계산을 * 에 할당해보자. 그전에 먼저 이 두 연산을 각각 dot(), cross()라는 별도의 메소드로 작성하자.

class Vector:
  ...
  def dot(self, other):
    if not isinstance(other, Vector):
      raise ValueError('Only Vectors are supported.')
    return sum(i*j for i, j in \
               zip(self._comps, other._comps)

  def cross(self, other):
    if not isinstance(other, Vector):
      raise ValueError('Only Vectors are supported.')
    return Vector(self.y * other.z - self.z * other.y,
                  self.z * other.x - self.x * other.z,
                  self.x * other.y - self.y * other.x)

우선 외적에 대해서는 __mul__ 메소드에서 다음과 같이 otherVector인 경우를 추가한다.

class Vector:
  ....
  # 곱셈 계산
  def __mul__(self, other):
    if Vector._isrealnumber(other):
      return self.multiplied(other)
    if isinstance(k, Vector):
      return self.cross(other)
    raise ValueError("Only int, float, Vector is supported.")

내적에 대해서는 파이썬 3.5 이상인 경우에는 @ 연산자를 사용할 수 있다. 이 연산자는 좌변 객체의 __matmul__ 메소드를 호출한다. 따라서 이 메소드를 구현하면 내적 계산을 연산자로 할 수 있게 된다.

class Vector:
  ...
  def __matmul__(self, other):
    if not isinstance(other, Vector):
      raise ValueError("Only Vectors are supported")
    return self.cross(other)

응용

이제 벡터에 대한 간단한 연산에 대한 구현이 완료되었다. 좌표평면에서 삼각형과 한 점이 주어질 때 그 점이 삼각형의 내부에 있는지 외부에 있는지를 판정하는 문제에서 벡터의 외적과 내적을 사용하면 간단하게 문제를 풀 수 있다. 여기서 작성한 Vector 클래스는 이러한 문제를 푸는데 유용하게 사용할 수 있다.

먼저 선분 AB를와 두 점 P, Q가 주어질 때, 두 점 P, Q가 선분의 같은 쪽에 있는지를 판정하는 함수를 Vector를 사용해 작성하면 다음과 같다.

이 문제를 좌표쌍 4개를 받아서 계산하도록 일일이 구현한다고 생각해보라….

def isSameSide(a, b, p, q):
  x = (b - a) * (p - a)
  y = (b - a) * (q - a)
  return x @ y >= 0

삼각형 ABC와 점 P가 있을 때에는 삼각형의 각 변과 대각의 꼭지점에 대해서 이 검사를 세번 해보면 된다.

def isInside(a, b, c, p):
  xs = (a, b, c) * 2
  return all(isSameSide(*xs[i:i+3], q) for i in range(3))

isInside(*map(lambda x: Vector(*x), 
              [(0, 0), 
               (10, 10), 
               (6, 2), 
               (2, 2)]
             )
        )
# True

이상으로 단순히 데이터를 저장하는 용도가 아닌 계산을 위한 값으로 활용할 수 있는 데이터 타입으로서의 클래스를 정의하는 것을 살펴보았다. 파이썬에는 이러한 방식으로 보다 정밀한 부동소수점 계산과 표현에 사용되는 Decimal 타입과 유리수(분수) 계산을 위한 Fraction 타입등이 구현되어 있어서 편리하게 사용할 수 있다.

생각해볼 문제

시, 분, 초 정보를 가지고 덧셈, 뺄셈을 할 수 있는 Clock 이라는 클래스를 생각해볼 수 있을 것이다. 같은 타입끼리 더하거나 뺄 수 있을 것이며, 혹은 시,분,초를 초로 환산하거나 그 시각의 시계 시침/분침의 각도를 얻거나 하는 등의 연산을 처리하게 할 수 있을 것이다. 혹은 수학 시간에 흔히 등장하는 소금물 문제를 푸는데 도음이 되는 클래스도 작성할 수 있을 것이다.