본문 바로가기
ZAION/C++

[이것이 C#이다]Ch.07 클래스 - 1

by 우기37 2024. 2. 5.

## 목차

1) 객체지향 프로그래밍과 클래스

2) 클래스의 선언과 객체의 생성

3) 객체의 삶과 죽음에 대하여: 생성자와 종료자

 1. 생성자

 2. 종료자

4) 정적 필드와 메소드

5) 객체 복사하기: 얕은 복사와 깊은 복사

6) this 키워드

 

 

 

 

#1 객체지향 프로그래밍과 클래스

객체지향 프로그래밍(Object Oriented Programming: OOP)은 코드 내 모든 것을 객체(Object)로 표현하려는 프로그래밍 패러다임을 뜻합니다. 여기에서 객체는 세상의 모든 것을 지칭하는 단어입니다.

 

이 세상에서 객체라고 할 만한 모든 것이 갖고 있는 속성과 기능을 뽑아내어 프로그래밍을 합니다.

C#에서는 이를 표현하려면 속성은 데이터로, 기능은 메소드로 표현하면 됩니다.

정리하자면 객체는 데이터와 메소드로 이루어집니다.

 

그렇다면 클래스는 객체를 만들기 위한 청사진입니다. 예를 들어 클래스가 자동차 설계도라면, 객체는 생산된 실제 자동차라고 할 수 있습니다.

 

설계도는 자동차가 어떤 속성과 기능을 가져야 하는지를 지정하고, 속성 중에 변경 가능과 불가능을 결정합니다. 실체를 가지지는 않습니다.

 

자동차는 실체가 있어서 도로나 주차 공간을 차지합니다. 차대 번호는 물론, 다양한 생상과 휠 사이즈를 가질 수 있습니다. 

 

정리하자면, 클래스는 객체가 가지게 될 속성과 기능을 정의하지만 실체를 가지지 않습니다. 클래스를 이용해 만든 객체가 실체를 가집니다. 동일 클래스로 객체 3개를 만들면, 이 세 객체는 서로가 구분되는 고유한 실체를 가지며 저마다 메모리 공간을 차지합니다.

 

string a = "123";
string b = "Hello";

 

string은 C#에서 이미 정의된 문자열을 다루는 클래스이고 a와 b는 객체입니다.

다시 말해, string은 문자열을 담는 객체를 위한 청사진이고, a와 b는 실제로 데이터를 담을 수있는 실제 객체입니다. a와 b를 일컬어서 string의 실체(instance)라고 하는데, 일반적으로 인스턴스라고 부릅니다. 그래서 객체를 인스턴스라고 부르기도 합니다.

 

객체에서 뽑아낸 속성과 기능은 클래스 안에 변수와 메소드로 표현 됩니다. 이런 식으로 프로그래밍하는 것이 바로 객체지향 프로그래밍입니다.

 

 

 

#2 클래스의 선언과 객체의 생성

클래스 안에 선언된 변수들을 일컬어 필드(Field)라고 합니다. 그리고 필드와 메소드를 비록하여 프로퍼티, 이벤트 등 클래스 내에 선언된 요소들을 일컬어 멤버(Member)라고 합니다.

 

[실습]

using System;

namespace ThisIsCSharpExam.Ch._07.ClassExam
{
    class BasicClass
    {
        public void Main()
        {
            Cat kitty = new Cat();
            kitty.Color = "하얀색";
            kitty.Name = "키티";
            kitty.Meow();
            Console.WriteLine($"{kitty.Name} : {kitty.Color}");

            Cat nero = new Cat();
            nero.Color = "검은색";
            nero.Name = "네로";
            nero.Meow();
            Console.WriteLine($"{nero.Name} : {nero.Color}");
        }
    }
    class Cat
    {
        public string Name;
        public string Color;

        public void Meow()
        {
            Console.WriteLine($"{Name} : 야옹");
        }
    }
}

 

 

 

 

#3 객체의 삶과 죽음에 대하여: 생성자와 종료자

객체가 생성될 대는 생성자가 호출되고 소멸할 때는 종료자가 호출됩니다.

 

 

1. 생성자

생성자는 클래스와 이름이 같고 반환 형식이 없습니다. 생성자의 임무는 해당 형식(클래스)의 객체를 생성하는 것뿐이기 때문입니다.

클래스를 선언할 때 명시적으로 생성자를 구현하지 않아도 컴파일러에서 생성자를 만들어 줍니다. 이런 생성자를 '기본 생성자' 라고 합니다.

 

그렇다면 컴파일러가 자동으로 생성해주는데 굳이 귀찮게 구현해야 하는 이유는 무엇일까요?

(저도 공감되는 궁금증이었습니다.)

 

객체의 필드를 원하는 값으로 초기화하고 싶을 때 매개변수를 입력받아 원하는 값으로 필드를 초기화할 수 있는 최적의 장소가 바로 생성자이기 때문입니다.

 

또한 생성자도 메소드와 마찬가지로 오버로딩이 가능합니다.

 

[예제]

class Cat
{
	public Cat()	// public : 한정자 / Cat() : 생성자
    {
    Name = "";
    Color = "";
    }
    
    public Cat(string _Name, string _Color)	// 객체를 생성할 때 이름과 색을 입력받아 초기화합니다.
    {
    Name = _Name;
    Color = _Color;
    }
    
    public string Name;
    public string Color;

 

이렇게 선언한 생성자는 다음과 같이 사용 할 수 있습니다. 매개변수가 없는 버전의 Cat() 생성자는 컴파일러가 자동으로 생성해준 생성자를 호출할 때처럼 사용하면 되고, 매개변수가 있는 버전의 Cat 생성자는 생성자의 괄호 안에 필요한 인수를 입력하면 됩니다.

 

Cat kitty = new Cat();	// Cat()
kitty.Name = "키티";
kitty.Color = "하얀색";

Cat nabi = new Cat("나비", "갈색");	// Cat(string _Name, string _Color)

 

*컴파일러가 기본 생성자를 제공하지 않는 경우

프로그래머가 생성자를 하나라도 직접 정의하면 C# 컴파일러는 매개변수 없는 기본 생성자를 제공하지 않습니다. 프로그래머가 생성자를 작성했다는 것은 객체가 특정한 상태로 초기화되기를 원한다는 뜻인데, 기본 생성자는 그런 의도와 상관없이 객체를 초기화합니다. C# 컴파일러는 프로그래머의 의도와 다르게 동작하는 코드가 제공되는 것을 방지하려는 것뿐입니다.

 

 

2. 종료자

종료자의 이름은 클래스 이름 앞에 ~를 붙인 꼴입니다.

그리고 종료자는 생성자와는 달리 매개변수도 없고, 한정자도 사용하지 않습니다. 또한 여러 버전의 종료자를 만드는 오버로딩도 불가능하며 직접 호출할 수도 없습니다. CLR의 가비지 컬렉터가 객체가 소멸되는 시점을 판단해서 종료자를 호출해 줍니다.

 

이렇게 종료자를 소개하기는 했지만, 아래와 같은 이유로 가급적 사용을 하지 않는 것이 좋습니다.

 

  1. 우리는 CLR의 가비지 컬렉터가 언제 동작할지 예츨할 수 없습니다. 그래서 중요한 자원을 종료자에서 해제하도록 놔뒀다가는 얼마 가지 않아 자원이 금세부족해지는 현상을 겼을 수도 있습니다.
  2. 응용 프로그램의 성능 저하를 초래할 확률이 높아 권장하지 않습니다. 가비지 컬렉터는 족보를 타고 올라가 객체로부터 상속받은 Finalize() 메소드를 호출하기 때문입니다.
  3. CLR의 가비지 컬렉터는 우리보다 훨씬 더 똑똑하게 객체의 소멸을 처리할 수 있습니다. 생성은 생성자에게 뒤처리는 가비지 컬렉터에 맡기는 편이 좋습니다. 

 

[실습]

using System;

namespace ThisIsCSharpExam.Ch._07.ClassExam
{
    class ConstructorExam
    {
        public void Main()
        {
            Cat1 kitty = new Cat1("키티", "하얀색");
            kitty.Meow();
            Console.WriteLine($"{kitty.Name} : {kitty.Color}");

            Cat1 nero = new Cat1("네로", "검은색");
            nero.Meow();
            Console.WriteLine($"{nero.Name} : {nero.Color}");
        }
    }
    class Cat1
    {      
        public Cat1()
        {
            Name = "";
            Color = "";
        }
        public Cat1(string _Name, string _Color)
        {
            Name = _Name;
            Color = _Color;
        }
        ~Cat1()
        {
            Console.WriteLine($"{Name} : 잘가");
        }
        public string Name;
        public string Color;

        public void Meow()
        {
            Console.WriteLine($"{Name} : 야옹");
        }
    }
}

 

 

 

 

#4 정적 필드와 메소드

"static은 '정적'이라는 뜻이며, 움직이지 않는다는 뜻입니다. C#에서 static은 메소드나 필드가 클래스의 인스턴스가 아닌 클래스 자체에 소속되도록 지정하는 한정자입니다."

 

한 프로그램 안에서 인스턴스는 여러 개가 존재할 수 있으나 클래스는 단 하나만 존재합니다. 어떤 필드가 클래스에 소속된다는 것은 곧 그 필드가 프로그램 전체에서 유일하게 존재한다는 것을 의미합니다.

 

인스턴스에 소속된 필드의 경우 클래스에 소속된필드의 경우(static) 
class MyClass
{
 public int a;
 public int b;
}

//

public static void Main()
{
 MyClass obj1 = new MyClass();
 obj1.a = 1;
 obj1.b = 2;

 MyClass obj2 = new myClass(0;
 obj2.a = 3;
 obj2.b = 4;
}
class MyClass
{
 public static int a;
 public static int b;
}

//

public static void Main()
{
 MyClass.a = 1;
 MyClass.b = 2; // <- 인스턴스를 만들지 않고 클래스의 이름을 통해 필드에 직접 접근합니다.
}

 

정적 필드를 사용하는 이유는 static으로 수식한 필드는 프로그램 전체에 걸쳐 하나밖에 존재하지 않습니다. 프로그램 전체에 걸쳐 공유해야 하는 변수가 있다면 정적 필드를 이용하면 됩니다.

 

[실습]

using System;

namespace ThisIsCSharpExam.Ch._07.ClassExam
{
    class StaticField
    {
        public void Main()
        {
            Console.WriteLine($"Global.count : {Global.count}");

            new ClassA();
            new ClassA();
            new ClassB();
            new ClassB();

            Console.WriteLine($"Global.count : {Global.count}");
        }
    }
    class Global
    {
        public static int count = 0;
    }
    class ClassA
    {
        public ClassA()
        {
            Global.count++;
        }
    }
    class ClassB
    {
        public ClassB()
        {
            Global.count++;
        }
    }
}

 

 

정적 메소드 역시 정적 필드처럼인스턴스가 아닌 클래스 자체에 소속됩니다. 정적 메소드가 클래스의 인스턴스를 생성하지 않아도 호출이 가능한 메소드라는 점입니다.

 

class MyClass
{
	public static void StaticMethod()
    {
    	//...
    }
}

//...

MyClass.StaticMethod();	// <- 인스턴스를 만들지 않고도 바로 호출 가능

 

클래스에 소속되는 정적 메소드와 달리 인스턴스에 소속된다고 해서 인스턴스 메소드라고 합니다. 이름처럼 클래스의 인스턴스를 생성해야만 호출할 수 있는 메소드입니다.

 

class MyClass
{
	public void InstanceMethod()
    {
    	//...
    }
}

//...
My Class obj = new MyClass();
obj.InstanceMethod();	// <- 인스턴스를 만들어야 호출 가능

 

보통 객체 내부 데이터를 이용해야 하는 경우에는 인스턴스 메소드를 선언하고,

내부 데이터를 이용할 일이 없는 경우에는 별도의 인스턴스 생성 없이 호출할수 있도록 메소드를 정적으로 선언합니다.

 

 

 

#5 객체 복사하기: 얕은 복사와 깉은 복사

다음과 같이 클래스를 선언합니다.

class MyClass
{
	public int MyField1;
    public int MyField2;
}

 

그리고 다음과 같이 두 개의 인스턴스 source와 target을 만들고 값을 할당했다고 해보겠습니다.

MyClass source = new MyClass();
source.MyField1 = 10;
source.MyField2 = 20;

MyClass target = source;
target.MyField2 = 30;

Console.WriteLine("{0} {1}", source.MyField1, source.MyField2);
Console.WriteLine("{0} {1}", target.MyField1, target.MyField2);

 

이 코드의 결과는 다음과 같습니다.

10 30

10 30

 

이제부터 왜 이런 결과가 나왔는지 확인해보겠습니다.

 

클래스는 태생이 참조 형식이기 때문입니다. 참조 형식은 힙 영역에 객체를 할당하고, 스택에 있는 참조가 힙 영역에 할당된 메모리를 가리킵니다.

 

그리고 source를 복사해서 받은 target은 힙에 있는 실제 객체가 아닌 스택에있는 참조를 복사해서 받습니다.

source와 target이 사이 좋게 같은 곳을 바라보게 됩니다. 이 때문에 target의 MyField2를 30으로 바꿧는데 source의 MyField2도 30으로 바뀌는 문제가 생긴 것입니다. 이렇게 객체를 복사할 때 참조만 살짝 복사하는 것을 얕은 복사(Shallow Copy)라고 합니다. 

 

이미지 출처 : https://velog.io/@qoehdcjswp/%EA%B0%9D%EC%B2%B4%EC%A7%80%ED%96%A5-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-7%EC%9E%A5-3-%EA%B9%8A%EC%9D%80-%EB%B3%B5%EC%82%AC%EC%96%95%EC%9D%80-%EB%B3%B5%EC%82%AC

 

하지만 우리가 원하는 것은 얕은 복사가 아닙니다. 다음 그림에서처럼 target이 힙에 보관되어 있는 내용을 source로부터 복사해서 받아 별도의 힙 공간에 객체를 보관하기를 바라는 것입니다. 이른바 깊은 복사(Deep Copy)입니다.

 

이미지 출처 : https://velog.io/@qoehdcjswp/%EA%B0%9D%EC%B2%B4%EC%A7%80%ED%96%A5-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-7%EC%9E%A5-3-%EA%B9%8A%EC%9D%80-%EB%B3%B5%EC%82%AC%EC%96%95%EC%9D%80-%EB%B3%B5%EC%82%AC

 

안타깝게도 C#에서는 이와 같은 일을 자동으로 해주는 구문이 없습니다. 우리 스스로 깊은 복사를 수행하는 코드를 만들어야 합니다.

 

[실습]

using System;

namespace ThisIsCSharpExam.Ch._07.ClassExam
{
    class DeepCopy
    {
        public void Main()
        {
            Console.WriteLine("Shallow Copy");
            {
                MyClass source = new MyClass();
                source.MyField1 = 10;
                source.MyField2 = 20;

                MyClass target = source;
                target.MyField2 = 30;

                Console.WriteLine($"{source.MyField1} {source.MyField2}");
                Console.WriteLine($"{target.MyField1} {target.MyField2}");
            }
            Console.WriteLine("Deep Copy");
            {
                MyClass source = new MyClass();
                source.MyField1 = 10;
                source.MyField2 = 20;

                MyClass target = source.DeepCopy();
                target.MyField2 = 30;

                Console.WriteLine($"{source.MyField1} {source.MyField2}");
                Console.WriteLine($"{target.MyField1} {target.MyField2}");
            }
        }
    }
    class MyClass
    {
        public int MyField1;
        public int MyField2;

        public MyClass DeepCopy()
        {
            MyClass newCopy = new MyClass();
            newCopy.MyField1 = this.MyField1;
            newCopy.MyField2 = this.MyField2;

            return newCopy;
        }
    }
}

 

 

 

 

#6 this 키워드

1. 나

this는 객체가 자신을 지칠할 때 사용하는 키워드입니다.

객체 외부에서는 객체의 필드나 메소드에 접근할 때 객체의 이름(변수 또는 식별자)을 사용한다면, 객체 내부에서는 자신의 필드나 메소드에 접근할 때 this 키워드를 사용한다는 것입니다.

 

[실습]

using System;

namespace ThisIsCSharpExam.Ch._07.ClassExam
{
    class ThisExam
    {
        public void Main()
        {
            Employee pooh = new Employee();
            pooh.SetName("Pooh");
            pooh.SetPosition("Waiter");
            Console.WriteLine($"{pooh.GetName()} {pooh.GetPosition()}");

            Employee tigger = new Employee();
            tigger.SetName("Tigger");
            tigger.SetPosition("Cleaner");
            Console.WriteLine($"{tigger.GetName()} {tigger.GetPosition()}");
        }
    }
    class Employee
    {
        private string Name;
        private string Position;

        public void SetName(string Name)
        {
            this.Name = Name;
        }
        public string GetName()
        {
            return Name;
        }
        public void SetPosition(string Position)
        {
            this.Position = Position;
        }
        public string GetPosition()
        {
            return this.Position;
        }
    }
}

 

 

 

2. this() 생성자

다음은 세 개의 생성자를 오버로딩하는 클래스의 코드입니다. 이 클래스는 int 형식의 필드 a,b,c를 갖고 있으며 세 개의 생성자는 입력받는 매개변수에 따라 이들 필드를 초기화합니다.

 

class MyClass
{
	int a, b, c;
    
    public MyClass()
    {
    	this.a = 5425;
    }
    public MyClass(int b)
    {
    	this.a = 5425;
        this.b = b;
    }
    
   public MyClass(int b, int c)
   {
   	this.a = 5425;
    	this.b = b;
        this.c = c;
   }
}

 

해당 코드는 우리가 원하는 대로 동작할 것입니다. 다만 세 개의 MyClass() 생성자 안에 똑같은 코드가 중복되어 들어가 있는 것이 마음에 걸릴 뿐입니다. MyClass()는 a를 초기화하니깐 MyClass(int)는 b만 초기화하고 a를 초기화하는 일은 MyClass()를 호출해서 처리할 수는 없을까요? 하지만 new 연산자 없이는 생성자를 호출할 수 없습니다. 이렇게 생성자를 호출하면 지금 생성하려는 객체 외에 또 다른 객체를 만들 뿐입니다. 우리가 원하는 상황이 아니죠.

 

이런 고민을 해결해주는 것이 this() 입니다. this가 객체 자신을 지칭하는 키워드인 것처럼, this()는 자기 자신의 생성자를 가리킵니다.  this()는 생성자에서만 사용할 수 있습니다. 그것도 생성자의 코드 블록 내부가 아닌 앞쪽에서만요.

 

[실습]

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ThisIsCSharpExam.Ch._07.ClassExam
{
    class MyClass1
    {
        int a, b, c;

        public MyClass1()
        {
            this.a = 5425;
            Console.WriteLine("MyClass1()");
        }
        public MyClass1(int b) : this()
        {
            this.b = b;
            Console.WriteLine($"MyClass1({b})");
        }
        public MyClass1 (int b, int c) : this(b)
        {
            this.c = c;
            Console.WriteLine($"MyClass1({b}, {c})");
        }
        public void PrintFields()
        {
            Console.WriteLine($"a:{a}, b:{b}, c:{c}");
        }
    }
    class ThisConstructor
    {
        public void Main()
        {
            MyClass1 a = new MyClass1();
            a.PrintFields();
            Console.WriteLine();

            MyClass1 b = new MyClass1(1);
            b.PrintFields();
            Console.WriteLine();

            MyClass1 c = new MyClass1(10, 20);
            c.PrintFields();
        }
    }
}