{{ label!='' ? 'Label : ' : (q!='' ? '검색 : ' : '전체 게시글') }} {{ label }} {{ q }} {{ ('('+(pubs|date:'yyyy-MM')+')') }}

[Java] 자바로 프로그래밍 입문하기: 3.3. 자료형 설계하기 (2)

불변성(Immutability)

  자바의 String과 같은 불변 자료형은 한 번 생성되면 그 값이 절대 변하지 않는다는 특성을 가지고 있습니다. 반면, 자바의 배열과 같은 가변 자료형은 변할 가능성이 있는 객체의 값들을 다루죠. 이 장에서 다루었던 자료형들 중, <Charge>, <Color>, <Stopwatch>, <Complex>는 불변 객체이며, <Picture>, <Histogram>, <Turtle>, <StcokAccount>, <Counter>는 가변 객체입니다. 자료형을 불변으로 만들 것인지에 대한 것은 중요한 설계 결정 중 하나이며, 응용프로그램의 목적에 따라 달라집니다.


불변형(Immutable types)

  많은 자료형의 목적은 변하지 않는 값을 캡슐화하고 기본 자료형과 같은 방식으로 동작하게 만드는 것입니다. 예를 들어, 한 프로그래머가 <Complex> 클라이언트를 구현한다면 아마도 코드에서 두 복소수 변수와 관해 z = z0와 같이 작성했을 것입니다. 이는 double형이나 int형 변수와 같은 방식이죠.

  하지만 <Complex>가 가변형일 때를 생각해보죠. z의 값이 z = z0이라는 대입문 이후에 바뀌었다면, z0 값 역시 바뀌게 됩니다. 왜냐하면, z와 z0은 같은 객체를 참조하고 있기 때문이죠! 이러한 예상치 못한 결과로 인해 흔히 앨리어싱(aliasing) 버그라고 하는 것이 발생하며, 객체지향 프로그래밍의 입문자들을 깜짝 놀라게 만드는 원인 중 하나입니다.

  우리가 불변 객체를 구현하는 아주 중요한 이유 중 하나는 대입문과 함수의 인자, 반환값으로부터 그것들이 변할 가능성에 대한 걱정을 덜어낼 수 있다는 사실입니다.


가변형(Mutable types)

  많은 자료형 추상화의 주된 목적은 변하는 값을 캡슐화하기 위함입니다. <Turtle, 프로그램 3.2.4>의 경우 전형적인 예가 될 수 있죠. 우리가 <Turtle>을 사용하는 이유는 클라이언트 프로그램에게 변하는 값을 추적할 책임을 없애주기 위함입니다. 비슷하게 <Picture>, <Histogram>, <StockAccount>, <Counter>, 자바 배열 등은 그 값이 변할 것이라고 예상되는 자료형들입니다. 


배열과 문자열

  여러분은 이미 클라이언트 프로그래머로서 이 둘의 차이를 직면했었죠. 자바 배열(가변)을 사용할 때와 자바 String 자료형(불변)을 사용할 때입니다. String의 메소드를 호출했을 때, String의 문자열이 바뀔 걱정을 하지 않아도 됐습니다. 하지만 배열과 관련된 메소드를 호출하면, 메소드는 배열을 언제든지 바꿀 수 있었죠.

  String 객체는 불변입니다. 일반적으로 우리는 String 값이 변화하는 것을 원치 않기 때문입니다. 자바의 배열은 가변입니다. 일반적으로 우리는 배열의 값이 변하기를 원하기 때문입니다. 물론 가변한 문자열을 원할 때도 있습니다. 이 때에는 자바의 <StringBuilder> 클래스를 사용할 수 있습니다. 또한 불변한 배열을 사용하고 싶을 때에는, <Vector>라는 클래스를 사용할 수 있습니다.


불변성의 이점

  일반적으로 불변형은 사용하기 더 쉽고 오용하지 않게 만들어줍니다. 값을 변하게 할 수 있는 코드의 범위가 가변 자료형의 경우보다 훨씬 더 적기 때문입니다. 또한 변수의 값이 항상 일관된 상태를 유지하기 때문에, 디버그가 더 쉬워집니다. 가변형을 사용할 때, 우리는 그 값이 언제든지 변할 수 있다는 사실을 명심해야 하죠.


불변성의 비용

  불변성의 단점은 모든 값에 대해서 항상 새로운 객체가 생성되어야 한다는 것입니다. 예를 들어, z = z.times(z).plus(z0) 식에서는 새로운 객체 생성을 수반하죠. z.times(z)가 반환한, 새롭게 생성된 값이 plus()를 호출합니다. 이 값에 대해서는 어떤 참조도 하지 않으면서도, 새롭게 객체를 생성하게 되는 것입니다. 

  <Mandelbrot, 프로그램 3.2.7>은 수많은 미아 객체들을 만들어냅니다. 하지만, 이러한 문제는 보통 감당 가능합니다. 자바의 가비지 컬렉터가 어느정도 이러한 문제를 최적화해주기 때문이죠. 어쨌거나 <Mandelbrot>의 경우 수많은 값들을 생성해낸다는 것이고, 우리는 이러한 상황이 발생할 수 있음을 인지해야 합니다.


Final

  자바는 불변성을 강제하기 위해 final 지정자를 지원합니다. 여러분이 변수를 final로 선언했을 때, 해당 변수는 생성자 혹은 초기자(initializer)에 의해 단 한 번만 값이 대입됨을 약속하는 것입니다. final 변수의 값을 변경하는 코드는 컴파일 에러를 일으킵니다. 우리는 final 지정자를 인스턴스 변수에 써넣어 그 값이 절대 변하지 않도록 할 것입니다. 

  이 정책은 값이 변하지 않게 하고, 의도치 않은 변경을 막아주며, 프로그램을 디버그하기 쉽게 만듭니다. 예를 들어 오류를 추적할 때 final 값들은 추적하지 않아도 됩니다. 그 값들은 절대 변하지 않음을 알기 때문이죠.


참조형(Reference types)

  안타깝게도, final은 불변성을 오로지 기본 자료형에 한해서만 보장해줍니다. 참조형은 보장되지 않죠. 만약 참조형 인스턴스 변수가 final 지정자를 갖고 있으면, 인스턴스 변수의 값(객체에 대한 참조)은 절대 변하지 않을 것입니다. 언제나 같은 객체를 참조할 것이라는 의미죠.

  하지만, 객체 그 자체의 값은 언제든지 변할 수 있습니다. 예를 들어, 여러분이 배열로 된 final 인스턴스 변수를 갖고 있다고 해보죠. 여러분은 배열의 길이와 같은 것들을 바꿀 순 없습니다. 하지만 배열의 요소들은 바꿀 수 있죠. 앨리어싱 버그가 또 생기겠군요. 다음 코드는 불변형을 구현하지 못합니다.

public class Vector {
    private final double[] coords;
    public Vector(double[] a) {
	coords = a;
    }
    ...
}

  클라이언트 프로그램은 <Vector>를 특정 배열을 명시함으로써 생성할 수 있습니다. 그리고 그 배열의 값을 여전히 변경할 수 있죠.


doubled a = { 3.0, 4.0 };
Vector vector = new Vector(a);
a[0] =17.0;

  인스턴스 변수 coords[]는 private이고 final이지만, <Vector>는 클라이언트가 해당 자료를 구현자가 아님에도 불구하고 참조를 쥐고 있으므로, 가변합니다. 가변형의 인스턴스 변수를 포함한 불변 자료형을 만들기 위해서는, 방어적 복사(defensive copy)라고 불리는 로컬 복사본을 만들어야 합니다. 이는 다음에 다뤄볼 것입니다.


  불변성은 어떤 자료형 설계에서든 고려되어야 합니다. 이상적으로, 자료형이 불변인지에 대해서 API로 명시해주어야 합니다. 그래야만 클라이언트가 해당 값이 변하지 않을 것임을 알 수 있기 때문입니다. 불변형을 구현하는 것은 참조형들 때문에 꽤나 부담이 될 수도 있습니다. 복잡한 자료형에 대해서는 복사본을 만드는 것 자체가 하나의 거대한 문제가 되기도 하죠. 


예제: 공간 벡터

  유용한 수학적 추상화의 맥락에서 이러한 개념들을 설명하기 위해, 벡터 자료형에 대해 알아보죠. 복소수와 같이, 벡터 추상화의 기본적 정의는 나름 친숙합니다. 지난 100년 간 수학에서 중추적인 역할을 해왔기 때문입니다. 

  선형대수학이라고 하는 수학 분야는 벡터의 속성과 관련이 깊습니다. 선형대수학은 많은 응용이 가능한 풍부하고 성공적인 이론이며, 사회 및 자연 과학의 모든 분야에서 아주 중요한 역할을 하고 있습니다. 선형대수학의 전체는 이 강의의 범위를 훨씬 뛰어넘지만, 몇몇 중요한 응용들은 기초적이고 친숙한 기초에 기반합니다. 따라서 우리는 벡터를 살짝 건드려볼 것이며, 곧 자료형의 추상화와 같이 캡슐화의 가치를 알 수 있을 것입니다.

  공간 벡터크기(magnitude)와 방향(direction)이 있는 추상적 개체입니다. 공간 벡터는 힘, 속도, 운동량, 가속도와 같은 물리 세계의 속성을 기술하는 자연스런 방법을 제시합니다. 벡터를 명시하는 방법 중 하나는 데카르트 좌표계에서 원점으로부터 특정 점으로 화살표를 그리는 것입니다: 방향은 원점으로부터의 화살표 방향이고, 크기는 화살표의 길이입니다. 따라서, 벡터를 명시하기 위해서는 (화살표가 도달할)특정한 점 하나를 명시하는 것만으로 충분하죠.

벡터

  이 개념은 어떤 차원의 숫자로든 확장할 수 있습니다. 순서가 있는 N개의 실수 목록은, N차원의 벡터로 표현할 수 있죠. 관습에 따라, 우리는 벡터와 숫자 혹은  괄호 안에 쉼표로 구분해 그것의 값을 적은 인덱스화된 변수를 참조할 때에는 굵은 글씨체로 표현할 것입니다. 예를 들어, x는 (x0, x1, ..., xN-1)을, y는 (y0, y1, ... , yN-1)과 같이 사용합니다.


API

   벡터에 정의된 기본 연산들을 살펴봅시다. 두 벡터를 더하고, 벡터를 스칼라(실수)로 곱하고, 두 벡터를 내적하고, 크기와 방향을 계산하는 방법입니다:

  • 합: x+y=(x0+y0, x1+y1, ... , xN-1+yN-1)
  • 스칼라곱: tx=(tx0, tx1, ... , txN-1)
  • 내적: x·y = (x0y0+x1y1+ ... + xN-1yN-1)
  • 크기: |x| = (x02+x12+ ... + xN-12)1/2
  • 방향: x/|x| = (x0/|x|, x1/|x|, ... , xN-1/|x|)

  합, 스칼라곱, 벡터의 방향은 다시 벡터로 그 결과가 나타나지만, 크기와 내적은 스칼라(double 값)로 그 값이 나타납니다. 예를 들어, x = (0, 3, 4, 0)이고, y = (0, -3, 1, -4)라면, x+y = (0, 0, 5, -4), 3x = (0, 9, 12, 0), x·y = -5, |x| = 5, x/|x| = (0, 0.6, 0.8, 0)입니다. 방향 벡터는 단위 벡터(unit vector)이며, 이는 크기가 1이라는 의미입니다. 이러한 정의들을 통해 즉시 다음 API를 만들어볼 수 있죠.

공간 벡터의 API


  <Complex>처럼, 이 API는 해당 자료형이 불변형인지 명시적으로 명세하지 않습니다. 하지만 우리는 (수학적 추상화에 대해 고심해본)클라이언트 프로그래머들이라면 이것이 아마 불변형일 것이라 예측할 것임을 알죠.


표현(Representation)

  보통 우리가 구현을 개발할 때의 첫번째 선택은 자료의 표현법을 고르는 것입니다. 생성자로부터 제공된 배열을 이용해서 데카르트 좌표계 값들을 담는 것은 명료한 선택이지만, 딱히 합리적인 방법은 아닙니다. 실제로 선형대수학의 기본적 교리 중 하나는, N개의 벡터들이 있는, 집합을 좌표계의 기저로 사용할 수 있다는 것입니다. 어떤 벡터든 N개의 벡터 집합의 선형 결합을 이용해서 표현될 수 있습니다. 이를 선형 독립이라고 하죠. 

  이를 이용해서 좌표계를 변경하는 것은 캡슐화와 잘 어울립니다. 보통 표현에 대해서 모든 것을 알지 못해도 클라이언트는 벡터 객체와 연산을 활용할 수 있죠. 


프로그램 3.3.3: Spatial vectors

public class Vector { 

    private final int n;         // length of the vector
    private double[] data;       // array of vector's components

    // create a vector from an array
    public Vector(double[] data) {
        n = data.length;

        // defensive copy so that client can't alter our copy of data[]
        this.data = new double[n];
        for (int i = 0; i < n; i++)
            this.data[i] = data[i];
    }

    // return this + that
    public Vector plus(Vector that) {
        if (this.length() != that.length())
            throw new IllegalArgumentException("dimensions disagree");
        Vector c = new Vector(n);
        for (int i = 0; i < n; i++)
            c.data[i] = this.data[i] + that.data[i];
        return c;
    }

    // return this - that
    public Vector minus(Vector that) {
        if (this.length() != that.length())
            throw new IllegalArgumentException("dimensions disagree");
        Vector c = new Vector(n);
        for (int i = 0; i < n; i++)
            c.data[i] = this.data[i] - that.data[i];
        return c;
    }

    // create and return a new object whose value is (this * factor)
    public Vector scale(double factor) {
        Vector c = new Vector(n);
        for (int i = 0; i < n; i++)
            c.data[i] = factor * data[i];
        return c;
    }
	
	// return the inner product of this Vector a and b
    public double dot(Vector that) {
        if (this.length() != that.length())
            throw new IllegalArgumentException("dimensions disagree");
        double sum = 0.0;
        for (int i = 0; i < n; i++)
            sum = sum + (this.data[i] * that.data[i]);
        return sum;
    }

    // return the Euclidean norm of this Vector
    public double magnitude() {
        return Math.sqrt(this.dot(this));
    }

    // return the corresponding unit vector
    public Vector direction() {
        if (this.magnitude() == 0.0)
            throw new ArithmeticException("zero-vector has no direction");
        return this.scale(1.0 / this.magnitude());
    }
	
	// return the corresponding coordinate
    public double cartesian(int i) {
        return data[i];
    }

    // return a string representation of the vector
    public String toString() {
        StringBuilder s = new StringBuilder();
        s.append('(');
        for (int i = 0; i < n; i++) {
            s.append(data[i]);
            if (i < n-1) s.append(", ");
        }
        s.append(')');
        return s.toString();
    }
}

구현

  <Vector>는 앞서 논의한 표현법을 이용해서 어렵지 않게 모든 연산을 구현합니다. 생성자는 클라이언트의 배열로부터 방어적 복사본을 만들며, 어떤 메소드도 복사된 배열의 값을 변경하지 않습니다. 따라서 <Vector> 객체는 불변합니다.

  cartesian() 메소드는 데카르트 좌표계에서 구현하기 쉽습니다. 배열의 i번째 좌표를 반환하면 되죠. 이는 말그대로 어떤 Vector의 표현법에서든 정의되는 수학적 함수를 구현한 것입니다. 데카르트 좌표계의 i번째 축으로 사영(projection)한 값입니다.


this 참조

  magnitude()와 direction() 메소드는 this라는 이름을 사용합니다. 자바는 인스턴스 메소드 코드 내에서, 해당 메소드를 호출하기 위해 사용된 객체를 참조할 수 있게끔 this 키워드를 제공합니다. 쉽게 말하면, 객체 자기 자신을 참조하는 것이죠. 

  this를 마치 참조형 변수의 이름처럼 사용할 수도 있습니다. 몇몇 자바 프로그래머는 해당 클래스의 인스턴스 변수를 참조할 때 항상 this를 붙여 사용하기도 합니다. 이러한 정책은 굉장히 방어적이죠. 왜냐하면 이것 자체로 해당 변수가 인스턴스 변수임을 나타내주기 때문입니다. 매개변수로 주어졌거나, 메소드 내에서만 살아있는 지역 변수가 아닌, 객체의 범위를 갖는 인스턴스 변수임을 확실히 나타낼 수 있죠.


  왜 굳이 <Vector> 자료형을 만들어가는 어려운 길을 자처할까요? 그냥 배열로 손쉽게 구현하면 더 좋지 않을까요? 이제는 이러한 질문에 여러분들도 쉽게 대답할 수 있죠: 모듈화 프로그래밍을, 손쉬운 디버깅을, 명확한 코드를 실현하기 위함입니다.

  배열은 모든 연산을 허용하는 자바의 저레벨 메커니즘입니다. <Vector>의 API를 통해 우리 스스로 그 연산을 제한하며(클라이언트들에게 필요한 만큼만), 설계의 절차를 간단화하고, 구현, 유지보수를 할 수 있게 됩니다. 

  또한 이는 불변형이므로, 기본 자료형처럼 사용할 수도 있죠. 예를 들어, <Vector>를 메소드에 전달했을 때, 그것의 값이 변하지 않을 것임을 알죠. 하지만 배열을 사용할 땐 아닙니다. 객체지향 프로그래밍의 더 많은 예제들을 보면 볼수록, 객체지향이 설계와 구현, 유지보수를 간단하게 만든다는 사실을 더 확고히 할 것입니다.

  <Vector>의 경우, <Vector>를 이용하고 잘 정의된 <Vector>의 연산을 활용하는 프로그램은 이러한 추상적인 개념을 중심으로 개발된 방대한 양의 수학적 지식을 쉽고 자연스러운 방법으로 참고할 수 있습니다.


계속.

댓글

이 블로그의 인기 게시물

[코딩의탑] 4층: 툰 쉐이딩

[코딩의탑] 3층: 바다 렌더링

[코딩의탑] 5층: 포탈(Portal), 더 나아가기