Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
 
(연관된 글이 3개 있습니다.)
(시리즈 글이 2개 있습니다.)
.NET Framework: 408. 자바와 닷넷의 제네릭 차이점 - 중간 언어 및 공변/반공변 처리
; https://www.sysnet.pe.kr/2/0/1581

.NET Framework: 743. C# 언어의 공변성과 반공변성
; https://www.sysnet.pe.kr/2/0/11513




자바와 닷넷의 제네릭 차이점 - 중간 언어 및 공변/반공변 처리

자바와 닷넷의 제네릭 구현에 대한 가장 큰 차이점은 중간 언어에 그것이 반영되었느냐로 나뉩니다. 닷넷은 반영되었고, 자바는 반영되지 않았습니다. 이것이 의미하는 바는 간단합니다. 닷넷은 중간 언어에 제네릭 타입이 생김으로써 타입 안정성이 생겼고, 자바는 언어 차원에서 제네릭을 구현했기 때문에 중간 언어에는 어떠한 정보도 출력되지 않아 타입 안정성이 없습니다.

장황한 글보다는 ^^ 코드로 설명하는 것이 낫겠지요. 일례로, 닷넷 개발자에게는 다음의 코드가 자바에서 컴파일되지 않는다는 것이 이해못할 현상입니다.

import java.util.*;

public class testC {

    private static void OutputArray(List<Integer> t2) 
    {
    }

    private static void OutputArray(List<String> t1) 
    {
    }
}

오류 메시지는 이렇습니다.

Method OutputArray(List<Integer>) has the same erasure OutputArray(List<E>) as another method in type testC

즉, OutputArray 메서드의 List 제네릭 클래스 형식 인자에 적용된 Integer와 String 타입은 자바 언어에서만 구분되는 것이기 때문에 컴파일이 안되는 것입니다. 간단히 말해서, 컴파일 하면 같은 바이트 코드를 가진 함수 2개가 나오는 것입니다.

실제로 타입 비교를 한번 해볼까요?

import java.util.*;

public class testC {

    public static void main(String[] args) 
    {
        List<String> t1 = new ArrayList<String>();
        List<Integer> t2 = new ArrayList<Integer>();
        
        System.out.println(t1.getClass() == t2.getClass());
    }
}

닷넷 개발자라면 위의 코드가 false라고 생각할 수 있지만, 자바에서 이를 실행하면 true가 나옵니다. 바이트 코드를 보면 그 이유를 알 수 있습니다.

 0:new             #16  <Class ArrayList>
 3:dup             
 4:invokespecial   #18  <Method void ArrayList()>
 7:astore_1        
 8:new             #16  <Class ArrayList>
11:dup             
12:invokespecial   #18  <Method void ArrayList()>
15:astore_2        
16:getstatic       #19  <Field PrintStream System.out>
19:aload_1         
20:invokevirtual   #25  <Method Class Object.getClass()>
23:aload_2         
24:invokevirtual   #25  <Method Class Object.getClass()>
27:if_acmpne       34
30:iconst_1        
31:goto            35
34:iconst_0        
35:invokevirtual   #29  <Method void PrintStream.println(boolean)>
38:return      

보시는 것처럼 바이트 코드에는 ArrayList 타입만 있을 뿐 제네릭 형식 인자의 정보는 없습니다. 그렇다고 Java 언어가 형식 정보를 아주 날려버리는 것은 아닙니다. 단지 바이트 코드 대신 속성(attribute) 테이블에 이를 보관해 놓았습니다

attribute_name_index: 40 LocalVariableTypeTable
attribute_length: 22
Nr  start_pc    length  name    signature   index
0   8   31  37 t1   41 Ljava/util/List<Ljava/lang/String;>;   1
1   16  23  39 t2   42 Ljava/util/List<Ljava/lang/Integer;>;  2

자바의 속성은 닷넷의 특성(attribute)과 유사합니다. 비록 속성으로 남겨놓긴 했지만 JVM 입장에서는 타입 정보로써 인식하는 것은 아니므로 단순히 ArrayList<object> 와 다를 것이 없습니다. 실제로 ArrayList에 5를 넣어볼까요?

=== 자바 코드 ====
t2.Add(5);

=== 바이트 코드 ====
16:aload_2         
17:iconst_5        
18:invokestatic    #19  <Method Integer Integer.valueOf(int)>
21:invokeinterface #25  <Method boolean List.add(Object)>

보시는 바와 같이 List.add 메서드의 인자는 Object 타입을 받도록 되어 있습니다. 즉, 값 타입인 5를 넣어도 자바는 Object 참조 타입으로 박싱한 다음 컬렉션에 값을 추가하는 것입니다.

따라서, 자바의 제네릭 컬렉션은 값타입과 참조타입간의 박싱/언박싱 문제가 여전히 발생합니다.

그럼 제네릭에 넘어가는 인자가 object와 다를 것이 없다면, 자바에서 다음의 코드는 왜 안되는 걸까요?

List<Integer> t2 = new ArrayList<Integer>();
        
t2.add("test");

처음에 말씀드렸다시피 자바는 제네릭을 언어 차원에서 구현한 것입니다. 즉, 컴파일러 차원에서는 t2 컬렉션의 형식 인자가 Integer라는 정보를 가지고 있으므로 이를 이용해서 컴파일이 안되도록 막을 뿐입니다.

만약, 바이트 코드를 임의로 끼워넣을 수 있어서 t2.add("test"); 에 해당하는 코드를 일부러 넣게 된다면 이 코드도 역시 정상적으로 실행될지도 모릅니다. (해보지 않아서 장담할 수 없군요. ^^)




마이크로소프트는 제네릭을 닷넷 2.0에서 구현했고, 자바의 경우 시기상으로 다소 늦은 5에서 구현했습니다. 대체로 늦게 나오는 경우 이전보다 더 낫게 구현하는 것이 보통인데 왜 자바는 스스로가 정적 언어이면서 형식 안정성을 보장하지 않는 방식을 택한 것일까요? 저도 모릅니다. ^^ 제가 자바를 만든 것이 아니므로. 단지 JVM 연합체의 다양성으로 인해 바이트 코드를 확장하는 것이 그리 녹록치는 않았을 거라는 짐작만 해봅니다. (대체로 다른 글을 보아도 그런 식으로 말하고 있습니다.) 사실 Java 언어 차원에서 단독으로 제네릭을 처리하는 방식으로 구현하면 IBM, Sun 등에서 이미 만들어진 JVM을 그대로 활용할 수 있을테니까요. 일종의 JVM 수준에서 하위 호환성을 유지한 것이라고 보면 될 것 같습니다.

그런데, 제네릭이 언어 차원에서 구현되었다고 해서 그리 나쁜 것만 있는 것은 아닙니다. 이에 대해서는 공변/반공변성과 연결됩니다. (자바의 제네릭에서 공변/반공변의 자유로움을 빼면 남는 것이 없죠.)

공변성과 반공변성
; http://blog.tobegin.net/39

공변성과 반공변성은 무엇인가? (What are covariance and contravariance?)
; https://www.haruair.com/blog/4458
; https://www.stephanboyer.com/post/132/what-are-covariance-and-contravariance

아래의 코드를 자세히 보면 DerivedClass가 BaseClass를 상속받았으므로 제네릭에도 이것이 당연히(?) 적용되어야 할 것으로 여겨집니다. 하지만 컴파일 오류가 발생하는데요.

using System.Collections.Generic;

namespace ConsoleApplication1
{
    class BaseClass { }

    class DerivedClass : BaseClass { }

    class Program
    {
        static void Main(string[] args)
        {
            List<DerivedClass> t = new List<DerivedClass>();
            OutputArray(t); // 컴파일 오류: The best overloaded method match for 'ConsoleApplication1.Program.OutputArray(System.Collections.Generic.List<ConsoleApplication1.BaseClass>)' has some invalid arguments
        }

        static void OutputArray(List<BaseClass> list)
        {

        }
    }
}

기본적인 면에서는 자바도 상황이 다르지는 않습니다. 역시 다음의 코드가 컴파일 되지 않습니다.

import java.util.*;

public class testC {

    public static void main(String[] args) 
    {
        List<DerivedClass> t = new ArrayList<DerivedClass>();
        OutputArray(t); // 컴파일 오류: The method OutputArray(List<Object>) in the type testC is not applicable for the arguments (List<DerivedClass>)
    }
    
    public static void OutputArray(List<BaseClass> t) 
    {
    }
}

그런데, 여기서 좀 이상합니다. 자바는 제네릭의 형식 타입이 지정되지 않기 때문에 당연히 위의 코드가 컴파일 되어야 하는데도 그렇지 않은 것입니다. 왜냐하면 자바는 최대한 형식 안정성을 보장하기 위해 언어 차원에서 이런 부분을 막기 때문입니다.

하지만 자바는 이런 경우에도 컴파일이 잘 될 수 있도록 특수한 목적의 형식 인자를 하나 마련해 두었는데요. 바로 '?'가 그런 역할을 해줍니다. 따라서 다음의 코드는 정상적으로 컴파일 됩니다.

import java.util.*;

public class testC {

    public static void main(String[] args) 
    {
        List<DerivedClass> t = new ArrayList<DerivedClass>();
        t.add(new DerivedClass());
        
        OutputArray(t);
    }
    
    public static void OutputArray(List<?> t) 
    {
    }
}

물론 이렇게 하는 경우 형식 인자를 상관하지 않기 때문에 BaseClass 상속 여부에 상관없이 모든 List 인스턴스를 전달할 수 있습니다.

import java.util.*;

public class testC {

    public static void main(String[] args) 
    {
        List<DerivedClass> t = new ArrayList<DerivedClass>();
        t.add(new DerivedClass());
        
        OutputArray(t);

        List<Integer> tInt = new ArrayList<Integer>();
        tInt.add(5);
        
        OutputArray(tInt);
    }
    
    public static void OutputArray(List<?> t) 
    {
    }
}

자바는 이에 대한 것도 역시 언어 차원에서 컴파일 오류를 낼 수 있도록 C#의 where과 유사한 제약을 걸 수 있도록 해두었습니다. 따라서 다음과 같이 해주면 반드시 BaseClass를 상속받은 타입만 형식인자로 지정된 List 타입을 받을 수 있습니다.

import java.util.*;

public class testC {

    public static void main(String[] args) 
    {
        List<DerivedClass> t = new ArrayList<DerivedClass>();
        t.add(new DerivedClass());
        
        OutputArray(t);

        List<Integer> tInt = new ArrayList<Integer>();
        tInt.add(5);
        
        OutputArray(tInt); // 컴파일 오류: The method OutputArray(List<? extends BaseClass>) in the type testC is not applicable for the arguments (List<Integer>)
    }
    
    public static void OutputArray(List<? extends BaseClass> t) 
    {
    }
}

C#의 경우 공변/반공변성의 필요성을 느끼고 그나마 C# 4.0에 와서는 인터페이스와 델리게이트에 대해서는 이를 허용하도록 보완했습니다. 그래서 다음과 같은 식의 제네릭 처리는 가능합니다.

using System.Collections.Generic;

namespace ConsoleApplication1
{
    class BaseClass { }

    class DerivedClass : BaseClass { }

    class Program
    {
        static void Main(string[] args)
        {
            List<DerivedClass> t = new List<DerivedClass>();
            OutputArray(t);
        }

        static void OutputArray(IEnumerable<BaseClass> list)
        {
        }
    }
}

여기서의 IEnumerable 제네릭 인터페이스는 형식 인자에 out 예약어가 지정되었기 때문에 상속 관계의 공변 처리가 가능해진 것입니다.

namespace System.Collections.Generic
{
    public interface IEnumerable<out T> : IEnumerable
    {
        IEnumerator<T> GetEnumerator();
    }
}

델리게이트의 경우에도 마찬가지의 규칙이 적용되어 다음과 같이 처리가 가능합니다.

using System;
using System.Collections.Generic;

namespace ConsoleApplication1
{
    class BaseClass { }

    class DerivedClass : BaseClass { }

    class Program
    {
        static void Main(string[] args)
        {
            Action<BaseClass> action = (elem) => { Console.WriteLine(elem); };
            Action<DerivedClass> d = action;
        }
    }
}

여기서 Action<T> 델리게이트는 형식 인자에 in 예약어가 지정되어 반공변 처리가 된 것입니다.

namespace System
{
    public delegate void Action<in T>(T obj);
}

이렇게 서로 간의 차이점을 이해함으로써 자바/닷넷 고유의 제네릭을 좀 더 잘 이해할 수가 있지요. ^^




대체로 의견을 종합해 보면 언어 차원에서 type erasure를 통해 VM 호환성의 제약을 극복하는 식으로 구현된 자바의 Generic보다는 중간 언어 수준에서 런타임과 연결된 닷넷의 Generic이 구조적으로 더 좋다는 평을 얻고 있습니다.

제 의견은 어떻냐고요? 다른 복잡한 것은 잘 모르겠고 자바의 제네릭이 박싱/언박싱 문제를 해결하지 못했다는 것은 좀 아쉬운 듯 합니다. ^^




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]






[최초 등록일: ]
[최종 수정일: 7/10/2021]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 



2014-01-15 03시39분
재미있는 슬라이드가 있습니다.

Why Java Sucks and C# Rocks (Final)
; http://www.slideshare.net/jeffz/why-java-sucks-and-c-rocks-final

저자의 말대로 편향적이긴 하지만 슬라이드 보다 보면 은근히 웃기기도 합니다. ^^
정성태
2014-01-23 12시49분
[장원웅] 개발중에 모르는것이 있어 구글링하다가 이렇게 들리게 되었는데,, 예제로 보니 정말 이해가 잘 되어서 너무 감사해서 댓글을 남김니다^^
[guest]
2014-01-23 02시21분
^^v
정성태
2014-08-28 12시39분
디버거를 통해서 본 C# Generics
; http://www.csharpstudy.com/Network/DevNote/Article/23

The Difference Between Invariance, Contravariance and Covariance in Generics in C#
; https://code-maze.com/csharp-the-difference-between-invariance-contravariance-and-covariance-in-generics/
정성태
2016-07-14 03시06분
[kernel] Action에 in 처리된 이유에 대한 예제를
  Action<BaseClass> action = (elem) => { Console.WriteLine(elem); };
  action(new DerivedClass());
가 아니라 혹시
  Action<BaseClass> action = (elem) => { Console.WriteLine(elem); };
  Action<DerivedClass> d = action;
  d(new DerivedClass());
로 작성하셔야 하는거 아닌가요? 코드가 누락된 것 같은 느낌이...
in keyword 없는 가짜Action 을 만들어서 예시대로 했더니 문제 없이 컴파일이 되어서요..
[guest]
2016-07-14 04시17분
@kernel님 지적이 맞습니다. ^^ T가 결국 BaseClass로 대체되어 빌드되는데, 그 메서드에 대한 호출에 Derived를 지정하는 것은 반공변과 무관한 예제입니다. 제가 너무 생각없이 마무리를 해버렸네요. ^^;
정성태
2016-07-14 05시23분
[kernel] 답변 고맙습니다. 하나 더 배워갑니다!
[guest]
2016-07-14 06시59분
@kernel 님 의견 주신 대로 예제를 수정했습니다. 제가 더 감사드립니다. ^^
정성태

[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13606정성태4/24/202446닷넷: 2247. C# - tensorflow 연동 (MNIST 예제)파일 다운로드1
13605정성태4/23/2024317닷넷: 2246. C# - Python.NET을 이용한 파이썬 소스코드 연동파일 다운로드1
13604정성태4/22/2024328오류 유형: 901. Visual Studio - Unable to set the next statement. Set next statement cannot be used in '[Exception]' call stack frames.
13603정성태4/21/2024523닷넷: 2245. C# - IronPython을 이용한 파이썬 소스코드 연동파일 다운로드1
13602정성태4/20/2024794닷넷: 2244. C# - PCM 오디오 데이터를 연속(Streaming) 재생 (Windows Multimedia)파일 다운로드1
13601정성태4/19/2024837닷넷: 2243. C# - PCM 사운드 재생(NAudio)파일 다운로드1
13600정성태4/18/2024846닷넷: 2242. C# - 관리 스레드와 비관리 스레드
13599정성태4/17/2024862닷넷: 2241. C# - WAV 파일의 PCM 사운드 재생(Windows Multimedia)파일 다운로드1
13598정성태4/16/2024884닷넷: 2240. C# - WAV 파일 포맷 + LIST 헤더파일 다운로드2
13597정성태4/15/2024862닷넷: 2239. C# - WAV 파일의 PCM 데이터 생성 및 출력파일 다운로드1
13596정성태4/14/20241049닷넷: 2238. C# - WAV 기본 파일 포맷파일 다운로드1
13595정성태4/13/20241050닷넷: 2237. C# - Audio 장치 열기 (Windows Multimedia, NAudio)파일 다운로드1
13594정성태4/12/20241068닷넷: 2236. C# - Audio 장치 열람 (Windows Multimedia, NAudio)파일 다운로드1
13593정성태4/8/20241079닷넷: 2235. MSBuild - AccelerateBuildsInVisualStudio 옵션
13592정성태4/2/20241217C/C++: 165. CLion으로 만든 Rust Win32 DLL을 C#과 연동
13591정성태4/2/20241193닷넷: 2234. C# - WPF 응용 프로그램에 Blazor App 통합파일 다운로드1
13590정성태3/31/20241078Linux: 70. Python - uwsgi 응용 프로그램이 k8s 환경에서 OOM 발생하는 문제
13589정성태3/29/20241150닷넷: 2233. C# - 프로세스 CPU 사용량을 나타내는 성능 카운터와 Win32 API파일 다운로드1
13588정성태3/28/20241262닷넷: 2232. C# - Unity + 닷넷 App(WinForms/WPF) 간의 Named Pipe 통신 [2]파일 다운로드1
13587정성태3/27/20241168오류 유형: 900. Windows Update 오류 - 8024402C, 80070643
13586정성태3/27/20241328Windows: 263. Windows - 복구 파티션(Recovery Partition) 용량을 늘리는 방법
13585정성태3/26/20241112Windows: 262. PerformanceCounter의 InstanceName에 pid를 추가한 "Process V2"
13584정성태3/26/20241060개발 환경 구성: 708. Unity3D - C# Windows Forms / WPF Application에 통합하는 방법파일 다운로드1
13583정성태3/25/20241195Windows: 261. CPU Utilization이 100% 넘는 경우를 성능 카운터로 확인하는 방법
13582정성태3/19/20241453Windows: 260. CPU 사용률을 나타내는 2가지 수치 - 사용량(Usage)과 활용률(Utilization)파일 다운로드1
[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...