9.1 public 필드의 유혹
Get/Set 메소드 대신 할당 연산자 '='를 읽거나 할당하고 싶을 때가 있다.
은닉성을 지키자니 귀찮고, 안지키자니 신경쓰인다.
프로퍼티를 이용하면 은닉성과 편의성을 가질 수 있다.
class MyClass
{
private int myField;
}
GetXXX(), SetXXX() 메소드를 클래스에 추가해서 MyField에 접근할 수 있다.
class MyClass
{
private int myField;
public int GetMyField(){return myField;}
public void SetMyField(int NewValue){myField = NewValue;}
}
사용은 다음과 같이 할 것이다.
MyClass obj = new MyClass();
obj.SetField(3);
Console.WriteLine(obj.GetMyField());
만약 Java였다면 완벽히 정석이지만, C#은 프로퍼티라는 우아한 장치를 제공하기 때문에 이런 방식은 지양한다.
9.2 메소드보다 프로퍼티
프로퍼티는 다음과 같이 선언한다.
class 클래스_이름
{
데이터_타입 필드_이름;
접근_한정자 데이터_타입 프로퍼티_이름
{
get
{
return 필드_이름;
}
set
{
필드_이름 = value;
}
}
}
get{...}과 set{...}을 일컬어 접근자(Accessor)라고 한다.
get 접근자는 필드로부터 값을 읽어오고 set 접근자는 필드에 값을 할당한다.
set 접근자 안에 있는 value 키워드는 C# 컴파일러가 set 접근자의 암묵적 매개변수로 간주한다.
앞에서 봤던 MyClass를 프로퍼티를 이용해서 바꿔보자.
class MyClass
{
private int myField;
public int GetMyField() {return myField;}
public void SetMyField(int NewValue) {myField = NewValue;}
}
class MyClass
{
private int myField;
public int MyField
{
get
{
return myField;
}
set
{
myField = value;
}
}
}
이제 MyClass의 객체는 '=' 할당 연산자를 통해 myField 필드에 데이터를 저장하고 또 반대로 데이터를 읽어올 수 있다.
MyClass obj = new MyClass();
obj.MyField = 3;
Console.WriteLine(obj.MyField);
메소드를 통해 필드가 변경되지 않기를 원할 때는 Set 메소드를 구현하지 않으면 됐다. 프로퍼티를 통해 필드가 변경되지 않았으면 할 때는 어떻게 할까?
set 접근자를 구현하지 않으면 된다. 해당 프로퍼티는 쓰기 불가, 즉 읽기 전용이 된다.
class MyClass
{
private int myField;
public int MyField
{
get // 이렇게 get 접근자만 있으면 읽기 전용 프로퍼티가 된다.
{
return myField;
}
}
}
쓰기 전용 프로퍼티도 만들 수 있을까?
문법적으로 문제가 없지만, 사용하기 전에 신중하게 생각해야 한다. 만약 클래스를 만들고 다른 프로그래머가 사용할 때, 쓰기 전용 프로퍼티의 용도와 동작 결과를 확인할 수 있는 방법을 알려야 한다. 그렇지 않다면 그 프로퍼티는 코드를 관리하기 어렵게 만드는 원인이 될 가능성이 높아진다.
9.3 자동 구현 프로퍼티
프로퍼티는 데이터의 오염에 대해선 메소드처럼 안전하고, 데이터를 다룰 때는 필드처럼 간결하다.
하지만 많은 경우에 중복 코드를 작성하고 있다는 기분이 든다. 여기에는 아무 논리도 섞여 있지 않다.
C# 3.0부터 코드를 더 단순하게 만드는 자동 구현 프로퍼티(Auto Implemented Property)를 도입했다.
public class NameCard
{
private string name;
private string phoneNumber;
public string Name
{
get{return name;}
set{name = value;}
}
public string PhoneNumber
{
get{return phoneNumber;}
set{phoneNumber = value;}
}
}
이 코드에서 Name과 PhoneNumber 프로퍼티는 다음과 같이 자동 구현 프로퍼티로 대체할 수 있다.
필드를 선언할 필요도 없고, 그저 get 접근자와 set 접근자 뒤에 세미콜론(;)만 붙여주면 된다.
public class NameCard
{
public string Name
{
get;set;
}
public string phoneNumber
{
get;set;
}
}
C# 7.0 부터는 다음과 같이 자동 구현 프로퍼티를 선언함과 동시에 초기화를 수행할 수 있다.
덕분에 자동 구현 프로퍼티에 초깃값이 필요할 때 생성자에 초기화 코드를 작성하는 수고를 덜게 되었다.
public class NameCard
{
public string Name{get;set;} = "Unknown";
public string PhoneNumber{get;set;} = "000-0000";
}
다음은 자동 구현 프로퍼티의 예제 프로그램이다.
using System;
namespace PracticeCSharp
{
class BirthdayInfo
{
public string Name { get; set; } = "Unknown";
public DateTime Birthday { get; set; } = new DateTime(1, 1, 1);
public int Age
{
get
{
return new DateTime(DateTime.Now.Subtract(Birthday).Ticks).Year;
}
}
}
class Program
{
static void Main(string[] args)
{
BirthdayInfo birth = new BirthdayInfo();
Console.WriteLine($"Name : {birth.Name}");
Console.WriteLine($"Birthday: {birth.Birthday.ToShortDateString()}");
Console.WriteLine($"Age: {birth.Age}");
birth.Name = "서현";
birth.Birthday = new DateTime(1991, 6, 28);
Console.WriteLine($"Name : {birth.Name}");
Console.WriteLine($"Birthday : {birth.Birthday.ToShortDateString()}");
Console.WriteLine($"Age: {birth.Age}");
}
}
}
1. 자동 구현 프로퍼티 뒤에서 일어나는 일
C# 컴파일러가 보이지 않는 곳에서 자동 구현 프로퍼티를 위해 하는 일은 무엇일까?
비주얼 스튜디오와 같이 제공되는 .NET 디어셈블리 도구인 ildasm.exe로 열어보자.
실행 시키면 창이 뜬다.
알고 싶은 C# 프로젝트 폴더의 bin > Debug > net6.0 에서 (프로젝트).dll 또는 (프로젝트).exe 를 넣어본다.
이전 예제를 열어본 결과다. PracticeSharp.BirthdayInfo 클래스 안에 마름모 아이콘의 필드 <Birthday>k_BackingField와 <Name>k_BackingField 두 개가 보인다. 이 필드는 우리가 선언하지도 않았는데 어디서 나타났을까? C# 컴파일러가 자동으로 구현해준 것이다. Birthday 프로퍼티를 위해 <Birthday>k_BackingField를, Name 프로퍼티를 위해<Name>k_BackingField를 선언해준 것이다.
9.4 프로퍼티와 생성자
객체를 생성할 때 프로퍼티를 이용해 각 필드를 초기화하는 방법이 있다.
클래스_이름 인스턴스 = new 클래스_이름()
{
프로퍼티1 = 값,
프로퍼티2 = 값,
프로퍼티3 = 값
};
이와 같이 객체를 생성할 때, '프로퍼티 = 값' 목록에 객체의 모든 프로퍼티가 올 필요는 없다.
초기화 하고 싶은 프로퍼티만 넣어서 초기화하면 된다. 매개변수가 있는 생성자를 작성할 때와 달리 어떤 필드를 생성자 안에서 초기화할지 미리 고민할 필요가 없다.
BirthdayInfo birth = new BirthdayInfo()
{
Name = "서현",
Birthday = new DateTime(1991, 6, 28)
};
using System;
namespace PracticeCSharp
{
class BirthdayInfo
{
public string Name { get; set; }
public DateTime Birthday { get; set; }
public int Age
{
get
{
return new DateTime(DateTime.Now.Subtract(Birthday).Ticks).Year;
}
}
}
class Program
{
static void Main(string[] args)
{
BirthdayInfo birth = new BirthdayInfo()
{
Name = "서현",
Birthday = new DateTime(1991, 6, 28)
};
Console.WriteLine($"Name : {birth.Name}");
Console.WriteLine($"Birthday : {birth.Birthday.ToShortDateString()}");
Console.WriteLine($"Age : {birth.Age}");
}
}
}
9.5 초기화 전용 자동 구현 프로퍼티
의도치 않게 데이터가 오염되는 일이 종종 있는데 C#에는 데이터 오염을 방지할 수 있는 장치가 여럿 있다.
예를 들어 접근 한정자, readonly 필드, readonly 구조체, 튜플 등이 있다.
프로퍼티를 읽기 전용으로 선언하는 방법이 불편했다. 다음과 같이 생성자를 통해 필드를 초기화하고 그 필드에 접근하는 프로퍼티는 get 접근자만 갖도록 해야 했다.
class Transaction
{
public Transaction(string _from, string _to, int _amount)
{
from = _from; to = _to; amount = _amount;
}
string from;
string to;
int amount;
public string From {get{return from;}}
public string To {get{return to;}}
public int Amount {get{return amount;}}
}
C# 9.0에 이르러서는 읽기 전용 프로퍼티를 간편하게 선언할 수 있도록 개선했다. init 접근자를 새로 도입했다.
init 접근자는 set 접근자처럼 외부에서 프로퍼티를 변경할 수 있지만, 객체 초기화할 때만 프로퍼티 변경이 가능하다는 점이 다르다. init 접근자를 사용하는 방법은 다음과 같다. 자동 구현 프로퍼티를 선언하면서 set 접근자 대신 init 접근자를 명시하면 된다. 이렇게 선언한 프로퍼티를 '초기화 전용(Init Only) 자동 구현' 프로퍼티라고 한다.
public class Transaction
{
public string From {get; init;}
public string To {get; init;}
public int Amount {get; init;}
}
초기화가 한 차례 이루어진 후 변경되면 안 되는 데이터에는 금융 거래 기록 등이 있다.
이를 이용해 객체 초기화가 이루어진 후에 초기화 전용 자동 구현 프로퍼티를 수정하려고 하면 컴파일 에러가 발생한다.
9.6 프로퍼티 초기화를 강제하는 required 키워드
required는 초기화가 필요한 프로퍼티를 실수로 초기화하지 않는 실수를 방지할 수 있게 해준다.
다시 말해, required 한정자는 수식하는 프로퍼티를 객체가 초기화할 때 반드시 초기화되도록 컴파일 수준에서 강제한다.
required 한정자는 다음과 같이 프로퍼티 이름 앞에 사용한다.
class BirthdayInfo
{
// Name과 Birthday를 객체 생성 시에 함께 초기화되도록 강제한다.
public required string Name{get; set;}
public required DateTime Birthday {get; init;} // 이름은 바뀌어도 생일은 바뀌지 않으므로 초기화 전용(init)으로 선언한다.
public int Age
{
get
{
return new DateTime(DateTime.Now.Subtract(Birthday).Ticks).Year;
}
}
}
위 코드에서 BirthdayInfo 클래스의 Name과 Birthday 프로퍼티는 required로 한정했기 때문에 초기화를 누락한 채 생성자를 호출하면 컴파일 에러가 발생한다.
9.7 레코드 타입으로 만드는 불변 객체
불변(Immutable) 객체는 내부 상태(데이터)를 변경할 수 없는 객체를 말한다. 상태를 변경할 수 없다는 특성 때문에 불변 객체에서는 데이터 복사와 비교가 많이 이뤄진다. 새로운 상태를 표현하려고 기존 상태를 복사한 뒤 이 중 일부를 수정해서 새로운 객체를 만들고, 상태를 확인하기 위해 객체 내용을 자주 비교한다.
레코드(Record)는 불변 객체에서 빈번하게 이뤄지는 이 두 가지 연산을 편리하게 수행할 수 있도록 C# 9.0에서 도입된 타입이다.
참조 타입은 클래스의 모든 필드를 readonly로 선언하면 불변 객체를 만들 수 있다.
값 타입은 readonly struct로 구조체를 선언하면 된다.
컴파일러가 모든 필드를 readonly로 선언하도록 강제하기 때문이다.
값 타입 객체는 다른 객체에 할당할 때 깊은 복사를 수행한다. 깊은 복사란 모든 필드를 새 객체가 가진 필드에 1:1로 복사하는 것을 말한다. 배열 요소에 입력하거나 함수 인수로 사용할 때도 늘 깊은 복사를 한다. 필드가 많을수록 복사 비용은 커진다. 객체를 여러 곳에서 사용해야 하는 경우에는 더 커진다.
참조 타입은 이런 오버헤드가 없다. 객체가 참조하는 메모리 주소만 복사하면 되기 때문이다. 참조 타입은 프로그래머가 직접 깊은 복사를 구현해야 한다는 단점이 있다.
값 타입은 객체를 비교할 때 기본적으로 내용을 비교하는데 모든 필드를 1:1로 비교한다. 불변 객체에 필요한 비교 방법이다. 참조 타입끼리 내용을 비교할 수 있으려면 프로그래머가 직접 비교 코드를 작성해야 한다. 보통은 object로부터 상속하는 Equals() 메소드를 오버라이딩 한다.
불변 객체를 참조 타입으로 선언하면 함수 호출 인수나 컬렉션 요소로 사용할 때 복사 비용을 줄일 수 있다. 한편 불변 객체는 새 상태 표현과 상태 확인을 위해 깊은 복사와 내용 비교가 필수적이므로 값 타입으로 선언하는 편이 프로그래머에게 편리한 부분이 많을 것이다. 불변 참조 타입의 비용 효율과 불변 값 타입의 편리함을 모두 얻을 수 있는 방법이 있으면 좋을 것이다.
레코드 타입은 값 타입처럼 다룰 수 있는 불변 참조 타입으로, 참조 타입의 비용 효율과 값 타입이 주는 편리함을 모두 제공한다.
1. 레코드 선언하기
레코드는 다음과 같이 record 키워드와 초기화 전용 자동 구현 프로퍼티를 함께 이용해서 선언한다.
이때 주의할 점이 있다. 레코드에는 초기화 전용 자동 구현 프로퍼티뿐만 아니라 쓰기 가능한 프로퍼티와 필드도 자유롭게 선언해서 넣을 수 있다는 것이다. 지금은 초기화 전용 자동 구현 프로퍼티만 이용해서 상태를 표현해보자.
record RTransaction
{
public string From {get; init;}
public string To {get; init;}
public int Amount {get; init;}
}
이렇게 선언한 레코드로 인스턴스를 만들면 불변 객체가 만들어진다.
RTransaction tr1 = new RTransaction{From="Alice", To="Bob", Amount=100};
RTransaction tr2 = new RTransaction{From="Bob}, To="Charlie", Amount=300};
2. with를 이용한 레코드 복사
C# 컴파일러는 레코드 타입을 위한 복사 생성자를 자동으로 작성한다. 단, 이 복사 생성자는 protected로 선언되기 때문에 명시적으로 호출할 수는 없고, 다음과 같이 with 식을 이용해야 한다.
RTransaction tr1 = new RTransaction {From="Alice", To="Bob", Amount=100};
RTransaction tr2 = tr1 with {To="Charlie"}; // tr1의 모든 상태를 복사한 다음 To 프로퍼티 값만 "Charlie"로 수정
이 코드에서 with 식은 tr1을 복사한 뒤 To 프로퍼티 값만 "Charlie"로 수정해서 tr2라는 새로운 레코드 객체를 생성한다.
with 식이 없었다면, RTransaction 인스턴스를 새로 할당하면서 To를 제외한 tr1의 모든 프로퍼티를 입력해줘야 했을 것이다. with 식은 객체 상태(프로퍼티)가 다양할수록 유용하다.
3. 레코드 객체 비교하기
컴파일러는 레코드의 상태를 비교하는 Equals() 메소드를 자동으로 구현한다.
왼쪽에는 클래스 객체 상태 비교를 위한 Equals() 메소드 오버라이딩 예제가 나타나 있고, 오른쪽에는 평범한 레코드 선언 예제가 있다. 레코드는 참조 타입이지만 값 타입처럼 Equals() 메소드를 구현하지 않아도 비교가 가능하다.
클래스 | 레코드 |
class CTransaction { public string From {get; init;} public string To {get; init;} public int Amount {get; init;} public override bool Equals(Object obj) { CTransaction target = (CTransaction)obj; if(this.From == target.From && this.To == target.To && this.Amount == target.Amount) return true; else return false; } } |
record RTransaction { public string From {get; init;} public string To {get; init;} public int Amount {get; init;} } |
원래 Equals()의 기본 구현은 내용 비교가 아닌 참조를 비교한다.
9.8 무명 타입
C#에는 여러 타입이 있다. 각 타입에는 이름이 있다. int, double, string, FireStream, MyClass 등이 있다.
타입의 이름이 왜 필요할까?
그 타입의 이름을 이용해서 인스턴스를 만들기 때문이다.
int a;
double b;
무명 타입(Anonymous Type)은 타입의 선언과 동시에 인스턴스를 할당한다. 인스턴스를 만들고 다시는 사용하지 않을 때 무명 타입이 요긴하다. (두 개 이상의 인스턴스를 만들려면 class나 struct를 이용해 이름을 가진 타입을 별도로 만들어야 한다.) 무명 타입의 선언 예는 다음과 같다.
// 중괄호 {와 } 사이에 임의의 프로퍼티 이름을 적고 값을 할당하면 그대로 새 타입의 프로퍼티가 된다.
var myInstance = new {Name="엄준식", Age="23"};
이와 같이 선언한 무명 타입의 인스턴스는 여느 객체처럼 프로퍼티에 접근하여 사용할 수 있다.
Console.WriteLine(myInstance.Name, myInstance.Age);
무명 타입에서 주의할 점이 있다. 무명 타입의 프로퍼티에 할당된 값은 변경불가능하다.
무명 타입의 인스턴스가 만들어지고 난 다음에는 읽기만 할 수 있다.
이러한 특성들 때문에 무명 타입이 쓸모가 없는 것 같지만, LINQ와 함께 사용하면 우용하다.
9.9 인터페이스의 프로퍼티
인터페이스는 메소드뿐만 아니라 프로퍼티와 인덱서도 가질 수 있다. 프로퍼티나 인덱서를 가진 인터페이스를 상속하는 클래스가 '반드시' 해당 프로퍼티와 인덱서를 구현해야 하는 것은 물론이다. 인터페이스에 들어가는 프로퍼티는 구현을 갖지 않는다. 여기에는 한 가지 문제가 있는데, 인터페이스의 프로퍼티 선언은 클래스의 자동 구현 프로퍼티 선언과 그 모습이 동일하다는 것이다. 다음은 인터페이스의 프로퍼티 선언 형식이다.
interface 인터페이스_이름
{
public 타입 프로퍼티_이름1
{
get; set;
}
public 타입 프로퍼티_이름2
{
get; set;
}
// ...
}
다음은 프로퍼티를 가진 인터페이스와 이를 상속하는 파생 클래스의 예이다.
interface IProduct
{
string ProductName
{
get;
set;
}
}
class Product : IProduct
{
private string productName;
public string ProductName // 파생 클래스는 인터페이스에 선언된 모든 프로퍼티를 구현해야 한다.
{
get{return productName;}
set{productName = value;}
}
}
using System;
namespace PracticeCSharp
{
interface INameValue
{
// 자동 구현 프로퍼티처럼 구현이 없지만, C# 컴파일러는 인터페이스의 프로퍼티에 대해서는 자동으로 구현해주지 않는다.
// 인터페이스는 어떤 구현도 가지지 않기 때문이다.
string Name
{
get;
set;
}
string Value
{
get;
set;
}
}
class NamedValue : INameValue
{
// INamedValue 인터페이스를 상속하는 NamedValue 클래스는 반드시 Name과 Value를 구현해야 한다.
// 이때 자동 구현 프로퍼티를 이용하는 것도 가능하다.
public string Name
{
get;
set;
}
public string Value
{
get;
set;
}
}
class Program
{
static void Main(string[] args)
{
NamedValue name = new NamedValue{Name = "이름", Value = "엄준식" };
NamedValue height = new NamedValue{ Name = "키", Value = "177Cm" };
NamedValue weight = new NamedValue{ Name = "몸무게", Value = "90Kg" };
Console.WriteLine($"{name.Name} : {name.Value}");
Console.WriteLine($"{height.Name} : {height.Value}");
Console.WriteLine($"{weight.Name} : {weight.Value}");
}
}
}
9.10 추상 클래스의 프로퍼티
추상 클래스는 클래스처럼 구현된 프로퍼티를 가질 수 있는 한편, 인터페이스처럼 구현되지 않은 프로퍼티도 가질 수 있다.
추상 클래스에서는 이것을 추상 프로퍼티(Abstract Property)라고 한다.
추상 메소드가 그랬던 것처럼, 추상 프로퍼티 역시 인터페이스의 프로퍼티와 다를 것이 없다. 파생 클래스가 해당 프로퍼티를 구현하도록 강제하는 것일 뿐이다.
인터페이스처럼 구현을 비워놓은 것만으로는 추상 프로퍼티를 만들 수 없다. C# 컴파일러가 자동 구현 프로퍼티로 간주하고 구현을 자동으로 채워 넣기 때문이다. 그래서 추상 프로퍼티는 다음과 같이 abstract 한정자를 이용해서 선언한다.
abstract class 추상_클래스_이름
{
abstract 데이터_타입 프로퍼티_이름
{
get;
set;
}
}
다음은 추상 프로퍼티를 갖는 추상 클래스와 이를 상속하는 파생 클래스의 예제이다.
abstract class Product
{
private static int serial = 0;
public string SerialID // 추상 클래스는 구현을 가진 프로퍼티와
{
get {return String.Format("{0:d5}", serial++);}
}
abstract public DateTime ProductDate // 구현이 없는 추상 프로퍼티 모두를 가질 수 있다.
{
get;
set;
}
}
class MyProduct : Product
{
// 파생 클래스는 기반 추상 클래스의 모든 추상 메소드뿐 아니라 추상 프로퍼티를 재정의해야 한다.
public override DateTime ProductDate
{
get;
set;
}
}
'공부 > C#' 카테고리의 다른 글
이것이 C#이다 Chapter 11 일반화 프로그래밍 (0) | 2024.03.09 |
---|---|
이것이 C#이다 Chapter 10 배열과 컬렉션 그리고 인덱서 (6) | 2024.03.08 |
이것이 C#이다 Chapter 08 인터페이스와 추상 클래스 (0) | 2024.03.06 |
이것이 C#이다 Chapter 07 클래스 (1) | 2024.02.27 |
이것이 C#이다 Chapter 06 메소드로 코드 간추리기 (1) | 2024.02.26 |