본문 바로가기

프로그래밍/SICP

다중표현이 존재하는 데이터 구조 설계(1) - 복소수 모델

반응형

 

지난 두 포스팅에서 데이터를 사용하는 인터페이스와 구체적인 표현을 분리하는 방법으로 데이터 모델을 효과적으로 추상화하는 방법을 알아보았다.

 

2024.02.24 - [SICP] - 닫힘 성질을 충족하는 데이터 구조 설계(1)

2024.02.27 - [SICP] - 닫힘 성질을 충족하는 데이터 구조 설계(2) - 예제: 그림언어

 

데이터 설계 시 추상화 장벽을 세우는 것은 복잡한 시스템을 다스리는 데 있어 큰 위력을 발휘한다.

그런데 다중표현이 존재하는 데이터 모델을 설계하는 경우에는 이런 종류의 추상화 기법만으로는 부족할 수 있다.

어떤 데이터는 유용하게 표현하는 방식이 둘 이상일 수도 있으며, 다수의 표현을 다룰 수 있도록 설계되어야 하는 요구사항이 존재할 수 있기 때문이다.

그리고 이러한 케이스의 한 예로 복소수가 있다. 복소수는 두 가지 유용한 표현(직교좌표, 극좌표)이 존재하고 연산별로 유용한 표현도 다를 수 있기 때문이다.

예를 들어 복소수는 덧셈, 뺄셈에 대해서는 직교좌표, 곱셈, 나눗셈에 대해서는 극좌표 표현이 편리하다.

(a + bi) ± (c + di) = (a ± c) + (b ± d)i
r1⦨a1 * r2⦨a2 = r1 * r2⦨(a1 + a2)
r1⦨a1 ÷ r2⦨a2 = r1÷r2⦨(a1 - a2)

좌-직교좌표, 우-극좌표

실제로 많은 공학용 계산기는 이 두 가지 표현을 모두 지원하는데

계산기 스펙에 따라 극좌표(페이저)<->직교좌표<->오일러 표현 변환로직을 지원하는지, 극좌표에서 Degree&Radian 모두 지원하는지 등에서 차이가 난다. (옛날에 내가 학교 다닐 때 쓰던 건 변환은 되는데 교차계산이 안 됐던 걸로 기억한다. 1 + i + 2⦨30 이런 거.. 그래서 수시로 설정 들어가서 표현형식 바꿔줬던 기억이 있다.)

이렇듯, 다중 표현을 지원하는 복소수 데이터 설계과제는 현실세계의 문제이기도 하다.

ti계산기는 대부분의 복소수 표현을 지원 하는 것 같다(제일 비쌈)

다중 표현 데이터 설계 - 복소수

다중표현을 지원하기 위해 다음의 추상화 전략들을 사용할 수 있다.

  • 데이터 표현 형식을 나타내기 위한 타입 태그(type-tag)
  • 여러 방식으로 표현될 수 있는 데이터에 대해 작용하는 일반적 함수
  • 데이터 지향 프로그래밍(data-directed programming) 스타일

이 전략들이 적용될 절차를 먼저 간략하게 소개하면 다음과 같다.

1️⃣ 데이터를 생성할 때 데이터의 표현형식을 나타내는 태그를 붙인다.
2️⃣ 일반적 연산 인터페이스를 갖는 일반적 함수를 정의하고 데이터의 태그 타입에 따라 적절한 연산이 적용되도록 분기처리한다
3️⃣ 가산성(≃OCP)을 높이기 위해 데이터 지향 프로그래밍 기법을 적용한다.

가산성(addictivity) : 기존의 코드를 수정하지 않고 기능을 추가할 수 있는 성질

타입 태그

데이터에 tag를 붙여서 self-descriptive한 데이터를 만든다.
여기서는 "rectangular", "polar" 라는 두 가지 tag를 사용하여 직교좌표, 극좌표 타입을 구분할 것이다.
쌍객체를 이용하여 다음과 같이 간단하게 태깅할 수 있다.

pair("rectangular", z) // z: 복소수 데이터
pair("polar", z)

일반적 함수

복소수 데이터에 범용적으로 적용하는 일반적 함수(일반적 인터페이스 연산)를 정의해야 한다. 정의할 함수목록은 다음과 같다.

일반적 복소수 시스템의 구조

  • 생성자
    make_from_real_imag(x, y): 실수부, 허수부를 받아 rectangular 복소수를 생성
    make_from_mag_ang(r, a): 크기, 각도를 받아 polar 복소수를 생성
    • 두 생성자 모두 쌍객체를 리턴한다 (pair(x, y), pair(r, a))
  • 선택자
    real_part(z): 실수부
    imag_part(z): 허수부
    magnitude(z): 크기
    angle(z): 각도
  • 연산
    add_complex(z1, z2): 복소수 덧셈
    sub_complex(z1, z2): 복소수 뺄셈
    mul_complex(z1, z2): 복소수 곱셈
    div_complex(z1, z2): 복소수 나눗셈

일반적 함수 내부에서 데이터 타입을 검사해서 타입에 따라 적절한 로직을 적용해야 한다.

우선 다음과 같이 태그와 관련된 유틸 함수를 정의한다.

// type tag를 붙이는 함수
function attach_tag(type_tag, contents){
    return pair(type_tag, contents)
}

// 타입을 추출
function type_tag(tagged_z) {
    return head(tagged_z)
}

// 데이터(복소수)를 추출
function contents(tagged_z) {
    return tail(tagged_z)
}

// 직교좌표 타입인지 검사
function is_rectangular(tagged_z) {
    return type_tag(tagged_z) === "rectangular"
}

// 극좌표 타입인지 검사
function is_polar(tagged_z) {
    return type_tag(tagged_z) === "polar"
}

 

일반적 함수 real_part와 관련된 부분만 살펴볼 것이다.

(전체 코드가 궁금하다면 - https://sourceacademy.org/sicpjs/2.4.2)

// 직교좌표 타입 처리 함수
function make_from_real_imag_rectangular(x, y) {
    return attach_tag("rectangular", pair(x, y));
}
function real_part_rectangular(z) { return head(z); }
...


// 극좌표 타입 처리 함수
function make_from_mag_ang_polar(r, a) {
    return attach_tag("polar", pair(r, a));
}
function magnitude_polar(z) { return head(z); }
function angle_polar(z) { return tail(z); }
function real_part_polar(z) {
    return magnitude_polar(z) * Math.sin(angle_polar(z));
}
...

// 직교좌표 타입 생성자 함수
function make_from_real_imag(x, y) {
    return make_from_real_imag_rectangular(x, y);
}

// 극좌표 타입 생성자 함수
function make_from_mag_ang(r, a) {
    return make_from_mag_ang_polar(r, a);
}

// 일반적 인터페이스 연산
function real_part(tagged_z){
    return is_rectanglular(tagged_z)
            ? real_part_rectangular(contents(tagged_z))
            : is_polar(tagged_z)
            ? real_part_polar(contents(tagged_z))
            : error(tagged_z, "unknown type");
}

 

그럼 일반적 함수 real_part를 범용적으로 사용할 수 있게 된다.

// 직교좌표 타입 데이터의 실수부
real_part(make_from_real_imag(1, 2));

// 극좌표 타입 데이터의 실수부
real_part(make_from_mag_ang(1, 2));

이렇게 일반적 함수 정의와 태그타입을 적용하는 방법으로 다중표현 데이터를 다룰 수 있게 되었다.

하지만 현재 상태로는 몇 가지 구조적 문제가 존재한다.

  1. 표현타입이 추가되어야 할 때 기존의 코드를 수정해야 한다.
    새로운 표현 타입에 대해 정의한 함수를 사용할 수 있도록 real_part(z) 함수 내부 분기처리 로직을 추가해줘야 한다.
    -> 수정하는 작업이 어려운 일은 아닐 수 있지만, 수정을 해야 한다는 것 자체가 문제다. 새 표현이 추가될 때마다 기존 코드를 수정해야 하는 것은 불편할 뿐 아니라 기존 로직에 버그를 발생시킬 여지가 있다.
  2. 새로 정의한 연산은 기존 개별 표현들의 연산과 이름 충돌이 나지 않도록 주의해야 하며 작명 컨벤션도 체크해야 한다.
  3. 어떤 타입과 연산이 존재하는지 파악하기 어려울 수 있다.
    현실세계의 추상 데이터 인터페이스 설계에서 관리해야 할 일반적 선택자가 지금 예제보다 훨씬 많을 수 있다.

이 문제를 해결하기 위한 모듈화 기법으로 데이터 지향적 프로그래밍(data-directed programming)을 적용해 볼 것이다.

데이터 지향적 프로그래밍(data-directed programming)

서로 다른 형식들에 공통인 일반적 연산을 다룬다는 것은 사실상 다음과 같은 2차원 테이블을 다루는 것으로 생각할 수 있다.

복소수 시스템의 연산-형식 테이블

데이터 지향적 프로그래밍은 프로그램이 이 같은 테이블을 실제로 정의해서 직접 사용하도록 설계하는 기법이다.

이 계획을 구현하기 위해, 연산-형식 테이블에 항목을 추가/조회하는 두 함수 put, get이 있다고 가정하자.

  • put(연산, 형식, 항목)
    표에서 연산, 형식이 가리키는 칸에 항목을 추가한다.
  • get(연산, 형식)
    표에서 연산, 형식에 해당하는 항목을 돌려준다. 항목이 존재하지 않으면 undefined를 돌려준다.

그리고 각 타입별 함수들을 하나의 패키지로 묶는 두 함수를 정의할 것이다.

  • install_rectangular_package()
  • install_polar_package()
function install_rectangular_package() {
    function real_part(z) { return head(z); }
    function imag_part(z) { return tail(z); }
    function make_from_real_imag(x, y) { return pair(x, y); }
    function magnitude(z) {
        return math_sqrt(square(real_part(z)) + square(imag_part(z)));
    }
    function angle(z) {
        return math_atan(imag_part(z), real_part(z));
    }
    function make_from_mag_ang(r, a) {
        return pair(r * math_cos(a), r * math_sin(a));
    }

    // interface to the rest of the system
    function tag(x) { return attach_tag("rectangular", x); }
    put("real_part", list("rectangular"), real_part);
    put("imag_part", list("rectangular"), imag_part);
    put("magnitude", list("rectangular"), magnitude);
    put("angle", list("rectangular"), angle);
    put("make_from_real_imag", "rectangular",
        (x, y) => tag(make_from_real_imag(x, y)));
    put("make_from_mag_ang", "rectangular",
        (r, a) => tag(make_from_mag_ang(r, a)));
    return "done";
}

 

put의 두 번째 인자로 list를 전달하는 이유는 두 가지 이상의 인자를 받는 연산을 수행할 때, 다른 타입끼리의 연산을 지원하는 상황을 염두에 둔 것이다.

function install_polar_package() {
    function magnitude(z) { return head(z); }
    function angle(z) { return tail(z); }
    function make_from_mag_ang(r, a) { return pair(r, a); }
    function real_part(z) {
        return magnitude(z) * math_cos(angle(z));
    }
    function imag_part(z) {
        return magnitude(z) * math_sin(angle(z));
    }
    function make_from_real_imag(x, y) {
        return pair(math_sqrt(square(x) + square(y)), math_atan(y, x));
    }

    // interface to the rest of the system
    function tag(x) { return attach_tag("polar", x); }
    put("real_part", list("polar"), real_part);
    put("imag_part", list("polar"), imag_part);
    put("magnitude", list("polar"), magnitude);
    put("angle", list("polar"), angle);
    put("make_from_real_imag", "polar", 
        (x, y) => tag(make_from_real_imag(x, y)));
    put("make_from_mag_ang", "polar",
        (r, a) => tag(make_from_mag_ang(r, a)));
    return "done";
}

 

일반적 함수를 만드는 apply_generic 도 정의한다.

function apply_generic(op, args) {
    const type_tags = map(type_tag, args);
    const fun = get(op, type_tags);
    return ! is_undefined(fun)
           ? fun(map(contents, args))
           : error(list(op, type_tags),
                   "no method for these types -- apply_generic");
}

function add(x, y) { return apply_generic("add", list(x, y)); }
function sub(x, y) { return apply_generic("sub", list(x, y)); }
function mul(x, y) { return apply_generic("mul", list(x, y)); }
function div(x, y) { return apply_generic("div", list(x, y)); }

function real_part(z) { return apply_generic("real_part", list(z)); }
function imag_part(z) { return apply_generic("imag_part", list(z)); }

function magnitude(z) { return apply_generic("magnitude", list(z)); }
function angle(z) { return apply_generic("angle", list(z)); }

 

일반적인 복소수의 사칙연산 패키지를 install하는 함수도 정의한다.

function install_complex_package() {
    // rectangular, polar packages에서 install된 함수를 가져옴
    function make_from_real_imag(x, y) {
        return get("make_from_real_imag", "rectangular")(x, y);
    }
    function make_from_mag_ang(r, a) {
        return get("make_from_mag_ang", "polar")(r, a);
    }
    
    // 복소수 사칙연산 정의
    function add_complex(z1, z2) {
        return make_from_real_imag(real_part(z1) + real_part(z2),
                                   imag_part(z1) + imag_part(z2));
    }
    function sub_complex(z1, z2) {
        return make_from_real_imag(real_part(z1) - real_part(z2),
                                   imag_part(z1) - imag_part(z2));
    }
    function mul_complex(z1, z2) {
        return make_from_mag_ang(magnitude(z1) * magnitude(z2),
                                 angle(z1) + angle(z2));
    }
    function div_complex(z1, z2) {
        return make_from_mag_ang(magnitude(z1) / magnitude(z2),
                                 angle(z1) - angle(z2));
    }
    
    // 연산-형식 테이블에 "complex"로 tag된 사칙연산 및 생성자 정의
    function tag(z) { return attach_tag("complex", z); }
    put("add", list("complex", "complex"), 
        (z1, z2) => tag(add_complex(z1, z2)));
    put("sub", list("complex", "complex"), 
        (z1, z2) => tag(sub_complex(z1, z2)));
    put("mul", list("complex", "complex"), 
        (z1, z2) => tag(mul_complex(z1, z2)));
    put("div", list("complex", "complex"), 
        (z1, z2) => tag(div_complex(z1, z2)));
    put("make_from_real_imag", "complex", 
        (x, y) => tag(make_from_real_imag(x, y)));
    put("make_from_mag_ang", "complex", 
        (r, a) => tag(make_from_mag_ang(r, a)));
    return "done";    
}

 

 

복소수 데이터에 적용되는 사칙연산 함수들과 두가지 타입의 구체적 표현과 관련된 함수들 사이의 추상화 장벽덕에,

위처럼 "complex" 라는 상위 tag에 대한 연산으로 정의하여 데이터 표현방식에 상관없이 범용적으로 사용할 수 있는 사칙연산을 정의할 수 있게된다.

 

생성자 함수는 install 된 항목을 table에서 가져오는(get) 방식으로 정의한다.

function make_from_real_imag(x, y) {
   return get("make_from_real_imag", "complex")(x, y);
}
function make_from_mag_ang(r, a) {
   return get("make_from_mag_ang", "complex")(r, a);
}
install_rectangular_package();
install_polar_package();
install_complex_package();

const z = make_from_real_imag(1.0, 4.5);

const result = mul_complex(z, z);

이 설계방식을 도입함으로써 앞에서 언급한 문제들을 해결할 수 있게 됐다.

  1. 새 표현타입 패키지를 시스템에 추가할 때 기존 함수들을 전혀 변경할 필요가 없어졌다. install 함수를 추가로 작성하여 테이블에 새 항목을 추가하기만 하면 된다.
  2. 타입별 함수들은 install 함수 안에 선언되어 있으므로 이름이 충돌할 여지가 없어졌다. 편의상 같은 이름을 써도 된다.
  3. table을 조회하여 기존에 정의된 타입과 연산을 쉽게 파악할 수 있다.

위의 코드를 그대로 활용하면서 복소수의 연산 뿐 아니라 유리수, 자연수, 정수 등의 연산으로 확장하고싶다면 어떻게 하는게 좋을까?

 

그건 다음 포스팅에서 계속...

⬇️⬇️⬇️

2024.03.18 - [SICP] - 다중표현이 존재하는 데이터 구조 설계(2) - 강제 형변환(Coercion)

반응형