Microsoft MVP성태의 닷넷 이야기
.NET Framework: 743. C# 언어의 공변성과 반공변성 [링크 복사], [링크+제목 복사]
조회: 5090
글쓴 사람
홈페이지
첨부 파일

C# 언어의 공변성과 반공변성

좋은 번역 글이 있군요. ^^

공변성과 반공변성은 무엇인가?
; https://www.haruair.com/blog/4458

[원글] What are covariance and contravariance? 
; https://www.stephanboyer.com/post/132/what-are-covariance-and-contravariance

본문의 내용을 C# 소스 코드로 옮겨 보면 다음과 같이 테스트할 수 있습니다.

class Program
{
    static void Main(string[] args)
    {
        Program pg = new Program();

        //pg.f(pg.g1); // Argument 1: cannot convert from 'method group' to 'Program.g'
        //pg.f(pg.g2); // Argument 1: cannot convert from 'method group' to 'Program.g'
        //pg.f(pg.g3); // 'Animal Program.g3(Animal)' has the wrong return type

        pg.f(pg.g4);
    }

    public delegate Dog g(Dog dog);

    string f(g func)
    {
        return "";
    }

    Greyhound g1(Greyhound arg)
    {
        return null;
    }

    Animal g2(Greyhound arg)
    {
        return null;
    }

    Animal g3(Animal arg)
    {
        return null;
    }

    Greyhound g4(Animal arg)
    {
        return null;
    }
}

class Greyhound : Dog
{
}

class Dog : Animal
{
}

class Animal
{
}

따라서, "공변성과 반공변성은 무엇인가?" 글의 본문에 따르면 C#에서도 Animal -> Greyhound는 Dog -> Dog의 서브타입입니다.




본문의 내용을 정리해 보면,

A <: B는 A가 B의 서브타입이고,
A -> B는 함수 타입으로 함수의 인자 타입은 A며 반환 타입은 B라는 의미일 때,

(Animal -> Greyhound) <: (Dog -> Dog)이다.

다소 말이 어려운데요. 쉽게 생각해서 인자 타입과 반환 타입의 권고 사항인 다음의 2가지를 비교해 기억하시면 됩니다.

  • 인자 타입은 최대한 추상적으로,
  • 반환 타입은 최대한 구체적으로,

예를 들어 다음의 메서드보다는,

Animal MyMethod(Greyhoud arg);

다음의 메서드가 더 좋은 시그니처입니다.

// 물론, MyMethod 안에서 사용하는 멤버가 Animal에 국한하는 것으로 가정!
// 다시 말해, 메서드 내에서 사용하는 멤버들을 만족하는 최소의 추상화 타입을 인자의 타입으로 명시
Greyhoud MyMethod(Animal arg);

그 이유는 메서드를 사용할 때를 생각하면 금방 이해가 됩니다. 가령 인자 타입을 가능한 추상화한 타입을 받기 때문에 다음과 같은 것들이 가능합니다.

MyMethod(new Dog());
MyMethod(new Animal());
MyMethod(new Greyhoud());
MyMethod(new Poodle());  // Poodle <: Dog

반대로 반환 타입의 경우에는 가능한 구체적인 타입이 좋습니다. 이것은 추상화한 타입을 반환할 경우와 비교하면 그 이유를 쉽게 알 수 있습니다. 가령, string 타입을 반환하는 메서드를 IEnumerable로 할 수는 있지만 해당 메서드를 사용하는 측에서 보면 이후 원하는 메서드를 사용하는데 제약이 많습니다.

IEnumerable GetText()
{
    return "test";
}

/*
GetText 메서드의 반환 값으로 호출할 수 있는 멤버가 제약이 됨.

GetText().Equals
GetText().GetEnumerator
GetText().GetHashCode
GetText().GetType
GetText().ToString
*/

반면, 최대한 구체적인(concrete) 타입을 반환하면,

string GetText()
{
    return "test";
}

/*
GetText 메서드의 반환 값으로 string의 모든 멤버를 호출 가능
*/

/*
느슨한 결합을 위해 concrete 타입보다는 interface가 선호되기도 하지만,
그런 경우에도 최대한 구체적인 interface를 선택합니다.
*/

위의 규칙을 염두에 두고 다음의 기호로 된 정의를 다시 보면,

(Animal -> Greyhound) <: (Dog -> Dog)

반환 타입의 경우는 최대한 구체적으로, 인자 타입은 최대한 추상화하는 것과 일치합니다.




또다시 정리해 보면, 공변/반공변은 각각 인자/반환 타입에 대해 다음과 같이 서술할 수 있습니다.

[A <: B 일 때 인자 타입에 대해]
    반공변이면,
        (B -> T) <: (A -> T)
    공변이면,
        (A -> T) <: (B -> T)

[A <: B 일 때 반환 타입에 대해]
    반공변이면,
        (T -> B) <: (T -> A)
    공변이면,
        (T -> A) <: (T -> B)

물론, C# 코드를 통해 알아본 것처럼 위의 4가지 경우 중 안전한 가변 규칙은 다음의 2가지로 압축됩니다.

[A <: B 일 때 인자 타입에 대해 반공변]
        (B -> T) <: (A -> T)

[A <: B 일 때 반환 타입에 대해 공변]
        (T -> A) <: (T -> B)

그런데, 타입스크립트(TypeScript) 언어의 경우 인자 타입이 이변적(bivariant)이라고 하면서 공변성과 반공변성을 동시에 지녔다고 하는데 이것은 다음의 규칙이 성립한다는 것입니다.

[A <: B 일 때 인자 타입에 대해]
    반공변이면서,
        (B -> T) <: (A -> T)
    공변,
        (A -> T) <: (B -> T)

[A <: B 일 때 반환 타입에 대해 공변]
        (T -> A) <: (T -> B)

물론 인자 타입에 대해 공변인 것은 안전하지 않기 때문에 이 문제를 해결하기 위해 TypeScript 2.6부터 컴파일 옵션을 통해 공변이면 컴파일 오류가 발생하도록 만들었다고 설명하고 있습니다.

반면, Eiffel이라는 언어는 인자 타입에 대해 다음과 같은 규칙이 성립한다는 것이고.

[A <: B 일 때 인자 타입에 대해 공변]
        (A -> T) <: (B -> T)

[A <: B 일 때 반환 타입에 대해 공변]
        (T -> A) <: (T -> B)




"공변성과 반공변성은 무엇인가?" 글에서 자바의 배열은 mutable인데 공변적이므로 부적절하다고 언급하고 있습니다. 이 말을 본문의 기호로 옮겨 보면 이렇습니다.

[A <: B 일 때]
        (A []) <: (B [])

혹은 이렇게 풀어쓸 수 있습니다.

[A가 B를 상속받았을 때]
    A []는  B []의 서브타입이다.

사실 이것은 자바뿐만 아니라 C#도 마찬가지입니다. C#도 배열은 공변이므로,

Greyhound[] greyhoundArr = new Greyhound[] { new Greyhound(), new Greyhound() };

Animal[] animalArr = greyhoundArr;  // 공변, 즉 Greyhound []은 Animal []의 서브타입이므로 컴파일 에러 없음
AnimalArrayCovariant(greyhoundArr); // 에러 없음

static void AnimalArrayCovariant(Animal[] args)
{
}

2가지 모두 컴파일 에러가 발생하지 않고, 심지어 이 때문에 다음과 같은 코딩이 가능합니다.

Greyhound[] greyhoundArr = new Greyhound[] { new Greyhound(), new Greyhound() };
Animal[] animalArr = greyhoundArr;

// 아래의 2개 모두 런타임 에러: System.ArrayTypeMismatchException: 'Attempted to access an element as a type incompatible with the array.' 
animalArr[0] = new Animal(); 
animalArr[0] = new Dog(); 

AnimalArrayCovariant(greyhoundArr);          

static void AnimalArrayCovariant(Animal[] args)
{
    // 아래의 2개 모두 런타임 에러: System.ArrayTypeMismatchException: 'Attempted to access an element as a type incompatible with the array.'
    args[0] = new Animal(); 
    args[0] = new Dog(); 
}

이런 문제 때문에 "공변성과 반공변성은 무엇인가?" 글에서 배열은 mutable인데 공변적이므로 부적절하다고 언급한 것입니다. 게다가 형변환 연산자를 사용하면 반공변적이기도 하다는 문제가 있습니다.

Greyhound[] greyhoundArr = new Greyhound[] { new Greyhound(), new Greyhound() };
Animal[] animalArr = greyhoundArr;
Greyhound[] greyhoundArr2 = (Greyhound[])animalArr;

배열의 이러한 공변성은 말 그대로 Greyhound[]가 Animal[]의 "서브타입"임을 의미합니다. 제가 혼란스러운 것은, 여기서의 "서브타입"은 상속 관계와는 무관하다는 점입니다. 실제로 다음과 같이 테스트를 해보면,

Type type1 = typeof(Greyhound[]);
Type type2 = typeof(Animal[]);

Console.WriteLine(type1.IsSubclassOf(type2)); // False
Console.WriteLine(type2.IsSubclassOf(type1)); // False

Console.WriteLine(type1.IsInstanceOfType(type2)); // False
Console.WriteLine(type2.IsInstanceOfType(type1)); // False

Console.WriteLine(type1.IsAssignableFrom(type2)); // False
Console.WriteLine(type2.IsAssignableFrom(type1)); // True

Greyhound[]가 Animal[] 간에는 상속 관계가 전혀 없으며 단지 "Assignable"하다는 정도로만 설명이 됩니다. 게다가 더욱 혼란스러운 것은 이 글의 처음에 제시했던 예제들도 delegate 차원에서 살펴보면 다음과 같이 Assignable한 관계도 아니라는 점입니다.

public delegate Greyhound GreyhoundAnimalDelegate(Animal arg);

GreyhoundAnimalDelegate greyhoundAnimal = pg.g4;
DogDogDelegate func = greyhoundAnimal; // 컴파일 오류: Cannot implicitly convert type 'Program.GreyhoundAnimalDelegate' to 'Program.g'

Type type1 = typeof(g);
Type type2 = typeof(GreyhoundAnimalDelegate);

Console.WriteLine(type1.IsAssignableFrom(type2)); // False
Console.WriteLine(type2.IsAssignableFrom(type1)); // False

전체적인 맥락에서 봤을 때 "서브타입"을 클래스 간의 물리적인 상속 관계가 아닌, 그와 유사한 논리적인 상속 관계로 이해할 수는 있겠는데 정확히 이것을 콕 집어서 설명할 수가 없으니 좀 답답한 감이 있습니다. 혹시 여기서의 "서브타입"이란 의미에 대해 학문적으로 잘 아시는 분은 덧글 부탁드립니다. ^^




C#의 경우, 공변/반공변이 대두가 된 것은 제네릭이 나오면서부터입니다.

자바와 닷넷의 제네릭 차이점 - 중간 언어 및 공변/반공변 처리
; https://www.sysnet.pe.kr/2/0/1581

"공변성과 반공변성은 무엇인가?" 글에서 나온 질문을 여기서도 해보겠습니다.

C#의 제네릭에서 List<Dog>이 List<Animal>의 서브타입일까요? 

이것은 C#의 제네릭이 공변이냐는 것으로 가령 List<T> 제네릭 타입을 예로 들면 다음과 같은 규칙이 적용되느냐에 해당합니다.

[A <: B 일 때]
    공변이면,
        (List<A>) <: (List<B>)

[A가 B를 상속받았을 때]
    List<A>는 List<B>의 서브타입이다.

이미 아시겠지만, C#의 제네릭은 위의 규칙을 만족하지 않아 공변적이지 않습니다. 따라서 다음과 같은 처리가 불가능합니다.

List<Greyhound> greyhoundArr = new List<Greyhound>(new Greyhound [] { new Greyhound(), new Greyhound() });

List<Animal> animalArr = greyhoundArr; // 컴파일 에러: Cannot implicitly convert type 'List<Greyhound>' to 'List<Animal>'

ListAnimalArrayCovariant(greyhoundArr); // 컴파일 에러: Argument 1: cannot convert from 'List<Greyhound>' to 'List<Animal>'

static void ListAnimalArrayCovariant(List<Animal> args)
{}

즉, C#의 제네릭은 공변적이지도, 반공변적이지도 않기 때문에 invariant(고정, 불변, 무공변)입니다. 따라서 이를 이용하면 배열의 공변 문제를 다음과 같이 해결할 수 있습니다. (Covariance and contravariance (computer science) 글에서 "With the addition of generics, Java and C# now offer ways to write this kind of polymorphic function without relying on covariance."라고 설명하는 그 내용입니다.)

List<Greyhound> greyhoundArr = new List<Greyhound>(new Greyhound[] { new Greyhound(), new Greyhound() });

List<Animal> animalArr = greyhoundArr;       // 컴파일 오류: 제네릭은 불변이므로.
ListAnimalArrayCovariant(greyhoundArr);      // 컴파일 오류: 제네릭은 불변이므로.

// 배열을 제네릭으로 구현
class GenericArray<T>
{
    T[] _elems;

    public GenericArray(T [] elems)
    {
        if (elems == null || elems.Length == 0)
        {
            return;
        }

        _elems = new T[elems.Length];
        Array.Copy(elems, _elems, elems.Length);
    }

    public T this[int index]
    {
        get
        {
            return _elems[index];
        }
        set
        {
            _elems[index] = value;
        }
    }
}




제네릭의 불변이 꼭 좋은 것만은 아닙니다. 왜냐하면, 이 글의 처음에 예시한 것처럼 인자 타입에서 반공변, 반환 타입에서 공변이어야 할 경우가 있기 때문입니다. C#에서는, 인터페이스와 델리게이트에 한해 제네릭이 사용되는 경우 공변/반공변을 허용하도록 C# 4 컴파일러부터 제네릭 타입 인자에 다음의 예약어 지원을 추가했습니다.

out: 공변
in: 반공변

각각의 가변성(variance)에 대해 예약어 지원을 추가하긴 했지만, 아무 위치에서나 사용하도록 허용하지는 않습니다. 즉, "인자 타입을 위한 반공변", "반환 타입을 위한 공변"인 경우에만 허용을 합니다. 따라서 in을 사용해 인자로 사용되는 반공변에 대해서는 다음과 같이 사용 가능하지만,

public interface IMyListContra<in TArg>
{
    void MyGet(TArg arg);
}

delegate void MyFuncCo<in TArg>(TArg arg);

반환에 대해 반공변으로 사용하려고 하면 컴파일 오류가 발생합니다.

// 컴파일 오류: Partial declarations of 'IMyList<TResult>' must have the same type parameter names and variance modifiers in the same order
public interface IMyList<in TResult>
{
    TResult MyGet();
}

// 컴파일 오류: Invalid variance: The type parameter 'TResult' must be covariantly valid on 'IMyList<TResult>.MyGet()'. 'TResult' is contravariant.
delegate TResult MyFuncCo<in TResult>();

마찬가지로, 반환 타입을 위한 out 인자는 가능하지만,

public interface IMyListCo<out TResult>
{
    TResult MyGet();
}

delegate TResult MyFuncContra<out TResult>();

인자 타입을 위한 공변을 하려는 경우 역시 컴파일 오류가 발생합니다.

// 컴파일 오류: Invalid variance: The type parameter 'TArg' must be contravariantly valid on 'IMyList<TArg>.MyGet(TArg)'. 'TArg' is covariant.
public interface IMyList<out TArg>
{
    void MyGet(TArg arg);
}

// 컴파일 오류: Invalid variance: The type parameter 'TArg' must be contravariantly valid on 'MyFuncContra<TArg>.Invoke(TArg)'. 'TResult' is covariant.
delegate void MyFuncContra<out TArg>(TArg arg);

물론, 2개 모두 개별 형식 인자에 지정할 수 있습니다.

// TArg: 반공변 형식 인자
// TResult: 공변 형식 인자
public interface IMyList<in TArg, out TResult>
{
    TResult MyGet(TArg arg);
}

이 정도면 지난 글(자바와 닷넷의 제네릭 차이점 - 중간 언어 및 공변/반공변 처리)과 함께 대충 공변/반공변에 대해서는 정리가 된 것 같습니다. ^^

(첨부 파일은 이 글의 예제 코드를 포함합니다.)




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

[연관 글]





[최초 등록일: ]
[최종 수정일: 5/10/2018 ]

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

비밀번호

댓글 쓴 사람
 



2018-05-04 12시21분
[용균] 리퍼러 링크가 있어서 오게 되었는데 너무 놀랐네요. 안그래도 C# 보다가 쉽게 와닿지 않아서 번역한 글이었는데 C#으로 설명해주셔서 제가 다 감사할 따름입니다. 늘 느끼지만 용어가 어려우면 실제 코드, 관념을 연결해서 생각하는게 쉽지 않은 것 같습니다.

중간에 배열 설명하시는 부분에서 서브타입은 상속 관계를 의미하는게 맞다고 생각합니다. 대신 명제의 역은 참이 아니기 때문에 공변성이 있다고 해서 관계가 서브타입이라고 추론할 수 없는 것이라고 보고 있습니다. 배열은 제네릭 이전부터 존재했기 때문에 mutable 하면서도 공변적으로 구현되어 있다고 생각하는데요. 제네릭으로 구현되어 있다고, 즉 System.Array<out TEntity> 식으로 생각하면 타입 관계가 좀 더 와닿는 것 같습니다.

IsAssignableFrom 메소드가 궁금해서 찾아보니 공변성, 반공변성 모두 확인해주네요. 설명에 또 많이 배워 갑니다.
[손님]
2018-05-04 04시21분
근데, 배열의 경우 상속 관계를 의미한다고 해버리면 그걸 증명할 길이 없습니다. 왜냐하면 닷넷의 Type 시스템에서는 명확히 그것이 상속 관계라는 걸 밝힐 수 있는 어떠한 방법도 제공하지 않고 있으며, 오히려 상속 관계를 알아내는 메서드 등에서는 전부 상속이 아니라고 나오기 때문입니다.

언급하신 "공변성이 있다고 해서 관계가 서브타입이라고 추론할수 없는 것"에는 수긍이 갑니다. 그런데, 사실 위와 같은 이유로 인해 현재 공변성이 있다는 것 외에는 서브타입을 판단할 수 있는 방법이 달리 없습니다. 아직 제가 미숙하다 보니 원글에서의 서브타입이라는 용어를 정확히 이해하기까지는 이 부분에 대한 결론은 좀 유보하려고 합니다. 어쨌든 한 걸음씩 나가다 보면 언젠가 명확해질 날이 있겠죠. ^^

용균님 덕분에 이 정도까지만이라도 정리할 수 있었습니다. ^^
정성태
2018-05-04 04시36분
[용균] 정성태님 댓글을 보니 제 덧글은 잘못된 내용이네요. 좀 더 공부해보고 제대로 정리해야겠습니다. 말씀 감사드립니다!
[손님]
2018-05-24 03시12분
[spowner] https://blogs.msdn.microsoft.com/ericlippert/2007/10/17/covariance-and-contravariance-in-c-part-two-array-covariance/
이 글에 의하면 배열이 컴파일 타임이 아니라 런타임에서 예외로 처리하는 이뉴는 Java를 CLR에서 수용하기 위해 규칙이 깨졌다고 이해가 됩니다. (한마디로 만가진 T_T)
하위 호환성을 유지하는 목적은 대중적으로 사용하는 언어에 필수적인 경우라, 이후 수정없이 유지된게 아닌가 해요

용균님과 성태님 덕분에 왜 C#에서 제네릭의 공변/반공변을 인터페이스와 딜리게이트에만 제한적으로 허용했는지를 이해하게 됩니다.
[손님]
2018-05-24 09시52분
제가 인용한 글에서의 "서브타입"이라는 것을, spowner님이 소개한 글에서는,

https://blogs.msdn.microsoft.com/ericlippert/2007/10/16/covariance-and-contravariance-in-c-part-one/
https://blogs.msdn.microsoft.com/ericlippert/2007/10/17/covariance-and-contravariance-in-c-part-two-array-covariance/

다음과 같은 기준으로,

T is bigger than U.
T is smaller than U.

by “bigger than” and “smaller than” I explicitly do NOT mean “is a supertype of” and “is a subtype of”.

설명하고 있군요. 즉, 명시적으로 서브타입이라고 쓰지 않고 "bigger than", "smaller than"이라고 바꿔 설명하고 있는 걸로 봤을 때 제가 인용한 글에서도 "서브타입"이라고 쓴 것을 다른 용어로 바꾸는 것이 더 좋을 듯 합니다. 암튼, 어려운 이야기들입니다. ^^

그나저나, Java와 맞춰 주기 위해서였다고는 하지만 Java가 없었다고 해도 충분한 논란이 있었을 것 같습니다. 개발의 편이성으로 봤을 때 현재 그 구조가 좋은 것은 사실이고, 반면 런타임에 체크해야 하는 오버헤드가 추가되었으니 성능 면에서는 손해지만... 휴... 이것도 역시 정답이 안 나오는 이야기같습니다. ^^
정성태

... 16  17  18  19  20  21  22  23  24  25  26  27  28  [29]  30  ...
NoWriterDateCnt.TitleFile(s)
11562정성태6/26/20182211개발 환경 구성: 385. 레지스트리에 등록된 원격지 스크립트 COM 객체 실행 방법
11561정성태6/26/20184727.NET Framework: 777. UI 요소의 접근은 반드시 그 UI를 만든 스레드에서! [3]파일 다운로드1
11560정성태3/2/20192137.NET Framework: 776. C# 7.3 - 초기화 식에서 변수 사용 가능(expression variables in initializers)파일 다운로드1
11559정성태1/27/20204920개발 환경 구성: 384. 영문 설정의 Windows 10 명령행 창(cmd.exe)의 한글 지원 [3]
11558정성태6/25/20182645.NET Framework: 775. C# 7.3 - unmanaged(blittable) 제네릭 제약파일 다운로드1
11557정성태6/22/20183085.NET Framework: 774. C# - blittable 타입이란?파일 다운로드1
11556정성태6/25/20185711.NET Framework: 773. C# 7.3 - 구조체의 고정 크기를 갖는 fixed 배열 필드에 대한 직접 접근 가능 [1]파일 다운로드1
11555정성태6/25/20182636.NET Framework: 772. C# 7.3 - 사용자 정의 타입에 fixed 적용 가능(Custom fixed)파일 다운로드1
11554정성태6/25/20182635.NET Framework: 771. C# 7.3 - 자동 구현 속성에 특성 적용 가능(Attribute on backing field)
11553정성태6/25/20183008.NET Framework: 770. C# 7.3 - 개선된 메서드 선택 규칙 3가지(Improved overload candidates)파일 다운로드1
11552정성태6/25/20183026.NET Framework: 769. C# 7.3에서 개선된 문법 4개(Support == and != for tuples, Ref Reassignment, Constraints, Stackalloc initializers)파일 다운로드1
11551정성태6/14/20181984개발 환경 구성: 383. BenchmarkDotNet 사용 시 주의 사항
11550정성태6/13/20182572.NET Framework: 768. BenchmarkDotNet으로 Span<T> 성능 측정
11549정성태6/13/20182116개발 환경 구성: 382. BenchmarkDotNet에서 생성한 BuildPlots.R 파일을 실행하는 방법
11548정성태6/13/20182611오류 유형: 470. .NET Core + BenchmarkDotNet 실행 시 프레임워크를 찾지 못하는 문제
11547정성태6/13/20182844.NET Framework: 767. BenchmarkDotNet 라이브러리 소개파일 다운로드1
11546정성태6/14/20183661.NET Framework: 766. C# 7.2의 특징 - GC 및 메모리 복사 방지를 위한 struct 타입 개선 [4]파일 다운로드1
11545정성태6/11/20183075오류 유형: 469. .NET Core 프로젝트를 Visual Studio에서 실행 시 System.BadImageFormatException 발생하는 경우 [1]
11544정성태6/10/20182681.NET Framework: 765. C# 7.2 - 숫자 리터럴의 선행 밑줄과 뒤에 오지 않는 명명된 인수
11543정성태6/10/20182356.NET Framework: 764. C# 7.2 - private protected 접근자 추가파일 다운로드1
11542정성태6/9/20183818개발 환경 구성: 381. Azure Web App 확장 예제 - Remove Custom Headers
11541정성태9/14/20182490개발 환경 구성: 380. Azure Web App 확장 배포 방법 [1]
11540정성태6/9/20182774개발 환경 구성: 379. Azure Web App 확장 예제 제작 [2]
11539정성태6/8/20182354.NET Framework: 763. .NET Core 2.1 - Tiered Compilation 도입파일 다운로드1
11538정성태6/8/20182085.NET Framework: 762. .NET Core 2.1 - 확장 도구(Tools) 관리
11537정성태6/8/20183541.NET Framework: 761. C# - SmtpClient로 SMTP + SSL/TLS 서버를 이용하는 방법 [4]
... 16  17  18  19  20  21  22  23  24  25  26  27  28  [29]  30  ...