본문 바로가기

C#

2023.09.07 C# 정리

상속

부모 클래스를 다른 말로 기반 클래스 또는 슈퍼 클래스라고도 한다.

자식 클래스는 다른 말로 파생 클래스 또는 서브 클래스라고도 한다.

public class Computer
{
    bool powerOn;
    public void Boot(){};
    public void Shutdown(){};
    public void Reset(){};
}

public class Notebook : Computer
{
    bool fingerScan; // Notebook 타입에 해당하는 멤버만 추가
    public bool HasFingerScanDevice() { return fingerScan; }
    
    public void CloseLid()
    {
    	Shutdown();  // Notebook에 추가된 메서드 내에서 부모의 메서드 호출
    }
}

public class Desktop : Computer
{	
}
public class Netbook : Computer
{	
}

namespace ConsoleApp1
{
	class Program
    {
    	Notebook notebook = new Notebook();
        notebook.Boot();	// Notebook 인스턴스에 대해 부모 메서드 호출
    }
}

컴퓨터라는 공통된 특징을 가진 Notebook, Desktop, Netbook 클래스에서 Computer를 상속받았다.

콜론(:)으로 부모클래스의 기능을 물려받는다.

 

protected 접근 제한자

위의 예제에서 Computer의 메서드들에는 public이 적용되어 있고, powerOn필드에는 기본적으로 private가 적용돼 있다.

private접근 제한자가 적용된 멤버는 해당 클래스에서만 접근할 수 있다. 자식 클래스일지라도 접근이 허용되지 않는다.

public class Computer
{
    bool powerOn;
    public void Boot(){};
    public void Shutdown(){};
    public void Reset(){};
}

public class Notebook : Computer
{
    bool fingerScan; // Notebook 타입에 해당하는 멤버만 추가
    public bool HasFingerScanDevice() { return fingerScan; }
    
    public void CloseLid()
    {
    	if (powerOn == true)  // 컴파일 오류 발생: 접근 불가
        {
    	    Shutdown();  
        }
    }
}

외부의 접근은 차단하면서도 자식 클래스에서 사용하고 싶다면 접근 제한자를 protected로 설정하면 된다.

상기 코드에서 powerOn 필드에 protected를 명시해 주면 정상적으로 컴파일된다.

public class Computer
{
    protected bool powerOn;
    public void Boot(){};
    public void Shutdown(){};
    public void Reset(){};
}

 

다중 상속이 불가능하다.

class Computer { }
class Moniter { }

class Notebook : Computer, Moniter // 컴파일 오류 발생
{
}

형변환

기본 자료형의 형변환 관계를 다시 정리해 보자

정수, int형, short형이 있을 때 정수 -> int -> short 순으로 특수화된다.

 

암시적 형변환

특수화 타입의 변수에서 일반화된 타입의 변수로 값이 대입되는 경우

예) short a = 100;

      int b = a; // 암시적 형변환

 

명시적 형변환

일반화 타입의 변수에서 특수화된 타입의 변수로 값이 대입되는 경우

예) int c = 100;

     short d = (short) c; // 명시적 형변환

 

이 규칙은 class로 정의된 타입의 부모/자식 관계에도 동일하게 적용된다.

그림과 같이 Computer는 가장 일반적인 개념으로 그 범위는 Computer를 상속받은 Notebook을 포함한다. 

따라서 NoteBook 인스턴스를  Computer의 변수로 대입하는 경우에는 암시적 형변환이 가능하다.

Notebook notebook = new Notebook();

Computer pc1 = notebook;	// 암시적 형변환 가능
pc1.Boot();
pc1.Shutdown();

반대로 부모 클래스의 인스턴스를 자식 클래스의 변수로 대입하는 것은 암시적 변환이 불가능하다.

강제로 캐스팅 연산자를 사용해 명시적 형변환은 가능하지만 실행하면 오류가 발생한다.

Computer pc = new Computer();
Notebook notebook = (Notebook)pc;	// 명시적 형변환, 컴파일 가능, 실행하면 오류 발생

오류 발생 이유

Notebook에는 Computer가 정의하지 않은 3개의 멤버가 추가돼 있지만 new Computer(); 코드에서 할당한 메모리에는

Notebook을 위한 멤버의 특성을 반영하고 있지 않다. 그런 상태에서는 Notebook의 멤버를 호출하면 프로그램 실행이

엉망이 될 수 있으므로 실행 단계에서 오류가 발생한다.

 

일반적으로는 암시적 형변환이 자주 사용된다.

public class DeviceManager
{
    public void TurnOff(Computer device)
    {
        device.Shutdown();
    }
}

namespace Week1_4
{
    class Program
    {
        static void Main(string[] args)
        {
            Notebook notebook = new Notebook();
            Desktop desktop = new Desktop();
            Netbook netbook = new Netbook();

            DeviceManager manager = new DeviceManager();
            manager.TurnOff(notebook);
            manager.TurnOff(desktop);
            manager.TurnOff(netbook);
        }
    }
}

또는 각 자식 클래스의 인스턴스를 부모 객체의 배열에 담을 수 있는 것도 암시적 형변환 때문이다.

Computer[] machines =
 new Computer[] { new Notebook(), new Desktop(), new Netbook() }; // 암시적 형변환
 
 Devicemanager manager = new DeviceManager();
 
 foreach (Computer device in machines)
 {
 	manager.TurnOff(device);
 }

 

as, is 연산자

클래스의 형변환에서 빠질 수 없는 것이 바로 as 연산자이다.

앞서 명시적 형변환에서 실행 시 오류가 발생했는데 오류를 발생시키지 않고도 형변환이 가능한지 

확인할 수 있는 방법이 as연산자이다.

Computer pc = new Computer();
Notebook notebook = pc as Notebook;	// as 사용

if ( notebook != null)	// 코드대로라면 if문 내부의 코드가 실행될 가능성은 없다.
{ 
   notebook.CloseLid();
}

as는 형변환이 가능하면 지정된 타입의 인스턴스 값을 반환하고, 가능하지 않으면 null을 반환한다.

주의할 점은 as는 참조형 변수에 대해서만 적용할 수 있고, 참조형 타입으로의 체크만 가능하다는 점이다.

 

as 연산자가 형변환 결괏값을 반환하는 반면 is 연산자는 형변환 가능성 여부를 불린형의 결괏값으로 반환한다.

형변환한 인스턴스가 필요하다면 as를 사용하고 필요 없다면 is를 사용하면 된다.

int n = 5;
if (n is string) 
{
    Console.WriteLine("변수 n은 string 타입");
}

string txt = "text";
if(txt is int)
{
    Console.WriteLine("변수 txt는 int 타입");
}

두 if문의 조건이 false이므로 내부 문장이 실행되지 않는다.

is 연산자가 as 연사자와 다른 또 하나의 특징은 대상이 참조 형식뿐 아니라 값 형식에도 사용할 수 있다는 점이다.

그래서 위의 코드를 컴파일하면 이번에는 오류가 발생하지 않는다.

 

모든 타입의 조상: System.Object

클래스를 정의할 때 부모 클래스를 명시하지 않는다면 C#컴파일러는 기본적으로 object라는 타입에서

상속받는다고 가정한다.

 

이전 절에서 부모/자식 간의 형변환을 알아 봤는데 object는 모든 클래스의 부모이므로 다음과 같은 코드도 작성할 수 있다.

Computer computer = new Computer();
object obj1 = computer;
Computer pc1 = obj1 as Computer;

Notebook notebook - new Notebook();
object obj2 = notebook;
Notebook pc2 = obj2 as Notebook;

object는 그 자체가 참조형임에도 값 형식의 부모 타입이기도 하다.

모든 값 형식을 System.ValueType에서 상속받고 System.ValueType은 object를 상속받고 있다.

즉, 참조 형식은 System.ValueType을 제외한 모든 타입을 의미한다.

 

엄밀히 말해서 object는 C#에서 정의된 예약어이고, 실체는 System 네임스페이스에 정의된 Object 클래스로 존재한다.

 

object의 메서드

ToString

호출하면 해당 인스턴스가 속한 클래스의 전체 이름(FQDN)을 반환한다.

using System;


namespace Week1_4
{
    class Program
    {
        static void Main(string[] args)
        {
            Program program = new Program();
            Console.WriteLine(program.ToString()); // 출력 : Week1_4.Program
        }
    }
}

항상 FQDN을 반환하는 것은 아니다. ToString 메서드는 자식 클래스에서 기능을 재정의할 수 있기 때문에 string을 비롯

기본 타입(short, int,.....)은 모두 ToString을 FQDN이 아닌 해당 타입이 담고 있는 값을 반환한다.

using System;


namespace Week1_4
{
    class Program
    {
        static void Main(string[] args)
        {
            int n = 500;
            double d = 3.141592;
            string txt = "Hello : ";

            Console.WriteLine(txt + n.ToString()); // Hello : 500
            Console.WriteLine(txt + d.ToString()); // Hello : 3.141592

            txt = d.ToString();
            Console.WriteLine(txt); // 3.141592

        }
    }
}

 

GetType

클래스 역시 속성으로 클래스의 이름을 담고 있으며, 필드, 메서드, 프로퍼티와 같은 멤버를 담고 있는 또 다른 타입으로 볼 수 있다. 개발자가 class로 타입을 정의하면 내부적으로 해당 class타입의 정보를 가지고 있는 System.Type의 인스턴스를 보유하게 되고, GetType 메서드를 통해 호출할 수 있다.

namespace Week1_4
{
    class Program
    {
        static void Main(string[] args)
        {
            Computer computer = new Computer();
            Type type = computer.GetType();

            Console.WriteLine(type.FullName); // Type 클래스의 FullName 프로퍼티 호출. 출력: Computer
            Console.WriteLine(type.IsClass);  // Type 클래스의 IsClass 프로퍼티 호출. 출력: True
            Console.WriteLine(type.IsArray);  // Type 클래스의 IsArray 프로퍼티 호출. 출력: False
        }
    }
}

GetType 메서드를 사용하면 기본 타입에 대한 전체 이름을 반환할 수 있다.

int n = 5;
string txt = "text";

Type intType = n.GetType();

Console.WriteLine(intType.FullName);       //출력: System.Int32
Console.WriteLine(txt.GetType().FullName); //출력: System.String

GetType은 클래스의 인스턴스로부터 Type을 구하는 반면, 클래스의 이름에서 곧바로 Type을 구하는 방법도 제공되는데,

이때는 typeof라는 예약어를 사용해야 한다.

using System;

namespace Week1_4
{
    class Program
    {
        static void Main(string[] args)
        {
            Type type = typeof(double); 
            Console.WriteLine(type.FullName); // System.Double
            Console.WriteLine(typeof(int).FullName); // System.Int32
        }
    }
}

 

Equals

비교한 값을 불린형으로 반환

값 형식을 비교하면 해당 인스턴스가 소유하고 있는 값을 대상으로 비교하지만 참조 형식에 대해서는 할당된

주소가 같은지 비교함

참조 형식 비교에서 Equals의 동작 방식이 실용성이 부족하기 때문에 object는 하위 클래스에 Equals를 재정의

할 수 있도록 허용한다. 좋은 예로 string 참조 타입이 있다.

string txt1 = new string(new char[] {'t', 'e', 'x', 't'});
string txt2 = new string(new char[] {'t', 'e', 'x', 't'});

Console.WriteLine(txt1.Equals(txt2)); // True

 

GetHashCode

특정 인스턴스를 고유하게 식별할 수 있는 4바이트 int 값을 반환한다.

Equals와 연계되는 특성이 있다. Equals의 반환값이 True라면 서로 같음을 의미하고, 고윳값 또한 같아야 한다.

반면, False라면 GetHashCode의 반환값이 달라야 한다.

이 때문에 보통 Equals 메서드를 하위 클래스에서 재정의하면 GetHashCode까지 재정의 해야 하고,

재정의하지 않으면 컴파일 경고가 발생한다.

 

object에서 정의된 GetHashCode는 참조 타입에 대해 기본 동작을 정의해 뒀는데, 생성된 참조형 타입의 인스턴스가 살아 있는 동안 닷넷 런타임 내부에서 그러한 인스턴스에 부여한 식별자 값을 반환하기 때문에 적어도 프로그램이 실행되는 중에 같은 타입의 다른 인스턴스와 GetHashCode 반환값이 겹칠 가능성은 많지 않다.

값 타입에 대해서는 GetHashCode의 동작 방식을 재정의해서 해당 인스턴스가 동일한 값을 가지고 있다면

같은 해시코드를 반환한다.

class Program
    {
        static void Main(string[] args)
        {
            short n1 = 256;
            short n2 = 32750;
            short n3 = 256;

            Console.WriteLine(n1.GetHashCode()); // 256
            Console.WriteLine(n2.GetHashCode()); // 32750
            Console.WriteLine(n3.GetHashCode()); // 256

            Book book1 = new Book(97889998139018);
            Book book2 = new Book(97889998139018);

            Console.WriteLine(book1.GetHashCode()); //임의의 int
            Console.WriteLine(book2.GetHashCode()); //임의의 int

        }
    }

int 값으로 반환되기 때문에 가령, long 값에 대해 GetHashCode를 호출하면 어떤 경우에는 값이 다름에도 동일한 해시 코드가 반환될 수 있다.

이때는 Equals를 호출해서 정말 동일한 객체인지 판단해야 한다.

 

모든 배열의 조상 : System.Array

컴파일러는 자동적으로 Array 타입으로부터 상속 바는 것으로 처리한다.

이로 인해 배열 인스턴스는 Array 타입이 가진 모든 특징을 제공하는데 일부 속성 및 메서드는 알아두면 유용하다.

출처: https://dudghks117.tistory.com/22

using System;

namespace Week1_4
{
    class Program
    {
        private static void OutputArrayInfo(Array arr)
        {
            Console.WriteLine("배열의 차원 수: " + arr.Rank);
            Console.WriteLine("배열의 요소 수: " + arr.Length);
            Console.WriteLine();

        }

        private static void OutputArrayElements(string title, Array arr)
        {
            Console.WriteLine("[" + title + "]");

            for (int i = 0; i < arr.Length; i++)
            {
                Console.Write(arr.GetValue(i) + ", ");
            }

            Console.WriteLine();
            Console.WriteLine();
        }
        static void Main(string[] args)
        {
            bool[,] boolArray = new bool[,] { { true, false }, { false, false } };
            OutputArrayInfo(boolArray);

            int[] intArray = new int[] { 5, 4, 3, 2, 1, 0 };
            OutputArrayInfo(intArray);

            OutputArrayElements("원본 intArray", intArray);
            Array.Sort(intArray); // Sort 정적 메서드
            OutputArrayElements("Array.sort 후 intArray", intArray);

            int[] copyArray = new int[intArray.Length];
            Array.Copy(intArray, copyArray, intArray.Length); // Copy정적 메서드

            OutputArrayElements("intArray로부터 복사된 copyArray", copyArray);
        }
    }

   
}

배열은 System.Array로부터 상속받은 참조형 타입이다.


this

주로 사용하는 경우

매개변수와 클래스에 정의된 필드의 이름이 같을 때

class Book
{
	decimal isbn;
    
    public Book(decimal isbn)
    {
    	this.isbn = isbn;
    }
}

 

중복 코드 제거할 때

 

기존 코드 - 여러 생성자 사용으로 코드가 중복됨

class Book
    {
        string title;
        decimal isbn13;
        string author;

        public Book(string title)
        {
            this.title = title;
        }
        
        public Book (string title, decimal isbn13) 
        {
            this.title = title;
            this.isbn13 = isbn13;
        }

        public Book(string title, decimal isbn13, string author)
        {
            this.title = title;
            this.isbn13 = isbn13;
            this.author = author;
        }

        public Book() 
        {

        }
    }

개선된 코드

 class Book
    {
        string title;
        decimal isbn13;
        string author;

        public Book(string title) : this(title, 0)
        {

        }
        
        public Book (string title, decimal isbn13) : this(title,isbn13,string.Empty)
        {

        }

        public Book(string title, decimal isbn13, string author)
        {
            this.title = title;
            this.isbn13 = isbn13;
            this.author = author;
        }

        public Book() : this(string.Empty, 0, string.Empty)
        {

        }
    }

this를 사용해 또 다른 생성자를 호출하는 구문을 사용함으로써 초기화 관련 코드를 하나의 메서드 내에서 처리했다.

 

this와 인스턴스/정적 멤버의 관계

this는 new로 할당된 객체를 가리키는 내부 식별자이므로 클래스 수준에서 정의되는 정적 멤버는 this  예약어를 사용할 수 없다.

해당 메서드의 내부에서 this 예약어를 사용해야 한다면, 즉 인스턴스 멤버에 접근한다면 정적 메서드로 정의해서는

안 된다.

반면 this 예약어를 사용하지 않는다면 인스턴스 메서드로 만들거나 정적 메서드로 만들어 사용하는 것이 가능하다.

 

base 예약어

 

class Book
    {
       
        decimal isbn13;
        public Book(decimal isbn13)
        {
            this.isbn13 = isbn13;
        }
        
    }

    class EBook : Book
    {
        public EBook() : base(0) // base 없으면 에러 발생
        {

        }
       /* public EBook(decimal isbn) : base(isbn) //이렇게 값을 연계하는 것도 가능
        {

        } */
    }

base를 사용하지 않으면 오류가 발생되는데 이유는  자식 클래스가 생성되는 시점에 

부모 클래스의 생성자를 호출해야 하는데 기본 생성자가 부모 클래스에는 없기 때문.

부모에서 제공되는 Book(decimal isbn13) 생성자를  C#컴파일러가 자동으로 연계해 줄 수는 없다.

isbn13 값을 넣어주어야 하는데 어떤 값을 넣어야 할지 컴파일러 입장에서는 알 수 없을뿐더러

부모 클래스의 생성자가 여러 개 있는 상황에서는 어떤 생성자를 자동으로 호출해야 할지도 모호하다.

이런 경우에 base 예약어를 이용해 어떤 생성자를 어떤 값으로 호출해야 할지 명시해서 문제를 해결할 수 있다.

 

다형성

메서드 오버라이드

부모 클래서의 메서드를 자식 클래스에서 재정의

 

자식이 부모 타입으로 암시적 형변환이 된 경우

Lion lion = new Lion();
Mammal one = lion;

one.Move(); // Lion 클래스에서는 Move를 네 발로 움직인다로 재정의 되었으나 
            // 실제 출력은 부모 클래스인 Mammal에서 정의한 이동한다가 나온다.

이런 문제를 해결하기 위해 가상 메서드라는 것이 제공된다.

일반 메서드를 가상 메서드로 바꾸려면 virtual 예약어를 부모 클래스 단계에서 명시하면 된다.

그리고 자식 클래스에서는 해당 메서드가 다형성을 띠게 명시적으로 override 예약어를 지정하면 된다.

class Mammal
    {
        virtual public void Move()
        {
            Console.WriteLine("이동한다.");
        }
    }

    class Lion : Mammal
    {
        override public void Move()
        {
            Console.WriteLine("네 발로 움직인다.");
        }
    }

    class Whale : Mammal
    {
        public override void Move()
        {
            Console.WriteLine("수영한다.");
        }
    }

때로는 자식 클래스에서 다형성 차원에서가 아닌 순수하게 독립적인 하나의 메스드로 이름을 정의하고 싶은 경우도 고려해야 한다. 같은 이름의 메서드를 일부러 겹쳐서 정의했다는 개발자의 의도를 명시적으로 표현할 수 있게 new 예약어를 제공한다. 따라서 부모와 자식 클래스에서 동일한 이름의 메서드를 사용하려면 두 가지 중 하나를 선택해야 한다.

  1. 메서드 오버라이드를 원하는가? 그렇다면 virtual / override를 사용하라.
  2. 단순히 자식 클래스에서 동일한 이름의 메서드가 필요했던 것인가? 그렇다면 new를 사용하라.

base를 이용한 메서드 재사용

부모 클래스의 메서드 기능을 유지하면서 자식 클래스에서 메서드 오버라이딩을 하여 기능을 추가하고자 할 때 사용

public class Computer
    {
        virtual public void Boot()
        {
            Console.WriteLine("메인보드 켜기");
        }
    }

    public class Notebook : Computer
    {
        public override void Boot()
        {
            base.Boot();    // 부모 클래스의 Boot메서드
            Console.WriteLine("액정 화면 켜기");
        }
    }

위 코드는 base를 이용해 부모 클래스에서 제공되는 기능을 사용하고 있는 반면 이전 절에서 예로 든

Mammal/Lion의 관계에서는 base.Move 메서드를 호출하지 않고 있다. 이처럼 상황에 따라 부모 클래스의 원본 메서드

호출이 필요한지 여부가 달라질 수 있는데, 문제는 부모 클래스를 만들었던 개발자가 자식클래스에서 base를 

호출하거나 호출하지 못하게 강제할 방법이 없다는 점이다. 따라서 가상 메서드를 하위 클래스에서 오버라이드할 때는

반드시 상위 클래스의 도움말을 확인해야 하고, 자신이 상위 클래스 개발자라면 반드시 도움말을 기록해야 한다.

 

object 기본 메서드 확장

 

ToString을 재정의한 Point

public class Point 
{
	int x, y;
    
    public Point(int x, int y)
    {
    	this.x = x;
        this.y = y;
    }
    
    public override string ToString()
    {
    	return "X: " + x + ", Y: " + y";
    }
}

로그를 남기거나 통합 개발 환경에서 디버깅할 때 ToString에서 반환된 결과가 유용하게 쓰일 수 있다.

 

Equals와 GetHashCode 재정의한 Book

class Book
    {
        decimal isbn13;
        string title;
        string content;

        public Book(decimal isbn13, string title, string content)
        {
            this.isbn13 = isbn13;
            this.title = title;
            this.content = content;
        }

        public override bool Equals(object obj)
        {
            if (obj == null)
            {
                return false;
            }

            Book book = obj as Book;
            if (book == null)
            {
                return false;
            }

            return this.isbn13 == book.isbn13;
        }
    }
    
    class Program
    {        
        static void Main(string[] args)
        {
            Book book1 = new Book(9789, "리버스", "1");
            Book book2 = new Book(9789, "리버스", "1");
            Book book3 = new Book(3333, "파이썬", "1");

            Console.WriteLine("book1 == book2: " + book1.Equals(book2)); 
            Console.WriteLine("book1 == book3: " + book1.Equals(book3));
        }
    }
    
    // 출력 결과
    book1 == book2: True
    book1 == book3: False

객체의 키가 될 요소를 적절하게 찾는다면 Equals와 GetHashCode는 자연스럽게 만들어질 수 있다는 것이다.

 

오버로드

반환값은 무시하고 이름이 같은 메서드가 매개변수 수나 타입이 다른 경우.

크게 메서드 오버로드와 연산자 오버로드로 나뉜다.

 

메서드 오버로드

메서드의 매개변수 수나 타입을 다르게 하여 같은 이름의 메서드를 사용할 수 있다.

 

class Mathematics
{
    public int AbsInt(int value)
    {
        return (value >= 0) ? value : -value;
    }

    public double AbsDouble(double value)
    {
        return (value >= 0) ? value : -value;
    }

    public decimal AbsDecimal(decimal value)
    {
        return (value >= 0) ? value : -value;
    }
}
class Mathematics
{
    public int Abs(int value)
    {
        return (value >= 0) ? value : -value;
    }

    public double Abs(double value)
    {
        return (value >= 0) ? value : -value;
    }

    public decimal Abs(decimal value)
    {
        return (value >= 0) ? value : -value;
    }
}

연산자 오버로드

+연산자를 보면 숫자에 쓰면 서로 더해주지만 문자열을 이어 붙이는 역할도 한다.

string 타입이 더하기 연산자를 재정의한 것처럼우리가 만드는 어떠한 타입도 그렇게 할 수 있다.

무게의 단위를 나타내는 Kilogram을 클래스로 정의한다고 가정하자.

public class Kilogram 
{
	// ...생략...
	public Kilogram Add(Kilogram target)
    {
    	return new Kilogram(this.mass + target.mass);
    }
}


// 사용 예제
Kilogram kg1 = new Kilogram(5);
Kilogram kg2 = new Kilogram(10);

Kilogram kg3 = kg1.Add(kg2);

연산자 오버로드 없이 더하기 연산을 해야 한다면 일반적인 메서드를 이용해 각 기능을 구현해야 한다.

public class Kilogram 
{
	// ...생략...
	public static Kilogram operator + (Kilogram op1, Kilogram op2)
    {
    	return new Kilogram(op1.mass + op2.mass);
    }
}


// 사용 예제
Kilogram kg1 = new Kilogram(5);
Kilogram kg2 = new Kilogram(10);

Kilogram kg3 = kg1 + kg2;

+연산자 재정의한 코드

 

차이점) 1. 메서드 유형이 정적으로 바뀜

             2. operator 예약어와 함께 +연산자 기호가 메서드 이름을 대신함

https://www.sysnet.pe.kr/0/0

 

J & J - 정성태의 닷넷 이야기: Digital Stories

 

www.sysnet.pe.kr

https://ridibooks.com/books/1160000100

 

시작하세요! C# 10 프로그래밍

시작하세요! C# 10 프로그래밍 작품소개: 이 책의 목표는 여러분이 C#을 이용해 프로그래밍 기초를 탄탄하게 다질 수 있게 하는 것이다. 이를 위해 C# 언어의 최신 버전인 C# 10의 문법까지 구체적인

ridibooks.com