본문 바로가기

IT 살이/04. 기술 - 프로그래밍

LINQ 시리즈 04 - type inference( 타입 유추 )

C#(3.0이상)의 타입 유추(type inference)는 쿼리 표현을 단순하게 만드는데 있어서 핵심적인 역할을 하는 기능중의 하나이다. 쉽게 말하면 변수의 타입을 정확히 명시하지 않고도, 앞 뒤 표현 문맥을 통해서 그 변수의 타입을 유추해 낼 수 있는 기능이다. 이 기능을 이용하면 변수의 타입에 대해서는 좀 덜 명확하게 되기는 하지만 "코드"가 좀 더 자연스런 언어처럼 된다. 여기서 "코드"란 쿼리 표현(query expression)을 말한다. 즉 쿼리 표현에서 처럼 자연스러움 즉 읽기 편함(readability)이 중요하고 명확한 타입 선언이 반드시 필요한 곳이 아니라면 타입 유추 기능은 의미를 갖게 된다. 사실 타입 유추 자체만으로는 그렇게 큰 의미가 없는 듯하다. 그러나 언어의 다른 기능과 함께할때 그 중요성? 편의성?은 커지는것 같다.

C#에서 타입 유추 메커니즘이 작동하는 경우는 2가지이다. var 타입의 변수를 사용할때와 그리고 제네릭을 사용할때 <T>을 생략하는 표현을 사용할 수 있는데 이때에 타입 유추가 수행된다.

■ var 타입의 변수 , local type inference

우선 var 키워드의 사용에 의한 타입 유추에 대해서 알아본다.다음과 같은 표현을 보자.

var a = 2;      //  a는 int형으로 선언된다.

object b = 2;   //  정수 2를 object로 박싱시킨다.

int c = a;      //  타입 변환 NO! 언박싱 NO!

int d = (int)b; //  타입 변환 필요! 언박싱 필요!

앞의 코드에서 var 타입의 변수를 사용했다고 해서 표현이 읽기 편해지고, 더 자연스럽게 되는 것은 아니다.  그러나 var가 뒤에서 배울 익명 타입(anonymous types)에서 사용될 수 있고 그리고 익명 타입이 쿼리 표현에서 사용된다는 것을 알게 되는 시점에서는 var가 표현을 간단히 해 줄 수 있다는 것을 알게 된다. 앞의 예제 코드에서는 타입 유추가 뭔지를 이해하는 것이 더 중요한 목적이라고 보면 된다.

var 타입은 분명 object 타입처럼 모든 타입의 변수로 될 수 있지만 object와는 다르다. C#이 코드를 컴파일할때, var 타입의 변수는 그 "주변의 표현"을 근거로 해서 변수의 타입을 유추한다. 그래서 IL 코드가 되는 순간에 var 타입의 변수는 구체적인 타입으로 변하게 된다. 앞의 변수 a에 값을 할당하는 코드는 IL코드가 되면 다음과 같은 코드와 동일한 코드가 되는 것이다.

int a = 2;

반면에 변수 b는 컴파일시에도 그 타입이 object로 남게 된다. 그래서 마지막 라인처럼 런타임시에 실제의 타입 int로의 변환이 필요하고 이때 언박싱이 수행된다. 따라서 좀 어려운 말로 하면 var를 이용하는 변수 선언은 type-safe 선언이라고 할 수 있다. 따라서 다음 코드는 컴파일시에 에러로 결정될 수 있다는 것이다. 컴파일시에!

int z = 0;

int y = 1;

var a = z + y;

a = "2"//  에러 !!

이 경우는 "int변수 + int변수"라는 표현을 통해서 a의 타입을 int로 유추해낸다. 마지막 코드에서 int 변수 a에 문자열 "2"를 할당하고 있다. 에러이다. 이렇게 로컬 변수의 타입을 결정할때 일어나는 타입 유추 과정을 특히 local type inference라고 부른다. 제네릭 타입의 유추와 비교할 수 있는 용어이다.

var 타입의 변수는 로컬 영역에서만 사용할 수 있다. 로컬 변수만 var로 정의될 수 있고, 클래스 멤버 나 파라미터는 var로 정의될 수 없다.  다음은 var를 사용한 몇 가지 유효한 코드의 예이다.

 public void ValidUse(decimal d)

{

    var x = 2.3;            // x 타입 : double

    var y = x;              // y 타입 : double

    var r = x / y;          // r 타입 : double

    var s = "sample";       // s 타입 : string

    var l = s.Length;       // l 타입 : int

    var w = d;              // w 타입 : double

    var p = default(string);// p 타입 : string

}

그러나 다음 코드는 var를 잘못 사용한 경우의 예이다.

class VarDemo

{

    // 클래스, 구조체, 인터페이스의 멤버 선언에 사용할 수 없다.

    var k = 0;


    // 파라미터 타입으로 사용할 수 없다.

    public void InvalidUseParameter( var x){}


    //반환값의 타입으로 사용할 수 없다.

    public var InvalidUseResult()

    {

        return 2;

    }


    public void InvalidUseLocal()

    {

        var x;        // "="할당이 필요하다.

        var y = null; // "null"로부터는 타입 유추를 할 수 없다.

    }

}

var 는 타입의 멤버 그리고 메소드의 파라미터에 사용될 수 없다는 것을 보여주고 있다. 그리고 InvalidUseResult()에서는 반환값이 2이므로 반환값 타입이 int라는 유추를 하는데는 무리가 없지만, 반환값의 타입으로 var는 허용되지 않는다. 이처럼 멤버나 파라미터, 반환값에 var를 허용하지 않음으로 해서 상속이나 오버로딩에 의해서 복잡해질 수 있는 문제를 막을 수 있게 되는 것이다. 예를 들어 파라미터 타입이 다른 오버로드 버전의 메소드를 정의해서 호출한다면 어떤 메소드가 호출되어야 하는지가 분명하지 않게 된다.

public void ExMethod(var x){ }

public void ExMethod(int i){ }

이렇게 정의한 두 버전의 오버로드가 있을때 ExMethod(2)를 호출하면 어떤 버전이 호출되겠는가. 이런 혼란을 허용하지 않겠다는 것이다.

그리고 마지막 InvalidUseLocal() 메소드에서는 var가 로컬 변수에서 사용되는 경우에도 허용되지 않는 경우를 보여주고 있다. 요는 로컬 변수에 사용될때에도 반드시 타입을 유추할 수 있는 표현이 필요하다는 것이다. 

■제네릭 타입의 유추

다음과 같은 제너릭 메소드를 정의했다고 해 보자.

T Min<T>(T a, T b) where T : IComparable<T>

{

    if (a.CompareTo(b) < 0)

        return a;

    else

        return b;

}

Min() 메소드는 타입 파라미터(type parameter) T와 같은 타입의 두 개의 파라미터 a, b를 받는다. 이때 타입 T는 IComparable 인터페이스를 구현해야만 한다는 것을 where이하에 표현하고 있다. 메소드 바디의 구현을 보면 이런 두 타입의 변수를 받아서 작은 값을 반환한다는 내용이다.

이 제네릭 메소드는 다음과 같은 모양으로 호출할 수 있다.

int a = 5;

int b = 10;

int c = Min<int>( a, b );

<int>를 통해서 파라미터 타입과 반환값 타입이 이미 밝혀졌기때문에 C# 컴파일러는 이 메소드를 다음과 같이 해석할 수 있기 때문이다.

int Min<int>( int a, int b ){ }

이것은 Min<int>()을 호출하고 나서 그 결과를 int로 타입변환하지 않아도 되는 이유이다.

제네릭 타입 T를 유추하기 위해서 앞의 코드에서는 <int>의 타입 파라미터 int를 이용해서도 알 수 있겠지만, 파라미터 a, b의 타입을 통해서도 가능하다. 즉 int a = 5; int b =10;을 통해서 a,b의 타입이 int형으로 밝혀지게 되고 그렇게 되면 타입 파라미터와 반환값의 타입 T도 밝혀지게 된다. 따라서 굳이 이 메소드를 호출할때 Min<int>(a, b)로 표현하지 않고도 다음처럼 호출해도 상관없게 된다.

int c = Min( a, b );

타입 유추라는 언어의 기능때문에 제네릭 메소드 Min<T>()의 호출에서 타입 파라미터 T가 생략되어 표현이 간단해지고 있다.

그럼, 다음 코드는 어떻게 될까 궁금해진다. 

static void Main(string[] args)

{

    int a = 0;

    int b = 1;

   int c = Min(a, b);

    Console.Read();

}


static T Min<T>(T a, T b) where T : IComparable<T>

{

    Console.WriteLine("Generic method called");


    if (a.CompareTo(b) < 0)

        return a;

    else

        return b;

}


static int Min(int a, int b)

{

    Console.WriteLine("General method called");

    if (a < b)

        return a;

    else

        return b;

}

Min(a, b)은 Min<T>()을 호출하는 것도 가능하고 Min( )을 호출하는 것도 가능하다. 그럼 어느 것이 우선권을 가질까? 아니면 우선권이 같기때문에 컴파일러는 에러를 내뱉을까? 실제로 이 코드를 실행시켜보면 제네릭 메소드보다 구체적인 타입의 메소드가 먼저 호출된다.

여튼 지금부터 이제 본격적으로 쿼리 표현에 가까워지게 될 것이다. 앞에서 계속 봐온 쿼리 표현이이다.

Customer[] customers = GetCustomers();

var query =


from c in customers


where c.Discount > 3


orderby c.Discount


select new { c.Name, Perc = c.Discount / 100 };

이 표현에서 c 변수에 대한 타입은 명시되지 않고 있다. 그러나 앞뒤 표현을 통해서 C# 컴파일러는 c의 타입이 Customer라는 것을 유추해낸다.

C#이 이 쿼리 표현을 어떻게 해석하는지 다시 한번 더 보자. 

var query = customers


       .Where ( c => c.Discount > 3 )


       .OrderBy( c=>c.Discount )


       .Select ( c=> new { c.Name, Perc = c.Discount /100 } );

var 타입의 변수 query에 어떤 값이 할당될까? customers 객체의 메소드가 차례로 호출되고 나서 마지막으로 Select()가 호출되고 나면 IEnumerable<T> 인터페이스를 구현하는 타입의 인스턴스가 반환된다. 여기서 타입 T는 순환의 대상이 되는 컬렉션의 요소의 타입을 나타낸다.  앞의 쿼리문에서 T는 구체적으로 Customer가 된다.

보니까 이 표현을 모두 설명하기 위해서 앞으로도 꽤 많은 것을 설명해야 할 것 같다. 람다 표현식("=>"을 포함한 표현), new {c.Name, ....} 부분을 익명 타입(anonymous type)이라고 하는데 이것도 알아봐야 하고....우 언제 끝나려나..이것을 다 설명하고도 본격적으로 LINQ에 대해서 설명해야 하는데....언젠가는 끝나겠지...


추가

글을 게시해놓고 보니까 type inference에 대한 요약이 없는 듯해서 다시 보충한다.

타입 유추(Type inference)라는 것은 컴파일러가 어떤 값의 타입을 주변의 표현식을 평가해서 자동으로 유추해낼 수 있는 능력을 말한다. 타입이라 하면 int, string같은 기본적인 타입뿐만 아니라 사용자 정의의 타입도 자동으로 만들어 낼 수 있다. 뒤에서 익명 타입(anonymous types)에 대한 설명이 나오겠지만 사용자 정의 타입을 만들때 이름을 주지 않고도 코드 상의 일부로 정의할 수가 있다. 마치 익명 메소드처럼. var 변수는 이런 익명 타입의 인스턴스도 받을 수 있는데, 컴파일러는 이런 인스턴스에 대한 타입을 컴파일시에 정의해서 이름도 내부에서 만들어 낸다. 이런 타입에 대한 평가와 결정 작업이 모두 컴파일시에 일어난다는 것을 기억할 필요가 있다. 즉 컴파일후 IL이 된 상태에서는 이미 내부적으로 타입이 결정되어 있는 상태가 된다.  컴파일시에 명확한 타입 표시가 없으면 컴파일러가 그 타입에 대한 평가를 시작하게 되는데, 컴파일러의 이런 타입 평가 시스템이 강력할 수록 프로그램이나 언어는 그만큼 더 간결해 질 수 있게 되는 것이다.

명확한 타입 표시가 없는 표현식에서 값에 대한 타입을 정확히 유추해내기 위해서 컴파일러는 주변 하위 표현식에서 주어진 타입 표시를 계속적이고 반복적인 작업을 통해서 미정의 타입에 대한 확률을 높여간다. LINQ 쿼리문에서의 하위 표현식이 간단치만은 않을 수 있다. 이런 쿼리문에서 특정 값의 타입을 유추해 내려면 필자도 잘은 모르겠지만, 내부적으로 아마 복잡한 타입 유추 알고리즘이 돌아가야 할 것이다.