ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 5.1 오버로딩 - 가장 특화된 함수
    프로그래밍 언어 속 타입 2022. 5. 17. 14:48

     

    이 글은 인사이트 출판사의 제안으로 작성 중인 책 『프로그래밍 언어 속 타입』 원고의 일부입니다.

     


     

    지금부터 벡터vector를 다루는 프로그램을 만들 것이다. 벡터는 컴퓨터 그래픽스나 기계 학습 등 여러 분야에서 사용되는 개념이다. 벡터가 친숙하지 않다면 단순하게 정수의 리스트라고 이해하면 된다. 예를 들면 [1, 3]이나 [2, -4, 7, 10] 등이 벡터다. 사실 정확히 말하면 정수의 리스트만 벡터인 것은 아니고 아무 수의 리스트나 다 벡터지만, 우리의 논의에는 별 영향이 없으니 그냥 정수의 리스트라 하겠다.

    다음 코드는 벡터를 나타내는 클래스를 정의한다.

    class Vector {
        List<Int> entries;
    }

    Vector 클래스는 벡터의 원소들을 나타내는 entries 필드를 가진다. 가령 [1, 3]이라는 벡터를 나타내는 객체의 entries 필드에는 List(1, 3)이 저장된다.

    벡터에는 “길이”라는 개념이 존재한다. 벡터의 길이는 그 벡터를 구성하는 원소를 각각 제곱하여 모두 더한 것의 제곱근이다. 예를 들면 [3, 4]의 길이는 , 즉 5다. 자세히 이해할 필요는 없다. 각 원소의 값이 길이에 영향을 준다는 사실만 알면 충분하다. 주어진 벡터의 길이를 구하는 length 함수를 다음과 같이 정의할 수 있다.

    Int length(Vector v) {
        v.entries[i] ...
    }

    벡터에 원소가 많다면 길이를 계산하는 등 각종 벡터 관련 연산을 수행하는 데 걸리는 시간이 늘어난다. 이럴 때 0을 많이 가진 벡터만 따로 특별하게 처리하면 성능에 도움이 된다. 원소의 대부분이 0인 벡터를 희소 벡터sparse vector라고 부른다. 예를 들면 [0, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, -1, 0, 0, 0, 0, 0, 0, 0]이 있다. 0이 아닌 원소의 위치를 미리 기억해 둔다면, 희소 벡터의 길이를 구할 때는 0이 아닌 원소들만 고려함으로써 계산에 걸리는 시간을 크게 줄일 수 있다.

    이를 위해 우선 Vector 클래스를 상속해서 희소 벡터를 나타내는 SparseVector 클래스를 정의한다.

    class SparseVector extends Vector {
        List<Int> nonzeros;
    }

    SparseVector 클래스는 상속받은 entries 필드에 더해 nonzeros 필드도 가진다. nonzeros는 0이 아닌 원소의 위치를 나타낸다. 예를 들어 [0, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, -1, 0, 0, 0, 0, 0, 0, 0]에서는 (제일 앞을 0번째라고 할 때) 4, 8, 20, 22번째 원소가 0이 아니니, 이 벡터를 나타내는 객체의 nonzeros에는 List(4, 8, 20, 22)가 저장된다.

    SparseVector 클래스를 정의했으니 이제 희소 벡터를 인자로 받아 효율적으로 길이를 구하는 함수를 작성할 수 있다.

    Int length(SparseVector v) {
        v.entries[v.nonzeros[i]] ...
    }

    지금까지 한 일을 정리하면 오버로딩된 두 개의 함수가 나온다.

    Int length(Vector v) { ... }
    Int length(SparseVector v) { ... }

    여기서 SparseVector는 Vector의 서브타입이다.

    이제 length를 사용해 보자. 일반적인 벡터의 길이를 계산할 때는 어려울 게 없다.

    Vector v = Vector(...);
    length(v);

    인자의 타입이 Vector이다. 첫 번째 length 함수의 매개변수 타입은 Vector이고 두 번째 length 함수의 매개변수 타입은 SparseVector이니, 고민의 여지가 없다. 당연하게도 첫 번째 함수가 선택된다.

     

     

    하지만 희소 벡터의 길이를 계산하려고 하면 새로운 고민거리가 생긴다.

    SparseVector v = SparseVector(...);
    length(v);

    인자의 타입이 SparseVector이다. 이 경우 두 length 함수 모두 호출 가능하다. SparseVector가 Vector의 서브타입이기 때문이다. 조금 전까지는 오버로딩된 함수를 호출할 때 인자의 타입에 맞는 함수가 하나뿐이었다. 하지만 이번에는 다르다. 인자의 타입을 만족하는 함수가 여러 개인 것이다.

     

     

    이럴 때 무슨 일이 일어나는지 알려면 함수 선택의 두 번째 규칙을 알아야 한다. 두 번째 규칙은 “인자의 타입에 가장 특화된most specific 함수를 고른다”는 것이다. 매개변수 타입이 Vector인 첫 번째 함수는 아무 벡터의 길이나 구할 수 있는 함수다. 한편, 매개변수 타입이 SparseVector인 두 번째 함수는 희소 벡터의 길이만 구할 수 있는 함수다. 그러니 첫 번째 함수보다 희소 벡터에 더 특화된more specific 함수라고 볼 수 있다. 함수가 두 개밖에 없고, 두 번째가 첫 번째보다 더 특화된 함수니, 가장 특화된 함수는 두 번째 함수다. 그러니 두 번째 length 함수가 호출된다.

     

     

    개발자의 입장에서 “인자의 타입에 가장 특화된 함수를 고른다”는 이 두 번째 규칙은 상당히 합리적이다. 앞에서 두 length 함수를 정의한 과정을 돌이켜 보면, 모든 벡터를 처리할 수 있는 첫 번째 length 함수를 정의한 뒤, 벡터에 0이 많은 특별한 경우만 더 효율적으로 처리하기 위해 두 번째 length 함수를 정의했다. 그러니 첫 번째와 두 번째가 모두 사용 가능한 경우라면, 두 번째를 사용함으로써 효율을 높이는 것이 우리의 의도다. 즉, 덜 특화된 함수와 더 특화된 함수를 모두 정의하는 데는 “가급적 더 특화된 함수를 사용하고 싶다”는 암묵적인 요구 사항이 있는 것이다. 따라서 함수 선택이 가장 특화된 함수를 고르는 게 매우 합리적이다.

    다만 “가장 특화된”이나 “더 특화된”이라는 표현은 직관적으로 이해하는 데는 좋아도 프로그램의 동작을 정확히 파악하는 데는 약간 불편하다. 좀 더 타입 검사기가 좋아하는 방식으로 표현할 필요가 있다. 앞서 매개변수 타입이 SparseVector인 함수가 매개변수 타입이 Vector인 함수보다 더 특화되었다고 했다. 직관적으로 이렇게 말할 수 있던 이유는 벡터 중 일부만이 희소 벡터이기 때문이다. 매개변수 타입이 SparseVector인 함수가 처리할 수 있는 벡터가 더 적으니, 그 적은 대상을 위해 더 특화된 동작을 제공할 것이라 기대하는 셈이다. “벡터 중 일부만이 희소 벡터”라는 말을 조금 바꿔 보면 “모든 희소 벡터가 벡터지만, 모든 벡터가 희소 벡터인 것은 아니다”라고도 할 수 있다. 우리는 이런 관계를 표현하는 개념을 이미 알고 있다. 바로 서브타입이다. SparseVector는 Vector의 서브타입이지만, Vector는 SparseVector의 서브타입이 아니다. 즉, 한 함수가 다른 하나보다 더 특화되었다는 말은 한 함수의 매개변수 타입이 다른 함수의 매개변수 타입의 서브타입이라는 뜻이다. (만약 매개변수가 여럿이라면, 순서대로 각 매개변수 타입이 서브타입 관계에 있는지 확인한다.) 또한, 주어진 함수들 중 가장 특화된 함수란, 그중 다른 어느 함수보다도 더 특화된 함수를 말한다.

    이처럼 가장 특화된 함수라는 개념이 서브타입으로 표현되기에, 함수 선택 시 타입 검사기가 관여한다. 서브타입 관계를 바탕으로 각 함수의 매개변수 타입을 비교하여 어느 함수가 더 특화되었는지 알아내는 것이다. 인자의 타입에 맞는 함수를 모두 찾은 뒤 그중 가장 특화된 함수를 찾으면 그 함수가 호출 대상이다.*

     

    * 사실 가장 특화된 함수가 존재하지 않는 경우도 있다. 이럴 때는 프로그램이 타입 검사를 통과하지 못한다. 다만 이런 경우가 드물기 때문에 다루지 않는다.

     

    이쯤 되었으면 오버로딩된 함수를 호출할 때 선택되는 함수가 충분히 명확할 것 같지만, 아쉽게도 아직 조금 부족하다. 무엇이 더 필요한지 알려면 우선 새로운 용어 두 개가 필요하다. 다음 코드를 생각해 보자.

    Vector v = SparseVector(...);

    SparseVector가 Vector의 서브타입이므로 문제없이 타입 검사를 통과하는 코드다. 여기서 집중할 부분은 v의 타입이다. 타입 검사기가 보기에는 v의 타입이 Vector이다. 타입 검사기는 변수 정의에 표시된 타입을 그대로 따른다. 그 변수에 실제로 어떤 값이 저장되는지는 신경 쓰지 않는다. 한편, 이 코드를 실제로 실행했을 때 v에 저장되는 값은 SparseVector 객체다. 따라서 변수 v가 실행 중에 저장하는 값의 타입은 SparseVector이다. 물론 SparseVector가 Vector의 서브타입이니 v에 저장된 값의 타입이 Vector라고 해도 틀린 말은 아니지만, SparseVector라고 하는 게 가장 정확한 표현이다.

    예시가 보여 주듯이 어떤 부품의 타입에는 두 종류가 있다. 하나는 타입 검사기가 알고 있는 타입이다. 다른 하나는 프로그램을 실행할 때 그 부품을 계산하면 실제로 나오는 가장 정확한 타입이다. 첫 번째 종류의 타입을 정적 타입static type, 두 번째 종류의 타입을 동적 타입dynamic type이라 부른다. “정적”은 “실행하기 전에”, “동적”은 “실행하는 중에”라는 뜻이라 했으니, 정적 타입은 실행하기 전에 타입 검사를 통해 알아낸 타입이고, 동적 타입은 실행하는 중에 진짜 값을 보고 알아낸 타입인 셈이다.

    위 예시의 경우, v라는 부품의 정적 타입은 Vector이다. v가 사용되는 모든 곳에서 타입 검사기는 v의 타입이 Vector라고 판단한다. 반면, v라는 부품의 동적 타입은 Vector가 아니라 SparseVector이다. v라는 부품을 계산해서 나오는 값은 변수 v에 저장되어 있는 값이므로, v에 저장된 객체의 타입인 SparseVector가 v의 동적 타입이다. 즉, 각 부품의 정적 타입과 동적 타입이 다를 수 있다.

    지금까지는 함수 선택 시에 인자의 정적 타입과 동적 타입이 같은 경우만 다루었다. 하지만 정적 타입과 동적 타입이 다르면 어떻게 될까? 대부분의 언어에서는 함수 선택 시에 정적 타입만을 고려한다. 이렇게 정적 타입을 바탕으로 함수를 선택하는 것을 정적 선택static dispatch이라 부른다. 타입 검사를 통해 인자의 정적 타입을 알아낸 뒤, 실행하기 전에 호출할 함수를 미리 선택하는 것이다. 정적 타입을 고려하든 동적 타입을 고려하든 무슨 상관일까 싶지만, 둘 중 무엇을 고려하는지는 생각보다 중요한 문제다. 정적 선택의 결과로 내 의도와는 다른 일이 일어날 수 있기 때문이다.

    다음 코드를 보자.

    Vector v = SparseVector(...);
    length(v);

    한편, 변수 v의 정적 타입은 Vector이고 동적 타입은 SparseVector이다. 즉, v가 실제로 나타내는 값은 희소 벡터이다. 개발자의 입장에서는 희소 벡터만을 더 효율적으로 처리하는 두 번째 length 함수를 통해 벡터의 길이를 구하는 것이 바람직하다. 하지만 실제로 호출되는 함수는 첫 번째 length 함수다. 정적 선택은 인자의 정적 타입만을 고려하는데, 인자의 정적 타입이 Vector이기 때문이다.

     

     

    정적 선택이라는 개념을 이해하지 않은 상태라면 이 코드가 효율적으로 길이를 계산한다고 생각할 수 있다. v의 값이 Vector 객체일 때는 첫 번째 length 함수, SparseVector 객체일 때는 두 번째 length 함수가 불리기를 기대하는 것이다. 하지만 이는 틀린 생각이다. 위 코드는 v의 값에 상관없이 언제나 첫 번째 length 함수만을 부른다. 그러니 나도 모르는 사이에 비효율적인 구현이 탄생한 것이다. 게다가 실행 중에 오류가 발생하는 것도 아니기 때문에 문제가 있다는 것을 발견하기도 어렵다. 그저 프로그램이 생각보다 느리게 작동할 뿐이다.

    그러니 함수 오버로딩을 사용할 때는 정적 선택을 잘 이해해야 한다. 어떤 경우에 내 기대와 다른 함수가 선택되는지 알고 있어야 나도 모르는 사이에 버그를 만들어 내는 일을 막을 수 있다. 버그를 방지하는 가장 간단한 방법은, B가 A의 서브타입일 때 A를 위한 함수가 이미 있다면 B를 위한 같은 이름의 함수를 추가로 정의하는 일을 가급적 피하는 것이다. 즉, 함수 오버로딩은 서로 완전히 다른 타입들의 값을 인자로 받는 함수를 정의하는 용도로 사용하는 게 좋다. 정수와 문자열을 인자로 받는 write 함수가 좋은 예시다. length 함수처럼 특정 타입의 서브타입을 위해 더 특화된 동작을 정의하는 게 목표라면 오버로딩은 썩 좋은 선택지가 아니다. 그런 걸 하고 싶다면 어떻게 해야 하냐고? 다음 절에서 다룰 메서드 오버라이딩method overriding이 그 해답이다. 본격적으로 메서드 오버라이딩을 알아보기 전에, 함수 오버로딩과 비슷한 메서드 오버로딩을 짧게 살펴보면서 오버로딩 이야기를 마무리하자.

     


    함수 선택 규칙

    1. 인자의 타입에 맞는 함수를 고른다.
    2. (인자의 타입에 맞는 함수가 여럿이면) 인자의 타입에 가장 특화된 함수를 고른다.
    3. 함수를 고를 때는 인자의 정적 타입만을 고려한다.
Designed by Tistory.