콘텐츠로 건너뛰기
Home » 벡터를 클래스로 표현하기 (Python)

벡터를 클래스로 표현하기 (Python)

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

개인적으로 데이터나 함수를 묶어두는 용도로 굳이 클래스를 써야하는가에 대해서는 회의적인 입장이다. 모든 객체인스턴스는 속성이나 메소드를 참조하기 위해서는 내부적인 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() 이라고 호출하는 것은 조금 어색해보인다. 왜냐하면 이건 벡터의 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 값은 각각의 성분값에 의해서 결정되는 값이기 때문에, 외부에서 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__ 는 나눗셈에 해당되는 연산이다. 또 앞에 “r” 이 붙은 이름이 있는데, 이들은 연산자의 우변에 위치할 때 호출하는 이름이다. 벡터를 곱할 때, v * 2 라고 쓴 경우에는 v.__mul__() 이 호출될 것이다. 반대로 2 * v를 호출한 경우라면, 2.__mul__() 이 호출되는데, int 타입 객체의 __mul__ 에는 벡터 클래스와 곱하기를 처리하는 방법이 없을 것이다. 처리할 수 없기 때문에 실패하는데, 예외가 발생되기 직전 반대로 v.__rmul__ 을 찾아서 호출한다. 메소드가 존재하고 값을 계산할 수 있다면 계산의 결과가 나오겠지만, 이 때에도 실패한다면 예외가 발생할 것이다.

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

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

벡터끼리의 연산

이처럼 연산자에 대응하는 메소드를 제대로 구현해주면 기본 연산자를 사용하여 계산할 수 있는 객체를 만들 수 있다. 아직 파이썬에는 새로운 연산자를 추가하는 동작은 지원하지 않는다. (처음보는 연산자들의 향연 같은게 가독성에 도움이 될리가 없으니…)

벡터와 백터의 덧셀과 뺄셈, 곱셈(내적 및 외적)을 어떤 연산자를 적용하여 계산할 수 있는지는 연산자와 대응하는 메소드 이름을 확인하면 된다. 이러한 메소드들은 ‘매직 메소드’라고 하는데 dir() 함수를 통해서 모두 확인할 수 있고, 대부분은 작동방법을 의미하는 단어나 그 약어로 되어 있어서 유추를 통해서 쉽게 찾을 수 있을 것이다.

또한 -v 와 같이 음수 부호를 붙여 역벡터를 반들기 위해서는 __neg__ 메소드를 작성해주면 된다. 정수와 실수는 모두 __neg__ 메소드가 존재한다. (그래서 -1 과 같은 표현을 이미 쓸 수 있다.) 그러므로 정수나 실수로된 속성 역시 음수일 때 부호를 앞에 붙여서 사용하는 것은 무방하니 -self.x 와 같은 표현을 쓸 수 있다. 따라서 각 성분에 대해 음수 부호를 붙인 값으로 새로운 벡터를 생성하면 되겠다.

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

벡터의 덧셈, 뺄셈 구현

이제 벡터와 벡터의 덧셈을 구현해보자. 벡터의 덧셈은 각 성분끼리의 합이다. 따라서 덧셈의 대상은 다른 벡터여야 하며, 벡터와 실수는 더할 수 없다. (벡터에 숫자값을 곱하는 것은 가능했다.) zip() 함수를 사용해서 계산할 때 두 벡터의 각각의 성분을 쉽게 짝지어서 연산하면 되겠다.

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)))

  def __radd__(self, other):
    return self.__add__(other)

벡터와 벡터끼리만 연산이 가능하기 때문에 사실 우연산 메소드인 __radd__() 는 사실 구현할 필요는 없는데, 위치만 바꿔서 계산하면 되는 것이기 때문에 인자의 위치만 바꾼 __add()__로 대체하였다.

뺄셈의 경우에도 덧셈과 같이 각 성분끼리 짝지어서 뺀 후에 새로운 벡터를 만들 수 있을 것이다. 그런데 뺄셈이라는 건 사실 음수로 반전시킨 값을 더하는 것과 같다. 이미 앞서 음수로 바꾸는 연산과 덧셈에 대한 연산을 모두 정의했으니 뺄셈은 이 둘을 결합하면 된다.

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

  def __rsub__(self, other):
    return -self + other

벡터의 곱셈

벡터는 두 가지 방식의 곱셈을 할 수 있다. 외적과 내적이라고 부르는 것이 그것이다. 내적은 각 성분끼리의 곱을 더한 것으로 계산된다. (선형대수학에서는 이것을 두 벡터의 선형결합이라고도 부른다.) 즉 두 벡터의 각 성분끼리 곱하고 그것을 모두 합산한 값이다. 내적은 보통 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__ 메소드를 호출한다. 사실 이 연산자는 2차원 리스트를 행렬로 취급할 때, 행렬에 대한 곱 연산위한 새 연산자이다. 어땠든 이 메소드를 사용하면 @ 연산자를 사용할 수 있게 된다는 말이며, 외적과 내적을 위한 각각의 연산자를 획득한 셈이다. (또 의미 상으로도 행렬곱과 내적은 거의 비슷하다.)

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를 사용해 작성하면 다음과 같다.

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

삼각형 ABC와 점 P가 있을 때에는 삼각형의 각 변과 대각의 꼭지점에 대해서 이 함수를 사용한 검사를 각 해보면 된다. 그렇게해서 모든 변에 대해 점 P가 꼭지점과 같은 방향에 존재한다면 점 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

이상으로 단순히 데이터를 저장하는 용도가 아닌 계산을 위한 값으로 활용할 수 있는 데이터 타입으로서의 클래스를 정의하는 것을 살펴보았다. 뭐 이렇게까지 할 필요가 있겠냐 싶은 생각도 들겠지만, 실제로 파이썬 표준 라이브러리에는 이런 방식으로 수의 범위를 확장한 Fraction 타입이나 Decimal 타입등이 존재한다.

생각해볼 문제

그 외에 단순한 수학적 형태외에도 서로 간에 계산이 가능한 양이나 정보를 이런 식으로 모델링하여 유용하게 사용할 수 있다. 시, 분, 초 정보를 가지고 덧셈, 뺄셈을 할 수 있는 Clock 이라는 클래스를 생각해볼 수 있을 것이다. 같은 타입끼리 더하거나 뺄 수 있을 것이며, 혹은 시,분,초를 초로 환산하거나 그 시각의 시계 시침/분침의 각도를 얻거나 하는 등의 연산을 처리하게 할 수 있을 것이다. 또 수학 시간에 흔히 등장하는 소금물을 모델링 할 수 있을 것이다.

class SaltWater:
@classmethod
def make(cls, density, weight):
s = weight * density
w = weight – s
return SaltWater(s, w)
def __init__(self, s, w):
self.w = w
self.s = s
def __repr__(self):
return f'{self.density * 100:.01f}%, {self.weight:.0f}g'
@property
def density(self):
return self.s / self.weight
@property
def weight(self):
return self.s + self.w
def addWater(self, w):
self.w += w
def addSalt(self, s):
self.s += s
def addSaltWater(self, sw):
if not isinstance(sw, SaltWater):
raise ValueError()
self.s += sw.s
self.w += sw.w
def __add__(self, sw):
if not isinstance(sw, SaltWater):
raise ValueError()
s = self.s + sw.s
w = self.w + sw.w
return SaltWater(s, w)
def __radd__(self, sw):
return self.__ad__(sw)
a = SaltWater.make(0.1, 400)
b = SaltWater.make(.16, 800)
print(a)
print(b)
c = a + b
print(c)
view raw salt.py hosted with ❤ by GitHub