C#에서는 OOP를 통해 자신만의 type을 만들 수 있습니다. 이를 위해 data를 저장하기 위한 field와 동작을 수행하는 method를 포함해 type이 가질 수 있는 member들에 대해 encapsul화와 같은 OOP개념을 사용해 볼 것입니다. 여기에 더해 tuple syntax support, out variables, inferred tuple names 그리고 default literals과 같은 언어기능과 간단한 동작을 수행하기 위한 연산자와 지역함수를 정의하는 방법도 함께 살펴보고자 합니다.
1. OOP (Object-Oriented Programming)
현실 세계의 개체는 자동차나 사람과 같은 것이지만 programming에서의 개체는 제품이나 은행 계좌와 같이 현실 세계의 무언가를 나타내는 경우가 많으며 더 추상적인 것일 수도 있습니다.
C#에서 우리는 개체의 type을 정의하기 위해 class와 record 또는 struct와 같은 C# keyword를 사용합니다. class와 struct의 차이에 관한 것은 추후에 알아볼 테지만 type을 개체의 청사진 또는 template으로 생각할 수 있습니다.
OOP의 개념을 요약하면 대략 아래와 같이 설명할 수 있습니다.
- Capsule화(Encapsulation) : 개체와 관련된 data와 동작의 결합을 말합니다. 예를 들어 '은행계좌'라는 개체는 '잔액'과 '예금주'라는 data를 가질 수 있고 '입금'과 '출금'이라는 동작을 가질 수 있습니다. 캡슐화할 때는 개체의 내부 상태에 대한 접근이나 변경을 외부로부터 제한하는 방식을 통해 동작이나 data로의 접근을 통제할 수 있습니다.
- 합성(composition) : 개체를 무엇으로 구성할지에 관한 것입니다. 예를 들어 자동차라는 개체는 4개의 wheel개체와 몇몇 좌석개체 그리고 engine과 같은 다른 부분으로 구성될 수 있습니다.
- 집합(Aggregation) : 개체와 결합될 수 있는 것을 말합니다. 예를 들어 사람은 자동차의 구성요소가 아니지만 운전석에 자리 잡게 되면 자동차의 driver가 됩니다. 즉, 2개의 분리된 개체가 서로 결합하여 새로운 component로 만들어지게 되는 것입니다.
- 상속(Inheritance) : 기반 혹은 super class로 부터 파생되어 하위 class를 생성함으로써 code를 재사용하는 것을 말합니다. 기반 class의 모든 기능이 상속되어 파생 class에서 사용할 수 있게 되는데 예를 들어 기반 혹은 super Exception class는 모든 예외에 대한 동일한 구현을 가진 몇 가지 member를 가지고 있으며 sub 혹은 파생 SqlException class에서는 이들 member를 상속하여 database연결 속성과 같이 SQL Database 예외가 발생할 때와 관련된 확장된 member를 가지게 됩니다.
- 다형성(Polymorphism) : 파생된 class에서 상속된 동작을 override 하도록 허용하는 것으로서 이를 통해 파생된 class에서는 본래 class와 다른 동작을 제공할 수 있습니다.
2 Class Library 만들기
Class library assembly는 type을 쉽게 배포가능한 단위(DLL file)로 group화 합니다. 단위 test를 배울 때를 제외하곤 지금까지 code를 작성하기 위해 console application만을 생성해 왔습니다. 여러 project에 걸쳐 재사용가능한 code를 만들려면 Microsoft가 하는 것처럼 class library assembly로 만들어야 합니다.
(1) Class library 생성
재사용 가능한 .NET class library를 만들기 위해 우선 csStudy05라는 solution을 생성하고 그 안에 'NetLibraryStandard2'이름의 Class Library project를 추가합니다. NetLibraryStandard2.csproj file을 열어보면 아래와 같이 .NET 8을 target으로 하는 .NET SDK 8에 의해 project가 만들어졌으며 따라서 해당 project는 다른 .NET 8 호환 assembly에만 참조될 수 있음을 알 수 있습니다.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
위 상태에서 아래와 같이 TargetFramework를 'netstandard2.0'로 변경하고 C# 12 compiler를 사용한다는 것을 명시적으로 지정하기 위한 요소와 모든 C# file로 정적 System.Console class를 정적 import하기 위한 요소를 추가합니다.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>12</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Using Include="System.Console" Static="true" />
</ItemGroup>
</Project>
C# 12 compiler를 사용한다고 지정하더라도 .NET platform을 동일하게 맞춰주지 않으면 일부 언어기능을 사용할 수 없는 경우가 있습니다. 예를 들어 C# 8에서 도입된 interface의 기본구현은 .NET Standard 2.1을 필요로 하며 C# 11에서 도입된 required keyword는 .NET 7의 attribute를 필요로 합니다.
이제 csproj file을 저장한뒤 기본적으로 생성한 Class1.cs file로 삭제합니다. 그리고 project를 build 하여 이후에 다른 project에서도 참조가능하도록 설정합니다.
최신의 C#언어와 .NET platform 기능을 사용하기 위해서는 .NET 8 Class Library를 생성해야 합니다. 하지만 .NET Core, .NET Framework, Xamarin과 같은 이전 .NET platform을 지원하려면 .NET Standard 2.0 class library를 생성해야 합니다. 기본적으로 .NET Standard 2.0은 C# 7.0 compiler를 사용하지만 이 설정은 변경할 수 있기 때문에 .NET Standard 2.0 API로 제한한다고 하더라도 최신 SDK와 compiler에 대한 혜택을 여전히 사용할 수 있습니다.
(2) Namespace에 class를 정의하기
이제 필요한 class를 정의할 차례입니다. 'NetLibraryStandard2' project에 Person.cs file을 추가하고 기존 구문을 모두 삭제한 뒤 아래와 같이 CLIEL.Shared라는 Namespace를 설정합니다. 이는 해당 file에서 정의된 type을 명시하는 것입니다.
namespace CLIEL.Shared;
가능한한 논리적으로 명명된 namespace로 class를 생성하는 것이 좋습니다. namespace이름은 예를 들어 숫자와 관련된 경우 System.Numerics와 같이 domain고유의 이름이 될 수 있습니다. 이 경우 예제에서는 Person, BackAccount 그리고 WonderOfTheWorld와 같은 type을 생성할 텐데 이들에 대한 전형적인 이름을 정하지는 않았으므로 CLIEL.Shared로 사용할 것입니다.
그다음 public keyword를 사용한 Person class를 아래와 같이 작성합니다.
namespace CLIEL.Shared;
public class Person
{
}
위 예제의 public C# keyword는 class이전에 와야 합니다. 이 keyword는 접근 한정자로서 해당 class library외부의 다른 모든 code로부터 접근을 허용하기 위한 것입니다.
만약 명시적으로 public keyword를 적용하지 않는다면 해당 class를 정의한 assembly안에서만 접근이 가능하게 됩니다. 아무것도 지정하지 않은 class는 암시적인 접근한정자로 internal을 사용하기 때문입니다. 예제에서 해당 class는 외부 assembly에서도 접근가능하도록 할 것이기 때문에 public으로 class를 정확히 지정해야 합니다.
target이 .NET 6.0 이후로 맞춰져 있고 따라서 C# 10 이후를 사용한다면 code를 간소화하기 위해 semicolon으로 namespace를 선언하고 중괄호를 제거하는 것으로 끝낼 수 있습니다. 따라서 type정의는 들여 쓰기를 하지 않아도 됩니다. 이는 file-scoped namespace선언이라는 것으로 file당 하나의 file-scoped namespace를 가질 수 있습니다.
예제에서는 언어기능을 명시적으로 C#11을 사용할 것이라 csproj file에서 명시하였으므로 가능한 형태이며 만약 그렇게 하지 않은 경우라면 예제는 이전 version의 style로 다음과 같이 만들어야 합니다.
namespace CLIEL.Shared
{
public class Person
{
}
}
(3) 접근 한정자의 이해
상가 예제의 class에는 public keyword를 사용하였으며 해당 keyword가 접근한정자에 해당합니다. public은 외부 class library를 포함해 외부에서 해당 class로 접근이 가능하도록 합니다. 만약 class에 별도의 한정자를 적용하지 않는다면 암시적으로 internal이 적용되며 이는 해당 class가 정의된 같은 assembly안에서만 접근이 가능하도록 합니다. 예제는 외부에서 접근이 가능하도록 만들 것이므로 명시적으로 public을 적용해야 합니다.
class내부의 다른 class처럼 class가 중첩된 경우 내부 class는 private 한정자를 적용하여 상위 class에서 접근하지 못하게 할 수 있습니다. 또한 .NET 7에서는 file한정자가 도입되었는데 이는 code file안에서만 접근이 가능하도록 하는 것으로서 하나의 file에 여러 class를 정의하는 경우 유용하게 사용될 수 있습니다.
file 접근 한정자에 관해서는 아래 글을 참고하시기 바랍니다.
C# reference - C# | Microsoft Learn
가장 흔하게 사용되는 한정자로는 public과 internal이 있습니다. internal은 한정자를 지정하지 않은 경우 기본적으로 적용되기는 하지만 되도록이면 internal이라도 code의 가독성을 위해 명시적으로 internal을 지정해 주는 것이 좋습니다.
(4) Member의 이해
예제의 type은 아직까지는 capsule화된 어떠한 member도 가지고 있지 않습니다. 이제 몇 가지 member를 추가할 텐데 member는 field, method가 될 수 있고 이 둘 모두에 대한 특수한 version이 될 수도 있습니다. member가 될 수 있는 각각의 항목은 아래와 같습니다.
- Field : data를 저장하는데 사용되며 또한 3가지로 분류될 수 있습니다. 그중 첫 번째는 Constant라는 것으로 compiler가 해당 field의 값에 대해 값을 읽는 모든 code에 그대로 복사하여 처리함으로써 field의 값이 바뀔 수 없도록 합니다. 두 번째는 Read-Only인데 이는 class가 instance화 된 후에 값이 바뀔 수 없도록 하는 것입니다. 다만 instance화 되는 시점에 data는 외부로부터 설정될 수 있습니다. 마지막 세 번째는 Event로 하나 또는 그 이상의 method를 참조하는 data를 말합니다. 해당 method는 어떤 사건이 발생할 때 실행되는 것으로서 button의 click을 예로 들 수 있습니다. Event에 관한 자세한 내용은 추후에 알아볼 것입니다.
- Method : 특정 구문을 실행하는데 사용되는 것으로 '함수'부분을 배워볼 때 몇 가지 예제를 만들어본 적 있었습니다. 또한 method는 4가지 정도로 분류될 수 있는데 그중 첫 번째는 Contructor(생성자)라고 하는 것으로 new keyword를 통해 class의 instance를 memory에 할당할 때 실행되는 method입니다. 두 번째 Property는 data를 저장하거나 읽을 때 실행되는 것으로 data는 일반적으로 field에 저장하지만 외부에 저장하거나 계산되어 처리될 수도 있습니다. 일반적으로 속성은 field의 memory 주소를 노출하기보다는 field를 capsule화 하는데 선호되는 방법입니다. 세 번째 Indexer는 'array'구문인 []을 사용해 data를 저장하거나 값을 가져올 때 실행되는 것이며 마지막 네 번째는 type을 대상으로 +나 /와 같은 연산자를 사용할 때 실행되는 것입니다.
(5) Class의 Instance생성
어떤 class를 사용하려면 해당 class에 대한 instance를 생성해야 하는데 만약 class가 정의된 것과 다른 project에서 사용하는 경우라면 해당 project에서 사용하고자 하는 class를 가진 assembly를 참조해야 합니다.
실제 이러한 과정을 거쳐보기 위해 csStudy05 solution에서 PeopleApp이라는 이름의 Console App project를 추가합니다. project가 생성되고 나면 해당 project에서 mouse 오른쪽 button을 눌러 'Add Project Reference...' menu를 선택합니다. 이어서 'NetLibraryStandard2' project를 확인하고 checkbox에 check 한 후 OK button을 눌러 해당 project를 참조추가한 뒤 project를 build 합니다.
(6) Type을 사용하기 위한 namespace importing
이제 Person class를 사용하기 위한 구문을 작성할 것입니다. PeopleApp의 Program.cs에서 기존 구문을 모두 삭제하고 Person class에 대한 namespace를 아래와 같이 추가합니다.
using CLIEL.Shared;
물론 위의 namespace를 전역적으로 import 할 수 있으나 project에서 namespace가 import 되는 Program.cs file이 있으며 사용할 type의 import문이 해당 file의 위에 존재한다면 해당 code를 읽는 누구에게나 명확해질 수 있는 장점도 있습니다.
그리고 Peron type의 instance를 생성하고 해당 instance를 통해 자체적인 설명을 출력하도록 합니다. 아래 예제의 new keyword는 개체를 memory에 할당하며 모든 내부 data를 초기화합니다.
Person kim = new();
Console.WriteLine(kim.ToString());
예제를 보면 ToString이라는 method를 호출하고 있는데 실제 Person class를 보면 아무것도 존재하지 않습니다. 그럼 어떻게 ToString method가 사용될 수 있을까? 이런 것이 가능한 이유는 잠시 후 알아보기로 하고 일단 project를 실행하여 아래와 같은 결과가 표시되는지를 확인합니다.
● using alias를 통한 namespace충돌 방지하기
흔한 경우는 아니지만 같은 이름의 type을 가진 2개의 namespace가 존재할 수도 있는데 이 둘의 namespace가 import 되면 ambiguity를 유발할 수 있습니다. 예를 들어
// Suncheon.Gagokdong.cs
namespace Suncheon
{
public class Gagokdong
{
}
}
// Miryang.Gagokdong.cs
namespace Miryang
{
public class Gagokdong
{
}
}
// Program.cs
using Suncheon;
using Miryang;
Gagokdong g = new();
위 예제의 경우 compiler는 아래와 같이 Error CS0104를 발생시키게 됩니다.
'Gagokdong' is an ambiguous reference between 'Suncheon.Gagokdong' and 'Miryang.Gagokdong' |
이런 경우에는 namespace 중 하나에 alias를 정의함으로써 namespace가 구별될 수 있도록 해야 합니다.
using Suncheon;
using m = Miryang;
Gagokdong g1 = new();
m.Gagokdong g2 = new();
● using alias를 통한 type명 변경
alias가 필요한 또 다른 상황은 type의 이름을 변경하는 경우입니다. 예를 들어 Environment class를 많이 사용하는 경우라면 해당 type을 이름을 간소하게 변경해 아래와 같이 사용할 수 있을 것입니다.
using Env = System.Environment;
WriteLine(Env.OSVersion);
WriteLine(Env.MachineName);
WriteLine(Env.CurrentDirectory);
(7) 개체의 이해
비록 예제의 Person class는 명시적으로 어떤 type으로부터 상속되도록 구현하지 않았지만 모든 type은 직간접적으로 System.Object라는 이름의 특별한 type으로 부터 상속됩니다.
ToString method의 구현은 System.Object안에 이루어진 것이며 전체 namespace와 type명을 출력하게 됩니다.
Person class로 다시 돌아가 아래와 같이 compiler에게 Person은 System.Object type으로 부터 상속됨을 명시적으로 알려줄 수도 있습니다.
public class Person : System.Object
{
}
A class를 B class가 상속받을 때 A는 기반(base) 또는 superclass라고 하며 B는 파생(derived) 또는 subclass라고 합니다. 따라서 이 경우 System.Object는 기반(superclass) class가 되며 Person은 파생(subclass)이 됩니다.
참고로 C#에서 object keyword는 System.Object의 별칭에 해당하므로 아래와 같은 방법으로도 구현할 수 있습니다.
public class Person : object
{
}
● System.Object로 부터의 상속
위에서 처럼 Person class를 명시적으로 object로부터 상속받게 한 뒤 object가 가진 모든 member를 확인해 보도록 하겠습니다. code에서 object keyword를 click 하고 F12 key를 누르거나 object keyword를 mouse 오른쪽 button을 눌러 'Go to Definition'을 선택합니다.
그러면 Microsoft가 정의한 System.Object type과 해당 member들을 다음과 같이 확인해 볼 수 있습니다. 아직까지는 모든 항목에 대한 상속을 이해할 필요가 없지만 여기서 ToString이름의 method가 존재함을 알 수 있습니다.
public class Object
{
//
// Summary:
// Initializes a new instance of the System.Object class.
public Object();
//
// Summary:
// Allows an object to try to free resources and perform other cleanup operations
// before it is reclaimed by garbage collection.
~Object();
//
// Summary:
// Determines whether the specified object instances are considered equal.
//
// Parameters:
// objA:
// The first object to compare.
//
// objB:
// The second object to compare.
//
// Returns:
// true if the objects are considered equal; otherwise, false. If both objA and
// objB are null, the method returns true.
public static bool Equals(Object objA, Object objB);
//
// Summary:
// Determines whether the specified System.Object instances are the same instance.
//
// Parameters:
// objA:
// The first object to compare.
//
// objB:
// The second object to compare.
//
// Returns:
// true if objA is the same instance as objB or if both are null; otherwise, false.
public static bool ReferenceEquals(Object objA, Object objB);
//
// Summary:
// Determines whether the specified object is equal to the current object.
//
// Parameters:
// obj:
// The object to compare with the current object.
//
// Returns:
// true if the specified object is equal to the current object; otherwise, false.
public virtual bool Equals(Object obj);
//
// Summary:
// Serves as the default hash function.
//
// Returns:
// A hash code for the current object.
public virtual int GetHashCode();
//
// Summary:
// Gets the System.Type of the current instance.
//
// Returns:
// The exact runtime type of the current instance.
public Type GetType();
//
// Summary:
// Returns a string that represents the current object.
//
// Returns:
// A string that represents the current object.
public virtual string ToString();
//
// Summary:
// Creates a shallow copy of the current System.Object.
//
// Returns:
// A shallow copy of the current System.Object.
protected Object MemberwiseClone();
}
class에 상속이 지정되지 않으면 System.Object로부터 기본적으로 상속이 이루어집니다.
(8) Class의 기본 생성자 정의하기
C#12부터는 Class의 일부로서 하나의 생성자를 정의할 수 있습니다. 이를 기본생성자(primary constructor)라고 하는데 문법은 record에서의 positional data member와 동일하지만 실제 동작에서는 약간의 차이가 있습니다.
본래 class를 정의할 때 생성자는 class내부에 위치합니다.
public class AnimalClass
{
public AnimalClass(string Name, string Species)
{
}
}
위 예제에서 기본생성자를 적용하게 되면 Class의 정의를 더욱 단순하게 구현할 수 있고
public class AnimalClass(string Name, string Species)
{
}
일반적인 class처럼 매개변수에 필요한 값을 전달하여 instance를 생성할 수 있습니다.
AnimalClass ac = new("Nyao", "Cat");
다만 record와 다르게 class에서는 매개변수에 필요한 속성에 public을 자동으로 구현해 주지 않기 때문에 값이 들어간 속성은 class외부에서 접근할 수 없습니다. 따라서 필요한 속성은 아래와 같이 추가해 줘야 합니다.
public class AnimalClass(string name, string species)
{
public string Name { get; set; } = name;
public string Species { get; set; } = species;
}
AnimalClass ac = new("Nyao", "Cat");
Console.WriteLine(ac.Name);
예제를 실행하면 다음과 같은 결과를 확인할 수 있습니다.
매개변수가 없는 생성자를 추가하는 경우에도 아래와 같은 방법으로 기본값을 기본 생성자로 전달할 수 있습니다.
public class AnimalClass(string name, string species)
{
public string Name { get; set; } = name;
public string Species { get; set; } = species;
public AnimalClass() : this("Mong", "Dog") { }
}
AnimalClass ac = new();
Console.WriteLine(ac.Name);
아래 URL을 참고하면 class와 구조체에 대한 더 많은 기본 생성자정보를 확인할 수 있습니다.
Declare and use primary constructors in classes and structs - C# | Microsoft Learn
3. Field에 data 저장하기
이제 person 관한 정보를 저장하기 위해 class에 몇몇 field를 정의할 것입니다.
(1) Field 정의
사람은 자신의 이름과 생일이라는 날짜를 가지고 있으므로 이 두 개의 값으로 person을 구성할 것입니다. 이때 person class내부에서는 이 둘의 값을 capsule화 하여 외부에서 해당 값이 보일 수 있도록 해야 합니다.
Person class에서 사람의 이름과 생일을 저장하기 위한 2개의 public field를 선언하는 구문을 아래와 같이 추가합니다.
public class Person : object
{
public string? Name;
public DateTime DateOfBirth;
}
예제에서 DateOfBirth field에 대한 data type에는 여러 가지를 선택할 수 있습니다. .NET 6에서는 DateOnly type을 도입했는데 이 것은 시간을 제외하고 날짜만 저장하도록 합니다. 반면 DateTime은 날짜와 시간 모두를 저장할 수 있습니다. 또는 DateTimeOffset을 선택할 수도 있는데 이는 날짜, 시간, time zone까지도 저장합니다. 어느 것을 선택할지는 얼마만큼 상세한 정보의 저장이 필요한가에 달려 있습니다.
C#8부터 compiler는 string과 같은 참조 type이 null값을 가질 수 있는 경우 경고를 표시할 수 있으며 잠재적으로 NullReferenceException예외를 발생시킬 수 있습니다. .NET 6 이후부터 SDK는 이들 경고를 기본적으로 표시하도록 하고 있는데 해당 참조type에 ?의 접미사를 붙여 null을 허용하고 있음을 표시할 수 있고 그러면 경고문구는 나타나지 않을 것입니다. null가능과 이와 같은 경우를 어떻게 처리할 수 있는지에 대한 자세한 사항은 추후에 알아볼 것입니다.
field는 하나의 field에 여러 값을 저장해야 하는 경우 array나 list와 dictionary와 같은 collection을 포함해 어떤 type이든 사용할 수 있습니다. 예제에서는 단지 하나의 이름과 하나의 생일값만을 가집니다.
(2) 접근 한정자(Access modifier)
접근 한정자는 capsule화의 일부로서 member를 어떠한 방식으로 노출할지를 지정하는 것입니다. 예제의 class에서는 명시적으로 field에 public keyword를 적용했는데 만약 이렇게 하지 않았더라면 암시적으로 private이 적용되어 해당 member는 오로지 class의 내부에서만 접근 가능한 형태가 됩니다.
접근한정자는 총 4개의 종류가 있으며 추가로 2개의 접근 한정자 keyword를 결합해 field나 method와 같은 class의 member에 적용할 수 있습니다.
private | type안에서만 접근가능하며 아무런 접근한정자도 적용하지 않으면 private이 기본으로 적용됩니다. |
internal | type내부와 같은 assembly안의 모든 type에서 접근할 수 있습니다. |
protected | type내부와 해당 type을 상속한 type에서만 접근할 수 있습니다. |
public | 외부 어디서든 접근할 수 있습니다. |
internal protected | type내부와 같은 assembly안의 모든 type 그리고 해당 type을 상속한 모든 type에서 접근할 수 있습니다. 'internal or protected'형태의 접근한정자와 동일합니다. |
private protected | type내부와 type을 상속한 그리고 같은 assembly의 모든 type에서 접근할 수 있습니다. 'internal and protected'형태의 접근한정자와 동일하며 해당 한정자는 C# 7.2이후에서만 사용할 수 있습니다. |
member에 암시적으로 적용되는 접근 한정자를 사용한다고 하더라도 이를 생략하지 말고 모든 member에 접근한정자 중 하나를 명시적으로 지정하는 것이 좋습니다. 일반적으로 field는 private 혹은 protected를 사용하며 field의 값을 설정하고 가져오기 위해 public속성을 생성하여 접근을 제한하는 데 사용합니다.
(3) Field에 값 설정하고 가져오기
Program.cs에서 kim을 instance화 한 이후에는 이름과 생일을 설정할 수 있는 구문을 추가할 수 있고 설정된 값을 아래와 같이 불러올 수 있습니다.
kim.Name = "kimya";
kim.DateOfBirth = new DateTime(1980, 12, 01);
Console.WriteLine($"{kim.Name}은 {kim.DateOfBirth}에 태어남.");
값을 확인하는데 보간문자열을 사용하고 있으며 실제 예제를 실행하면 다음과 같은 결과를 확인할 수 있습니다.
참고로 위 결과는 해당 PC의 지역화와 문화권설정에 따라 다르게 보일 수 있습니다.
여기서 DateOfBirth는 몇 가지 형식화된 값을 가져올 수 있는데 dddd로 요일의 이름을, d로는 날짜를 표현할 수 있습니다. MMMM은 월의 이름을 나타내며 소문자 m으로는 시간에서 분을 의미합니다. yyyy는 전체 연도를 표시하며 yy는 2 자릿수 연도를 표시합니다.
또한 C# 3.0부터 도입된 중괄호를 사용한 '개체 초기자' 구문을 사용해 field를 초기화할 수 있습니다.
기존 구문아래에 'hong'이름의 새로운 person개체를 생성하기 위한 구문을 추가합니다. 해당 예제에서 값을 가져올 때는 DateOfBirth속성에 특정 형식화를 적용하였습니다.
Person hong = new() {
Name = "honggd",
DateOfBirth = new (1982, 09, 29)
};
Console.WriteLine($"{kim.Name}은 {kim.DateOfBirth: yyyy-MM-dd}에 태어남.");
예제를 실행하면 결과는 다음과 같을 것입니다.
(4) Enum type을 사용한 값 저장
때로는 값이 option의 제한된 설정중 하나여야 하는 경우가 있습니다. 예를 들어 세계 7대 불가사의 중 좋아하는 한 가지를 선택하는 것처럼 다수 중에 하나의 값을 설정하거나 또는 이와 관련해 가보고 싶은 곳에 대한 bucket list처럼 여러 값을 설정하는 것입니다. 이러한 형태의 data를 저장하기 위해서 우리는 enum type을 정의할 수 있습니다.
Enum type은 내부적으로 문자열설명의 순람표(lookup table)와 함께 정수값을 사용함으로써 하나 또는 그 이상의 선택적 data를 저장하기에 효율적인 방법을 제공합니다.
NetLibraryStandard2 project에 WondersOfTheAncientWorld.cs이름의 file을 아래와 같이 추가합니다.
namespace NetLibraryStandard2
{
public enum WondersOfTheAncientWorld
{
GreatPyramidOfGiza,
HangingGardensOfBabylon,
StatueOfZeusAtOlympia,
TempleOfArtemisAtEphesus,
MausoleumAtHalicarnassus,
ColossusOfRhodes,
LighthouseOfAlexandria
}
}
이어서 Person.cs에서는 아래와 같은 field를 추가하고
public WondersOfTheAncientWorld FavoriteAncientWonder;
Program.cs에서 해당 field로 값을 설정하고 확인합니다.
Person hong = new()
{
Name = "honggd",
DateOfBirth = new(1982, 09, 29)
};
hong.FavoriteAncientWonder = NetLibraryStandard2.WondersOfTheAncientWorld.StatueOfZeusAtOlympia;
Console.WriteLine($"{hong.Name} : {hong.FavoriteAncientWonder} / {(int)hong.FavoriteAncientWonder}")
해당 project를 실행하면 다음과 같은 결과를 표시할 것입니다.
enum값은 내부적으로 효율성을 위해 int값을 사용해 저장합니다. 이때 int값은 자동적으로 0부터 할당되므로 예제의 enum에서 세 번째 불가사의항목은 2 값을 가지게 됩니다. 물론 필요에 따라서는 이 순서를 따르지 않은 다른 값을 저장하는 것도 가능합니다. 다만 값과 일치되는 것을 찾을 수 없는 경우에는 이름대신 int값을 출력하게 됩니다.
(5) Enum type을 사용한 다수값 저장하기
위에서 예로든 bucket list의 경우에는 enum에 대한 array나 collection을 생성할 수도 있을 것입니다. 하지만 더 좋은 방법이 있는데 enum flag를 사용한 단일값으로 여러 선택값을 결합하는 것입니다.
예제의 enum에 아래와 같이 [Flags] attribute를 적용하고 명시적으로 각 항목에 byte값이 사용되도록 한 뒤 실제 bit column마다 다른 값을 표현하여 값을 설정합니다.
[Flags]
public enum WondersOfTheAncientWorld : byte
{
None = 0b_0000_0000, //0
GreatPyramidOfGiza = 0b_0000_0001, //1
HangingGardensOfBabylon = 0b_0000_0010, //2
StatueOfZeusAtOlympia = 0b_0000_0100, //4
TempleOfArtemisAtEphesus = 0b_0000_1000, //8
MausoleumAtHalicarnassus = 0b_0001_0000, //16
ColossusOfRhodes = 0b_0010_0000, //32
LighthouseOfAlexandria = 0b_0100_0000 //64
}
예제에서는 명시적으로 memory에 저장된 bit를 확인할 때 겹치지 않는 각 항목에 값을 할당하였습니다. 또한 enum type에 는 System.Flags attribute를 적용했기 때문에 값이 반환될 때 int값 대신 자동적으로 comma로 분리된 다수의 값과 일치하는 문자열을 반환할 수 있습니다.
일반적으로 enum type은 내부적으로 int 변수를 사용하지만 그리 큰 값은 필요하지 않기 때문에 byte를 사용하면 4byte가 아닌 1byte만 있으면 되므로 75% 정도의 필요한 memory를 절약할 수 있습니다. 또 다른 예로 요일을 위한 enum을 정의할 때도 단지 7개 정도의 항목이 있으면 되기 때문에 byte의 사용이 유리할 수 있습니다.
위 예제를 통해 만약 Hanging Gardens of Babylon과 Mausoleum at Halicarnassus를 포함하는 bucket list를 표현하고자 한다면 16과 2에만 1bit의 설정을 주면 됩니다. 다시 말해 18 값을 저장하는 것입니다.
64 | 32 | 16 | 8 | 4 | 2 | 1 |
0 | 0 | 1 | 0 | 0 | 1 | 0 |
Person.cs에서는 아래와 같이 BucketList field를 추가하고
public WondersOfTheAncientWorld FavoriteAncientWonder;
public WondersOfTheAncientWorld BucketList;
Program.cs에서는 |(OR bit 논리) 연산자를 사용해 enum값을 결합하여 bucket list를 설정하는 구문을 추가합니다. 혹은 enum type으로 형변환하는 숫자 18의 값을 사용해 저장할 수도 있습니다. 다만 code를 읽기 어렵게 만들 수 있기 때문에 숫자를 직접적으로 사용하는 것은 권하지 않습니다.
hong.BucketList = WondersOfTheAncientWorld.HangingGardensOfBabylon | WondersOfTheAncientWorld.MausoleumAtHalicarnassus;
//혹은 이렇게도 가능 hong.BucketList = (WondersOfTheAncientWorld)18;
Console.WriteLine($"{hong.Name}의 bucket list {hong.BucketList}");
예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
분리된 option의 결합을 저장할 때는 enum값을 사용합니다. 이때 option이 최대 8개라면 byte를, 16개라면 ushort를, 32개라면 uint를, 64 이상이라면 ulong을 사용합니다.
(6) Collection을 사용한 다수의 값 저장
만약 person에 대한 자녀를 저장해야 한다면 이를 위한 field도 추가해야 하며 이를 위해 어떠한 type이든 정렬된 collection을 저장할 수 있는 generic List<T> collection type을 사용할 것입니다. 이것은 자녀가 현재 person과 관련이 있지만 person자체에 대한 일부가 아닌 class의 instance이므로 class에 집계를 적용하는 하나의 예가 될 수 있습니다. 참고로 collection에 관해서는 추후에 자세히 다룰 것입니다.
Person.cs에서 person의 자녀를 나타내는 여러 Person intance를 저장할 field를 아래와 같이 선언합니다.
public WondersOfTheAncientWorld BucketList;
public List<Person> Children = new();
List<Person>은 Person에 대한 List입니다. 따라서 Children이름의 속성 type은 Person intance의 list에 해당하는 것입니다.
또한 collection은 item이 추가되기 전에 새로운 intance로 초기화되어야 합니다. 그렇지 않으면 field는 null이 되고 Add와 같은 member의 사용을 시도하게 되면 runtime 예외를 발생시키게 됩니다.
(7) generic(일반화) collection
List<T> type에서 <>는 C#에서 2005년 C# 2.0에서 도입된 일반화라 불리는 기능으로서 collection을 강력한 type으로 만들 수 있습니다. 이로 인해 compiler는 collection에 저장될 수 있는 개체의 type을 특정할 수 있고 따라서 code의 성능과 정확성을 향상됩니다.
강력한 type은 정적인 type과는 다른 의미를 가집니다. 예전 System.Collection type은 정적 type으로서 System.Object를 다루기 위한 것이었습니다. 그러나 새로운 System.Collection.Generic type은 강력한 type의 <T> instance를 다루기 위한 정적 type이라고 할 수 있습니다. (irony 하게도 generic이라는 용어는 좀 더 구체적인 정적 type을 사용할 수 있다는 것을 의미합니다.)
Program.cs에서 hong에 대한 2명의 자녀를 추가하고 얼마나 많은 자녀를 가지고 있는지, 그리고 그 자녀에 대한 이름이 무엇인지를 표시하는 구문을 작성합니다.
hong.Children.Add(new Person { Name = "kim" }); // C# 3.0 부터
hong.Children.Add(new() { Name = "lee" }); // C# 9.0 부터
Console.WriteLine($"{hong.Name}에게는 {hong.Children.Count} 명의 자녀가 있음 => ");
for (int i = 0; i < hong.Children.Count; i++)
{
Console.WriteLine($" {hong.Children[i].Name}");
}
참고로 예제는 for문을 사용했으나 collection을 열거하기 위한 것으로 foreach문의 사용도 가능합니다.
위 예제를 실행하면 다음과 같은 결과를 표시합니다.
(8) 정적 field
예제를 통해 지금까지 만들어온 field는 모두 intance member이며 생성된 class의 각 instance에 대한 각각의 field에 다른 값이 존재합니다. 예제에서도 kim과 hong Person은 서로 다른 Name값을 가지고 있습니다.
하지만 때로는 모든 instance에 걸쳐 공유되는 단 하나의 값을 가지는 field가 필요할 때도 있습니다.
이는 field만이 정적 member가 될 수 있는 것은 아니기에 정적 member라고 하는데 static filed를 통해 이를 구현할 수 있습니다.
NetLibraryStandard2 project에 BankAccount.cs이름의 새로운 class file을 생성하고 해당 class에 3개의 field를 추가합니다. 이중 2개는 intance field이며 다른 하나는 static field입니다.
public class BankAccount
{
public string AccountName;
public decimal Balance;
public static decimal InterestRate;
}
BankAccount의 각 intance는 자체적인 AccountName과 Balance값을 가지지만 이들 모든 instnace는 하나의 InterestRate값을 공유할 것입니다.
Program.cs에서는 InterestRate에 값을 설정하고 2개의 BankAccount에 대한 intance를 생성하는 구문을 추가합니다.
BankAccount.InterestRate = 0.012M;
BankAccount kbAccount = new();
kbAccount.AccountName = "kb";
kbAccount.Balance = 2400;
Console.WriteLine(format: "{0} earned {1:C} interest.", arg0: kbAccount.AccountName, arg1: kbAccount.Balance * BankAccount.InterestRate);
BankAccount hcAccount = new();
hcAccount.AccountName = "hc";
hcAccount.Balance = 98;
Console.WriteLine(format: "{0} earned {1:C} interest.", arg0: hcAccount.AccountName, arg1: hcAccount.Balance * BankAccount.InterestRate);
위 예제를 실행하면 다음과 같은 결과를 확인할 수 있습니다.
예제에서 1:C는 형식화 code로서 .NET에게 number를 통화형식으로 사용할 것을 알려주게 됩니다. 사용되는 통화형식은 설치된 운영체제의 설정을 따릅니다.
다만 현재 thread의 속성을 설정함으로써 통화기호나 다른 data형식을 결정하는 문화권을 조정할 수 있습니다. 예를 들어 영국으로의 문화권을 설정하고자 한다면 Program.cs상단에 아래와 같이 code를 작성할 수 있습니다.
Thread.CurrentThread.CurrentCulture = System.Globalization.CultureInfo.GetCultureInfo("en-GB");
앞서 언급했듯이 field만이 정적 member가 될 수 있는 것은 아닙니다. Constructor(생성자), method, property 등도 정적 member가 될 수 있습니다.
(9) constant field
field의 값이 절대 바뀔 수 없는 경우라면 const keyword를 사용해 compile time에 실제값을 할당할 수 있습니다.
Person.cs에서 Person에 대한 인종을 설정하는 string constant를 아래와 같이 추가합니다.
public List<Person> Children = new();
public const string Species = "Homo Sapiens";
constant field의 값을 가져오려면 class의 intance이름이 아닌 class자체의 이름을 사용해야 합니다. Program.cs에서 아래와 같이 hong의 이름과 함께 인종을 표시하는 구문을 추가합니다.
Console.WriteLine($"{hong.Name} is a {Person.Species}");
예제를 실행하면 다음과 같은 결과를 표시합니다.
Microsoft type에 대한 const field의 예로 System.Int32.MaxValue나 System.Math.PI 등을 들 수 있습니다. 이들의 값은 절대 바뀔 수 없습니다.
const사용 시에는 2가지 사항에 대해 주의해야 합니다. 우선 값은 반드시 선언과 동시에 할당되어야 하며 string이나 bool 또는 숫자같이 실제 표현가능한 값이어야 합니다. const field에 대한 모든 참조는 compile time에 실제값으로 바뀌게 되므로 향후 version에서 값이 바뀌게 될 때 새로운 값을 가져오기 위해 참조하는 모든 assembly를 recompile 하지 않으면 값은 반영되지 않습니다.
(10) 읽기 전용 field
값이 바뀔 수 없는 field에 대한 또 다른 방법으로 read-only field가 있습니다.
Person.cs에서 readonly field를 선언하고 거주 중인 행성의 이름을 저장합니다.
public readonly string HomePlanet = "Earth";
Program.cs에서는 hong의 이름과 함께 위에서 저장한 행성의 이름을 표시하는 구문을 추가합니다.
Console.WriteLine($"{hong.Name} is a {hong.HomePlanet}");
예제를 실행한 결과는 다음과 같습니다.
readonly field는 runtime에서 값을 읽어 들일 수 있으며 실행가능한 모든 구문에서 표현될 수 있습니다. 따라서 값의 설정을 위해 생성자를 사용하거나 field에 값을 직접 할당할 수 있습니다. readonly field에 대한 모든 참조는 live 참조로서 관련된 모든 기능은 호출 code에서 정확하게 반영될 것입니다. 이것이 const와 readonly와의 가장 중요한 차입니다.
경우에 따라서는 static readonly field를 선언할 수 있습니다. 이렇게 되면 type에 대한 모든 intance에 걸쳐 값이 해당 값을 공유할 수 있습니다.
(11) 생성자를 통한 field의 초기화
때로 field는 runtime에서 초기화가 필요한 경우도 있습니다. 이러한 동작은 class로 new keyword를 사용해 intance를 생성할 때 호출되는 생성자를 통해 구현할 수 있습니다. 생성자자는 모든 field가 type을 사용하는 code에 의해 설정되기 전에 실행됩니다.
Person.cs에서 읽기 전용 field인 HomePlanet다음에 새로운 읽기전용 field를 정의하고 생성자에서 Name과 Instantiated field를 설정하는 구문을 아래와 같이 추가합니다.
public readonly string HomePlanet = "Earth";
public readonly DateTime Instantiated;
public Person()
{
Name = "Unknown";
Instantiated = DateTime.Now;
}
그다음 Program.cs에서 새로운 person에 대한 intance를 생성하고 초기 field값을 표시하도록 합니다.
Person blankPerson = new();
Console.WriteLine(format: "{0} of {1} was created at {2:hh:mm:ss} on a {2:dddd}.", arg0: blankPerson.Name, arg1: blankPerson.HomePlanet, arg2: blankPerson.Instantiated);
예제를 실행하면 결과를 다음과 같을 것입니다.
(12) intance 생성 시 속성에 설정 강제하기
C# 11에서는 required 한정자를 도입하였는데 이를 속성이나 field에 사용한다면 compiler는 해당 속성이나 field의 값을 instance생성 시 설정하도록 요구하게 됩니다. 참고로 C# 11이므로 .NET 7 이상을 필요로 합니다.
csStudy05 solution에서 MyLibrary이름의 class library project를 .NET 8에(.NET 7부터 지원함) 맞춰 생성합니다. 그리고 해당 project에 Book.cs이름의 file을 추가하고 여기에 4개의 속성을 아래와 같이 추가합니다. 이때 아래 속성 중 2개는 required를 적용합니다.
namespace CLIEL.Shared
{
public class Book
{
public required string? Isbn { get; set; }
public required string? Title { get; set; }
public string? Author { get; set; }
public int PageCount { get; set; }
}
}
예제에서 3개의 string 속성은 nullable입니다. 속성이나 field에 required를 적용하는 것이 null이 될 수 없다는 것을 뜻하는 것이 아닙니다. 단지 속성이나 field에 할당처리가 되어야 한다는 것을 말하는 것이기 때문에 명시적으로 null을 설정할 수도 있습니다.
Program.cs에서 Book에 대한 intance를 Isbn과 Title속성을 설정하지 않고 생성하려고 하면 다음과 같이 오류를 발생시키게 됩니다.
따라서 개체 초기화 구문을 통해 아래와 같이 2개의 required속성을 설정하도록 하면 정상적으로 instance가 생성될 것입니다.
Book book = new()
{
Isbn = "123-4567890000",
Title = "The Little Prince"
};
Console.WriteLine("{0}: {1} written by {2} has {3:N0} pages.", book.Isbn, book.Title, book.Author, book.PageCount);
다시 MyLibrary project의 Book.cs로 돌아와 이번에는 생성자 한쌍을 정의하는 구문을 추가하는데, 이때 아래와 같이 하나는 초기화 구문을 지원하도록 하고 2개의 required속성을 설정하도록 합니다. 이때 해당 생성자에는 [SetsRequiredMembers] attribute를 적용하여 compiler에게 생성자를 통해 required 속성이 설정되고 있음을 명시적으로 알려줘야 합니다.
public Book() { }
[SetsRequiredMembers]
public Book(string? isbn, string? title)
{
Isbn = isbn;
Title = title;
}
이를 통해 Program.cs에서는 이전에 Book의 초기화 문대신 아래와 같은 생성자를 통해 어떠한 required속성도 설정할 필요 없이 instance를 생성할 수 있게 됩니다.
Book book = new(isbn: "123-4567890000", title: "The Little Prince")
{
Author = "Antoine de Saint-Exupéry",
PageCount = 100
};
예제를 실행하면 아래와 같은 결과를 표시합니다.
(13) 생성자를 통해 field초기화 하기
어떤 경우는 field가 runtime에 초기화(값의 설정)가 되어야 할 때가 있는데 이는 new keyword를 통해 class를 intance화 할때 호출되는 생성자로 실현할 수 있습니다
Person.cs에서 readonly HomePlanet다음에 두 번째 readonly field인 Instantiated를 정의하고 Name과 Instantiated field에 값을 설정합니다.
public readonly DateTime Instantiated;
public Person()
{
Name = "Unknown";
Instantiated = DateTime.Now;
}
그런 다음 새로운 Person의 intance를 생성하고 초기화된 field의 값을 표시하도록 합니다.
Person blankPerson = new();
WriteLine("Name : {Name}, Instantiated : {Instantiated}");
예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
(14) 다중 생성자 정의하기
필요하다면 하나의 type에 여러 생성자를 가질 수 있습니다. 이 것은 특히 개발자가 field에 대한 초기값을 설정하도록 권장하는데 유용하게 사용될 수 있습니다.
Person.cs에서 개발자가 Person의 Name과 HomePlanet에 초기값을 설정할 수 있는 두 번째 생성자를 정의합니다.
public Person(string initialName, string homePlanet)
{
Name = initialName;
HomePlanet = homePlanet;
Instantiated = DateTime.Now;
}
Program.cs에서는 또 다른 Person의 Intance를 생성하는데 이때 두 개의 매개변수를 가진 두 번째 생성자를 사용하도록 합니다.
Person gunny = new(initialName: "Matthew Paige Damon", homePlanet: "Mars");
Console.WriteLine(format: "{0} of {1} was created at {2:hh:mm:ss} on a {2:dddd}.", arg0: gunny.Name, arg1: gunny.HomePlanet, arg2: gunny.Instantiated);
예제의 실행결과는 다음과 같습니다.
생성자는 다소 특별한 method로 분류될 수 있는데 method와 관련해서는 이어서 좀 더 자세히 알아보도록 하겠습니다.
3. Method의 호출과 작성
Method는 여러 구문을 실행하는 type의 member라고 할 수 있습니다. 즉 type에 속하는 함수입니다.
(1) 값을 반환하는 method
Method는 단일 값을 반환하거나 아무것도 반환하지 않을 수 있습니다. 어떤 동작을 수행하지만 값을 반환하지 않는 경우라면 method의 이름 앞에 void type을 사용해 표시하고 그렇지 않고 값을 반환한다면 method의 이름 앞에 반환하는 값의 type을 사용해 표시합니다.
예를 들어 아래 예제를 위해 2개의 method를 생성할 예정인데 각각의 method는 다음과 같습니다.
- WriteToConsole : console에 text를 출력하는 method로서 동작만 수행할 뿐 아무런 값도 반환하지 않을 것이므로 해당 method에는 void keyword를 적용합니다.
- GetOrigin : 해당 method는 text값을 반환하므로 method에 string keyword를 적용합니다.
Person.cs에서 위 2개의 method를 정의하는 구문을 아래와 같이 추가합니다.
public void WriteToConsole()
{
WriteLine($"{Name} was born on a {DateOfBirth:dddd}.");
}
public string GetOrigin()
{
return $"{Name} was born on {HomePlanet}.";
}
그리고 Program.cs에서는 위 method를 호출하는 구문을 아래와 같이 작성합니다.
Person hong = new()
{
Name = "honggd",
DateOfBirth = new(1982, 09, 29)
};
hong.WriteToConsole();
Console.WriteLine(hong.GetOrigin());
예제를 실행하면 다음과 같은 결과를 표시합니다.
(2) 매개변수를 정의하고 Method로 전달하기
method는 동작하는데 필요한 값을 얻기 위해 전달가능한 매개변수를 가질 수 있습니다. 매개변수는 변수의 선언과 비슷하게 정의될 수 있지만 이전 예제에서의 생성자와 같이 method의 괄호 안에 들어간다는 차이가 있습니다.
Person.cs에서 2개의 method를 정의하는데 첫 번째 method는 매개변수를 가지지 않고 두 번째 method는 하나의 매개변수를 가집니다.
public string SayHello()
{
return $"{Name} says 'Hello!'";
}
public string SayHelloTo(string name)
{
return $"{Name} says 'Hello, {name}!'";
}
Program.cs에서는 위 2개의 method를 호출하고 console에 반환값을 출력하는 구문을 아래와 같이 추가합니다.
Console.WriteLine(hong.SayHello());
Console.WriteLine(hong.SayHelloTo("kim"));
예제를 실행한 결과는 다음과 같습니다.
method를 호출하는 구문을 typing 할 때 IntelliSense는 tootip으로 이름과 함께 매개변수의 type, 그리고 method의 반환 type을 표시할 것입니다.
(3) Method overload
이름이 다른 2개의 method를 정의하는 대신 우리는 하나의 method에 같은 이름을 부여할 수 있습니다. 이 것은 method가 각각 다른 매개변수를 가지게 함으로써 가능합니다. 그리고 이미 이에 대한 예로서 위에서 생성자를 선언하면서 확인해 본 바가 있습니다.
Person.cs에서 SayHelloTo method의 이름을 SayHello로 변경합니다. 그리고 Program.cs에서도 역시 method의 호출 부분을 SayHello로 변경합니다. 이때 tootip을 통해서 해당 method가 1 of 2와 2 of 2를 통해 추가적인 overload가 있음을 다음과 같이 표시하게 됩니다.
overload 된 method를 사용하면 class의 method가 적은 것처럼 나타낼 수 있고 class자체를 더욱 간소화하게 만들 수 있습니다.
(4) 선택적 그리고 명명된 매개변수 전달
Method를 간소화하기 위한 또 다른 방법으로는 매개변수를 선택적으로 만드는 것입니다. 이는 method내부의 매개변수에 기본값을 할당함으로써 가능합니다. 이때 선택적 매개변수는 반드시 매개변수의 마지막에 와야 합니다.
Person.cs에서 아래와 같이 3개의 선택적 매개변수를 갖는 method를 정의합니다.
public string OptionalParameters(string command = "Run!", double number = 0.0, bool active = true)
{
return string.Format(format: "command is {0}, number is {1}, active is {2}", arg0: command, arg1: number, arg2: active);
}
그리고 Program.cs에서는 method를 호출하고 console에 결과를 표시하도록 하는 구문을 아래와 같이 추가합니다.
Console.WriteLine(hong.OptionalParameters());
이때도 역시 IntelliSense는 tooltip을 통해 기본값을 가진 3개의 선택적 매개변수를 다음과 같이 표시할 것입니다.
예제를 실행한 결과는 다음과 같습니다.
이번에는 Program.cs에서 command 매개변수에 string을 number 매개변수에 double값을 아래와 같이 전달합니다.
Console.WriteLine(hong.OptionalParameters("Jump!", 95.5));
다시 예제를 실행하면 다음의 결과를 표시하게 됩니다.
command의 기본값과 number 매개변수의 기본값은 전달한 값으로 바뀌었으나 active의 기본값은 여전히 true를 갖고 있습니다.
● Method 호출 시 매개변수 명명하기
명명매개변수는 선언한 순서에 따라 값이 다른 순서로 전달될 수 있도록 하기 때문에 method를 호출할 때 종종 선택적 매개변수와 결합하여 사용하기도 합니다.
Program.cs에서 command 매개변수에 string값을, number 매개변수에 double값을 전달하도록 합니다. 이때, 명명된 매개변수를 사용하는데 순서를 이전과 다르게 지정합니다.
Console.WriteLine(hong.OptionalParameters(number: 52.7, command: "Hide!"));
예제를 실행하면 결과는 다음과 같습니다.
이러한 명명된 매개변수를 사용하면 일부 선택적 매개변수를 생략하는 데에도 사용할 수 있습니다. Program.cs에서 선택적 순서를 통해 command 매개변수로 string값을 전달하고 number 매개변수는 생략합니다. 또한 action 매개변수는 명명된 매개변수를 사용하도록 합니다.
Console.WriteLine(hong.OptionalParameters("Pooling!", active: false));
예제를 실행한 결과는 다음과 같습니다.
(5) 매개변수 전달 방식 제어하기
매개변수가 method로 전달될 때는 아래 3가지 중 하나의 방식이 사용될 수 있습니다.
- 값 (기본방식) : 입력전용으로 처리됩니다.
- out 매개변수 : 출력전용으로 처리되며 매개변수 선언과 함께 기본값을 지정할 수 없고 초기화되지 않은 채로 남아있을 수 없습니다. 따라서 반드시 method내부에서 값이 설정되어야 합니다. 그렇지 않으면 compiler는 error를 발생시키게 될 것입니다.
- ref 매개변수 : 입력과 출력으로 동시에 사용될 수 있습니다. out 매개변수와 같이 ref 매개변수 또한 기본값을 가질 수 없으며 이미 method외부에서 값이 설정되어 있기 때문에 method내부에서 반드시 값을 설정하는 처리를 필요로 하지 않습니다.
- in 매개변수 : 매개변수의 값이 변경될 수 없으며 그러한 행위가 시도되면 compiler는 error를 발생시키게 됩니다.
입력과 출력으로 매개변수를 전달하는 예제를 만들어 보기 위해 Person.cs에서 4개의 매개변수를 가진 method를 정의합니다. 이 중 하나는 값매개변수이고 다른 하나는 in, ref, 마지막 하나는 out 매개변수입니다.
static void PassingParameters(int w, in int x, ref int y, out int z)
{
z = 100;
y++;
//x++; x는 in입니다. 값을 변경하려 한다면 error가 발생합니다.
w++;
Console.WriteLine($"In the PassingParameters: w={w}, x={x}, y={y}, z={z}");
}
Program.cs에서는 3개의 int변수를 선언하고 이들을 method로 전달합니다.
int a = 10;
int b = 20;
int c = 30;
int d = 40;
Console.WriteLine($"Before: a = {a}, b = {b}, c = {c}, d = {d}");
PassingParameters(a, b, ref c, out d);
Console.WriteLine($"After: a = {a}, b = {b}, c = {c}, d = {d}");
예제를 실행하고 다음과 같은 결과를 확인합니다.
Before: a = 10, b = 20, c = 30, d = 40 In the PassingParameters: w=11, x=20, y=31, z=100 After: a = 10, b = 20, c = 31, d = 100 |
기본적으로 매개변수로 변수를 전달할 때는 변수 자체가 아닌 전달된 현재 값을 가져오게 됩니다. 따라서 w는 변숫값의 복사본을 가지게 되고 변수는 본래값 10을 계속 유지할 수 있습니다.
in 매개변수가 사용되는 경우에는 해당 변수에 대한 참조가 method로 전달됩니다. 그러므로 x는 b를 참조하게 됩니다. x는 PassingParameters method안에서 변경될 수 없지만 method밖에서 b의 값은 바뀔 수 있고 그러면 PassingParameters method에도 바뀐 값이 적용됩니다.
ref 매개변수로 변수를 전달하는 경우에는 method로 전달된 변수의 참조를 가져오게 됩니다. 따라서 y는 c에 대한 참조가 됩니다. c변수가 증가된 값을 가지게 되면 y 매개변수 역시 증가된 값을 가지게 됩니다.
out 매개변수로 변수를 전달하는 경우에는 method로 전달된 변수의 참조를 가져오게 됩니다. 따라서 z는 d에 대한 참조가 되고 d변숫값은 method안에서 실행되는 code에 의해 바뀌게 됩니다. 예제에서는 Before로의 출력을 위해 d변수에 값을 할당했지만 사실 이 부분만 아니면 변수 d에 값을 할당하지 않아도 됩니다. 해당 변수는 어쨌거나 다른 값으로 바뀌기 때문입니다.
● out 매개변수 간소화
C# 7부터는 out 매개변수를 사용하는 code를 더욱 간소화할 수 있게 되었습니다.
Program.cs에서 변수를 더 선언하고 f이름의 inline선언 out 매개변수를 추가합니다. f는 본래 존재하지 않던 변수지만 out 매개변수를 통해 method에서 처리되면서 해당 변수를 사용할 수 있게 됩니다.
int a = 10;
int b = 20;
int c = 30;
Console.WriteLine($"Before: a = {a}, b = {b}, c = {c}");
PassingParameters(a, b, ref c, out int d);
Console.WriteLine($"After: a = {a}, b = {b}, c = {c}, d = {d}");
예제를 실행한 결과는 다음과 같습니다.
Before: a = 10, b = 20, c = 30 In the PassingParameters: w=11, x=20, y=31, z=100 After: a = 10, b = 20, c = 31, d = 100 |
(6) ref return
C# 7부터 ref keyword는 method로의 매개변수 전달뿐만 아니라 return 값에도 적용될 수 있습니다. 결과를 반환할 때 실제 값이 아닌 참조를 반환하도록 함으로써 외부 변수가 내부 변수를 참조하고 method가 호출된 후 해당 값이 수정될 수 있도록 합니다.
Person.cs에서 하나의 array변수와 특정 index의 참조를 반환하는 method를 추가합니다.
private int[] position = new int[3];
public ref int GetValue(int index)
{
return ref position[index];
}
Program.cs에서 해당 method를 호출하는 2개의 문을 추가합니다.
ref int v = ref hong.GetValue(1);
v = 10;
Console.WriteLine(hong.GetValue(1));
예제의 실행결과는 다음과 같습니다.
예제는 method내부 변수의 참조를 통해 외부에서 값을 변경하고 다시 method안에서 해당 값을 확인하고 있습니다.
(7) tuple을 사용한 다중 값 반환하기
Method는 기본적으로 하나의 type에 대한 하나의 값만을 반환할 수 있습니다. 여기서 type은 이전 예제와 같이 string이 될 수 있고 Person과 같이 복합 type이 될 수도 있습니다. 혹은 List<Person>과 같이 collection type을 반환하는 것도 가능합니다.
그런데 만약 GetTheData이름의 method를 만들어야 하는데 해당 method에서 string값과 int값을 둘 다 받아야 하는 경우라면 어떻게 할 수 있을까? 쉽게는 TextAndNumber와 같은 string field와 int field를 갖는 새로운 class를 정의하고 해당 type의 intance를 반환하도록 할 수 있을 것입니다.
public class TextAndNumber
{
public string Text;
public int Number;
}
public class Human
{
public TextAndNumber GetTheData()
{
return new TextAndNumber
{
Text = "Hong",
Number = 25
};
}
}
하지만 둘의 값을 결합하기 위해 꼭 class를 정의할 필요는 없습니다. C#은 이런 경우에 사용가능한 tuple이라는 것을 사용할 수 있는데 Tuple은 2개 혹은 그 이상의 값을 하나의 단위로 결합하기 위한 효율적인 방식을 제공합니다.
Tuple은 F#과 같은 어떤 언어에서는 첫 version부터 지원하기 시작했지만 .NET에서는 2010년 .NET 4.0부터 System.Tuple type을 통해 tuple을 지원하기 시작했습니다.
● C# 언어에서의 tuple지원
C#이 괄호(())를 사용해 tuple을 위한 언어 구문을 지원하는 것은 2017년 C# 7.0에서만 가능했습니다. 같은 시간 .NET은 일부 일반적인 scenaris에서 old version의 .NET 4.0 System.Tuple type보다 더 효율적인 새로운 System.VlueTuple type추가하였으며 이것으로 더욱 효율적인 구문을 사용할 수 있게 되었습니다.
Person.cs에서는 string과 int를 결합한 tuple을 반환하는 method를 정의하는 구문을 아래와 같이 추가합니다.
public (string, int) GetFruit()
{
return ("Apples", 5);
}
그리고 Program.cs에서 위의 GetFruit method를 호출하고 tuple의 field를 출력하는 구문을 추가합니다. 이때 field는 Item1과 Item2라는 이름이 자동적으로 부여됩니다.
(string, int) fruit = hong.GetFruit();
Console.WriteLine($"{fruit.Item1} - {fruit.Item2}");
예제를 실행하면 다음과 같은 결과를 표시합니다.
● Tuple field의 Naming
tuple의 field에 접근하기 위해서는 기본적으로 Item1, Item2등의 이름을 사용할 수 있습니다.
하지만 명시적으로 각 field에 이름을 부여하는 것도 가능합니다. Person.cs에서 field의 이름과 함께 tuple을 반환하는 method를 아래와 같이 정의합니다.
public (string Name, int Number) GetNamedFruit()
{
return (Name: "Apples", Number: 5);
}
Progam.cs에서는 method를 호출하는 구문을 추가하고 tuple의 field를 이름을 사용하여 출력하도록 합니다.
var fruitNamed = hong.GetNamedFruit();
Console.WriteLine($"There are {fruitNamed.Number} {fruitNamed.Name}.");
위 예제는 다음과 같은 형태로도 사용할 수 있습니다.
(int Number, string Name) fruitNamed = bob.GetNamedFruit();
예제를 실행하면 다음과 같은 결과를 볼 수 있습니다.
만약 다른 개체에서 생성된 tuple이라면 C# 7.1에서 도입된 tuple name inference(tuple 이름 추론)이라는 기능을 사용할 수도 있습니다.
다시 Program.cs에서 string과 int값으로 구성된 2개의 tuple을 생성합니다.
var thing1 = ("kim", 3);
Console.WriteLine($"{thing1.Item1} has {thing1.Item2} children.");
var thing2 = (hong.Name, hong.Children.Count);
Console.WriteLine($"{thing2.Name} has {thing2.Count} children.");
C# 7.0에서는 위 2가지 모두 Item1과 Item2라는 이름 체계를 사용합니다. 또한 C# 7.1부터는 thing2는 Name과 Count라는 이름을 추론할 수도 있습니다.
● Tuple 분해
Tuple은 또한 개별적인 변수로 분해될 수 있습니다. 이 방식은 명명된 field tuple과 같은 문법으로 구현되지만 tuple에 대한 변수가 없습니다.
//2개의 field를 가진 tupe변수로 받는것이 아닌
(string TheName, int TheNumber) tupleWithNamedFields = hong.GetNamedFruit();
//2개의 변수로 받음
(string Name, int Number) = hong.GetNamedFruit();
이렇게 함으로써 tuple자체를 각각의 새로운 변수로 분해하게 됩니다. Program.cs에서 GetFruit method로 반환된 tuple을 분해하는 구문을 아래와 같이 추가합니다.
(string Name, int Number) = hong.GetFruit();
Console.WriteLine($"Deconstructed: {Name}, {Number}");
● Tuple 별칭
Tuple을 별칭화 하는 건 C#12에 도입된 것으로 type을 명명하고 이를 변수를 선언할 때 type과 pamater의 이름으로서 사용할 수 있도록 하는 것입니다.
using UnnamedParameters = (string, int);
using NamedParamters = (string Name, int Number);
참고로 Tuple 별칭을 사용할 때는 title case(pascal case)를 사용합니다.
위와 같은 방식으로 tuple type을 선언하면 다음과 같이 매개변수 type이 동일한 경우 해당 tuple type으로 값을 받을 수 있습니다.
using NamedParamters = (string Name, int Number);
(string Name, int Number) = hong.GetNamedFruit();
● Type 해체
tuple만이 분해될 수 있는 유일한 type이 아닙니다. 모든 type은 개체를 부분적으로 분해할 수 있는 Deconstruct란 특별한 method를 가지고 있습니다. Person.cs에서는 부분적으로 나누고자 하는 out 매개변수가 정의된 2가지 Deconstruct method를 추가합니다.
public void Deconstruct(out string? name, out DateTime dob)
{
name = Name;
dob = DateOfBirth;
}
public void Deconstruct(out string? name, out DateTime dob, out WondersOfTheAncientWorld fav)
{
name = Name;
dob = DateOfBirth;
fav = FavoriteAncientWonder;
}
예제에서 사용된 out keyword에 관해서는 추후에 자세히 알아볼 것입니다.
그리고 Program.cs에서 hong을 분해하는 구문을 아래와 같이 추가합니다.
var (name1, dob1) = hong;
Console.WriteLine($"Deconstructed: {name1}, {dob1}");
var (name2, dob2, fav2) = hong;
Console.WriteLine($"Deconstructed: {name2}, {dob2}, {fav2}");
개체를 tuple변수에 할당할 때 암시적으로 호출되므로 예제와 같이 Deconstruct method를 명시적으로 호출하지 않습니다.
이제 예제를 실행하면 아래와 같은 결과를 표시할 것입니다.
5. partial을 사용한 class분리
여러 team의 member로 구성된 대규모 project를 구축하는 경우 또는 크고 복잡한 class를 구현할 때는 여러 file에 걸쳐 class를 나누어 정의하는 것이 필요할 수 있는데 이것은 partial keyword를 통해 구현할 수 있습니다.
또 하나 예를 들어 database로부터 schema 정보를 읽는 개체-관계(object-relational) mapper와 같은 도구에 의해 자동적으로 생성된 Person class에 어떤 구문을 추가해야 한다면 해당 class가 partial로 정의되어 있을 때 우리는 class를 자동으로 생성된 code file과 임의로 작성된 code로 나눌 수 있습니다.
위와 같은 상황에 있다고 가정했을 때 Person class를 어떻게 작성할 수 있을지 예제를 통해 알아보도록 하겠습니다.
Person.cs에서 아래와 같이 partial keyword를 추가합니다.
public partial class Person : object
그런 뒤 NetLibraryStandard2 project에 PersonAutoGen.cs라는 이름의 file을 아래와 같이 추가합니다.
namespace CLIEL.Shared
{
public partial class Person
{
}
}
앞으로 Person.cs에 작성될 code는 이제 PersonAutoGen.cs에서 작성할 것입니다.
6. Property와 Indexer를 통한 접근 제어
이전 예제에서는 Person의 origin과 name을 포함한 string을 반환하는 GetOrigin method를 생성했었습니다. Java와 같은 언어에서는 이와 같은 것을 많이 하는데 C#은 property라는 더 나은 방법을 갖고 있습니다.
property는 단순화된 method(또는 method의 쌍)으로서 값을 가져오거나 설정할 때 어떤 동작을 수행하며 field처럼 값을 확인할 수 있는데, 비교적 간단한 구문을 통해 이를 구현할 수 있습니다.
(1) 읽기 전용 property 정의
읽기 전용 property는 단지 get만 구현된 것을 말합니다.
PersonAutoGen.cs file의 Person class에서 아래 3개의 속성을 정의합니다.
- 첫 번째 속성은 C#의 모든 version에서 작동하는 property 구문을 사용한 GetOrigin method와 동일한 역할을 수행합니다.
- 두 번째 속성은 C# 6부터 사용가능한 lambda 표현식 본문인 => 구문을 사용해 환영 message를 반환합니다.
- 세 번째 속성은 person에 대한 age를 계산합니다.
public partial class Person
{
public string Origin
{
get
{
return string.Format("{0} was born on {1}", arg0: Name, arg1: HomePlanet);
}
}
public string Greeting => $"{Name} says 'Hello!'";
public int Age => DateTime.Today.Year - DateOfBirth.Year;
}
Program.cs에서는 위 속성에 대한 값을 가져오는 구문을 아래와 같이 추가합니다.
Person hong = new()
{
Name = "honggd",
DateOfBirth = new(1982, 09, 29)
};
Console.WriteLine(hong.Origin);
Console.WriteLine(hong.Greeting);
Console.WriteLine(hong.Age);
예제를 실행하면 아래와 같은 결과를 확인할 수 있습니다.
(2) 설정 가능 property 정의
설정가능한 속성을 생성하려면 get뿐만 아니라 set부분 method를 같이 한쌍으로 제공해야 합니다.
PersonAutoGen.cs에서 get과 set method(getter와 setter)를 가진 string 속성을 아래와 같이 추가합니다.
public string? FavoriteIceCream { get; set; }
예제에서는 Person의 좋아하는 ice cream을 저장하기 위한 field를 정의하지 않았지만 위와 같은 code만으로 compiler는 자동적으로 필요한 field를 생성합니다.
때로는 속성의 값이 설정될 때 더 세세한 제어가 필요할 수 있는데 이런 경우에는 해당 기능에 필요한 구문과 함께 속성에 값을 저장하기 위한 private field를 임의로 생성해야 합니다.
PersonAutoGen.cs에서 string field와 get과 set을 가진 string 속성을 아래와 같이 정의합니다.
private string? favoritePrimaryColor;
public string? FavoritePrimaryColor
{
get
{
return favoritePrimaryColor;
}
set
{
switch (value?.ToLower())
{
case "red":
case "green":
case "blue":
favoritePrimaryColor = value;
break;
default:
throw new ArgumentException($"{value} is not a primary color. Choose from: red, green, blue.");
}
}
}
되도록이면 getter와 setter에 너무 많은 code가 들어가지 않도록 해야 합니다. code가 너무 긴 경우에는 private method를 추가하여 getter와 setter에서 호출하도록 해 code를 간소화하는 것이 좋습니다.
Program.cs에서는 person의 FavoriteIceCream과 FavoritePrimaryColor를 설정하는 구문을 추가하고 설정값을 출력하도록 합니다.
hong.FavoriteIceCream = "Vanilla";
Console.WriteLine($"hong's favorite ice-cream flavor is {hong.FavoriteIceCream}.");
string color = "Red";
try
{
hong.FavoritePrimaryColor = color;
Console.WriteLine($"hong's favorite primary color is {hong.FavoritePrimaryColor}.");
}
catch (Exception ex)
{
Console.WriteLine("Tried to set {0} to '{1}': {2}", nameof(hong.FavoritePrimaryColor), color, ex.Message);
}
예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
color에는 red, green, blue 이외에 다른 값의 설정을 시도하면 다음과 같은 결과를 표시하게 됩니다.
field에 대한 값을 설정하고 읽을 때 되도록이면 GetAge나 SetAge와 같은 method의 사용을 피하고 속성을 사용하는 것이 좋습니다.
(3) Indexer 정의
Indexer는 호출 code가 배열문법을 통해 속성으로 접근할 수 있도록 합니다. 예를 들어 string type에서는 indexer를 정의하고 있으므로 호출 code가 string에 있는 각각의 문자에 아래와 같이 접근할 수 있습니다.
string alphabet = "abcdefghijklmnopqrstuvwxyz";
char letterF = alphabet[5]; // f
Person에서도 children으로의 접근을 간소화하기 위해 indexer를 정의할 수 있습니다.
PersonAutoGen.cs에서 child의 index를 통해 child를 설정하고 가져올 수 있는 indexer를 아래와 같이 정의합니다.
public Person this[int index]
{
get { return Children[index]; }
set { Children[index] = value; }
}
또한 indexer의 재정의를 통해 int값을 전달하는 것 외에 매개변수에 string 같은 다른 type을 사용할 수 있습니다.
PersonAutoGen.cs에서 child의 이름을 사용해 child를 설정하고 가져올 수 있는 indexer를 아래와 같이 정의합니다.
public Person this[string name]
{
get
{
return Children.Find(p => p.Name == name);
}
set
{
Person found = Children.Find(p => p.Name == name);
if (found is not null)
found = value;
}
}
List<T>와 같은 collection과 lambda 식에 관해서는 추후에 자세히 알아볼 것입니다.
Program.cs에서는 hong에 대한 2개의 children을 추가하고 각각의 방식을 통해 Child에 접근하는 문을 아래와 같이 추가합니다.
hong.Children.Add(new()
{
Name = "gil",
DateOfBirth = new(2014, 6, 28)
});
hong.Children.Add(new()
{
Name = "young",
DateOfBirth = new(2015, 10, 11)
});
//Children List 사용
Console.WriteLine($"Hong's first child is {hong.Children[0].Name}.");
Console.WriteLine($"Hong's second child is {hong.Children[1].Name}.");
//integer position indexer 사용
Console.WriteLine($"Hong's first child is {hong[0].Name}.");
Console.WriteLine($"Hong's second child is {hong[1].Name}.");
// indexer명 사용
Console.WriteLine($"Hong's child named Ella is {hong["gil"].Age} years old.");
예제를 실행하면 다음과 같은 결과를 표시할 것입니다.
7. Method 확장하기
기본적인 method의 활용은 이전에 이미 살펴본 바 있습니다. 자주 사용되는 것은 아니지만 method의 좀 더 효율적인 활용을 위해 추가적인 몇몇 사항들을 더 알아보고자 합니다.
+나 *와 같은 연산자는 숫자에만 적용될 수 있는 것은 아닙니다. 예를 들어 Person의 instance에 적용되어 +나 *와 같은 연산자에 동작할 수 있는 method를 생성하고 연산자를 재정의함으로써 2개의 Person에 +연산자를 적용하는 경우 결혼을, *연산자를 적용하는 경우 아이의 출산과 같은 동작을 구현할 수 있습니다.
Instance method는 개체 자체에서 동작하는 것이며 static(정적) method는 type에서 동작하는 것입니다. 어떤 것을 선택할지는 동작에 가장 접합한 것이 무엇이냐에 따라 달라집니다.
비슷한 동작을 수행하기 위한 정적 method와 instance method를 둘 다 정의하는 것은 때로는 현명한 수단이 될 수 있습니다. 예를 들어 string은 Compare라는 정적 method와 CompareTo라는 instance method를 가지고 있으며 개발자는 편의에 따라 어떤 것을 사용할지 선택할 수 있습니다.
(1) Method를 사용한 기능 구현
예제를 통해 static과 instance method를 사용한 기능을 구현해 볼 것입니다. PersonAutoGen.cs의 Person class에서 private field를 가진 읽기 전용 property를 추가하여 결혼을 했는지, 했으면 누구랑 했는지를 나타내도록 합니다.
private bool married = false;
public bool Married => married;
private Person? spouse = null;
public Person? Spouse => spouse;
그리고 하나의 instance method와 static method를 Person class에 추가하여 2개의 Person개체로 결혼할 수 있는 기능을 부여합니다.
public static void Marry(Person p1, Person p2)
{
p1.Marry(p2);
}
//instance method
public void Marry(Person partner)
{
if (married) return;
spouse = partner;
married = true;
partner.Marry(this);
}
위 예제에서는 다음 2가지 사항에 주목해야 합니다.
- Marry static method에서 Person개체는 p1과 p2 매개변수로 Marry instance method로 전달됩니다.
- Marry instance method에서는 매개변수로 전달된 partner로 spouse가 설정되며 married bool 변수가 true로 설정됩니다.
이번에는 2개의 Person개체가 아이를 낳을 수 있도록 하는 instance와 static method를 하나씩 추가합니다.
public static Person Procreate(Person p1, Person p2)
{
if (p1.Spouse != p2)
{
throw new ArgumentException("일단 결혼부터...");
}
Person baby = new()
{
Name = $"Baby of {p1.Name} and {p2.Name}",
DateOfBirth = DateTime.Now
};
p1.Children.Add(baby);
p2.Children.Add(baby);
return baby;
}
public Person ProcreateWith(Person partner)
{
return Procreate(this, partner);
}
위 예제에서는 아래 사항에 주목합니다.
- Procreate static method에서 아이를 낳을 Person 개체는 매개변수 p1과 p2로 전달됩니다.
- baby라는 새로운 Person class는 부모가 될 둘의 Person에 대한 이름의 결합으로 만들어지게 됩니다. 그러나 이렇게 설정된 이후에 반환된 baby 변수의 Name속성을 설정하게 됨으로써 해당 값은 바뀔 수 있습니다.
- baby개체는 둘 부모의 Children collection으로 추가되고 반환됩니다. Class는 참조 type이므로 이때는 baby 개체의 복사본이 아닌 memory에 저장된 baby 개체의 참조가 추가됩니다. 참조 type과 값 type에 대한 자세한 차이점은 추후에 알아볼 것입니다.
- ProcreateWith instance method에서 아이를 낳을 Person개체는 자신을 나타내며 Procreate static method로 전달된 this와 함께 partner이름의 매개변수로 전달하여 구현된 method를 재사용합니다. this는 class의 현재 instance를 참조하는 keyword입니다.
새로운 개체를 생성하거나 기존 개체를 수정하는 method는 개체에 대한 참조를 반환하도록 함으로써 method의 호출자가 결과에 접근할 수 있어야 합니다.
Program.cs에서는 3개의 Person개체를 생성하고 이들에 대한 결혼을 수행한 뒤 서로 아이들 낳을 수 있도록 합니다.
Person kim = new() { Name = "Kim" };
Person lee = new() { Name = "Lee" };
Person hong = new() { Name = "Hong" };
kim.Marry(lee);
Person.Marry(hong, kim);
Console.WriteLine($"{kim.Name} is married to {kim.Spouse?.Name ?? "nobody"}");
Console.WriteLine($"{lee.Name} is married to {lee.Spouse?.Name ?? "nobody"}");
Console.WriteLine($"{hong.Name} is married to {hong.Spouse?.Name ?? "nobody"}");
Person baby1 = kim.ProcreateWith(lee);
baby1.Name = "Baby1";
Console.WriteLine($"{baby1.Name} was born on {baby1.DateOfBirth}");
Person baby2 = Person.Procreate(hong, kim);
baby2.Name = "Baby2";
Console.WriteLine($"{kim.Name} has {kim.Children.Count} children.");
Console.WriteLine($"{lee.Name} has {lee.Children.Count} children.");
Console.WriteLine($"{hong.Name} has {hong.Children.Count} children.");
for (int i = 0; i < kim.Children.Count; i++)
{
Console.WriteLine(format: "{0}'s child #{1} is named \"{2}\".", arg0: kim.Name, arg1: i, arg2: kim[i].Name);
}
예제의 마지막 부분에서는 문자열에 큰따옴표(")를 추가하기 위해 backslash문자를 사용했습니다. 예제를 실행하면 다음과 같은 결과를 나타낼 것입니다.
(2) 연산자를 사용한 기능 구현
System.String class에는 2개의 문자열 값을 연결하고 그 결과를 반환하는 Concat이라는 static method가 있습니다.
string s1 = "Hello ";
string s2 = "World!";
string s3 = string.Concat(s1, s2);
Console.WriteLine(s3);
Concat과 같은 method를 통해서도 가능하지만 아무래도 동일한 처리를 위해 가장 많이 사용되는 것은 +연산자일 것입니다.
string s3 = s1 + s2;
이를 통해 code에서 * 문자를 통해 2개의 Person 개체에 대한 출산을, 그리고 + 문자로는 혼인을 구현해 볼 것입니다.
앞서 상술한 동작을 위해서는 * 문자를 위한 static 연산자를 정의해야 합니다. 이 문법은 method와 비슷하지만 method의 이름대신 기호를 사용하기에 사실상 연산자가 method이며 이러한 방식으로 문법을 더욱 간소화하게 만들 수 있습니다.
PersonAutoGen.cs에서 + 기호를 위한 static 연산자를 아래와 같이 생성합니다.
public static bool operator +(Person p1, Person p2)
{
Marry(p1, p2);
return p1.Married && p2.Married;
}
연산자의 return type은 매개변수로 연산자에 전달된 type와 일치할 필요는 없으나 return type이 void가 될 수는 없습니다.
그리고 * 기호를 위한 static 연산자 또한 아래와 같이 생성합니다.
public static Person operator *(Person p1, Person p2)
{
return Procreate(p1, p2);
}
method와 달리 연산자는 IntelliSense를 통해 type의 list에 나타나지 않습니다. 따라서 정의한 모든 연산자에 대한 method를 같이 만드는 것이 좋습니다. 왜냐하면 연산자를 사용할 수 있다는 것이 개발자에게는 분명하지 않을 수 있기 때문이며 연산자의 구현 부분에서는 method를 호출하여 작성한 code를 재사용합니다. method를 제공해야 하는 두 번째 이유는 연산자가 모든 언어의 compiler에서 지원하는 것이 아니기 때문입니다. 예를 들어 *와 같은 산술연산자가 비록 Visual Basic과 F#에서 지원되기는 하지만 다른 언어가 C#에서 지원되는 모든 연산자를 지원한다는 보장이 없습니다.
Program.cs에서는 hong과 kim의 혼인을 위해 호출한 static Marry method부분을 주석처리하고 Marry의 기능을 대체할 +연산자를 사용하여 if문에 넣고 code를 아래와 같이 바꿔줍니다.
if (kim + hong)
{
Console.WriteLine($"{kim.Name} and {hong.Name} successfully got married.");
}
그리고 hong의 children을 순회하는 for문 위에서 아래와 같이 * 연산자를 사용해 kim과 그의 아내 lee와 hong사이에 두 명의 아이를 생성하는 구문을 추가합니다.
Person baby3 = kim * lee;
baby3.Name = "Baby3";
Person baby4 = hong * kim;
baby4.Name = "Baby4";
for (int i = 0; i < kim.Children.Count; i++)
{
Console.WriteLine(format: "{0}'s child #{1} is named \"{2}\".", arg0: kim.Name, arg1: i, arg2: kim[i].Name);
}
예제를 실행하면 다음과 같은 결과를 확인할 수 있습니다.
(3) 지역함수를 사용한 기능 구현
C# 7.0에서 도입된 언어기능으로 지역함수를 정의하는 것이 있습니다.
지역함수는 지역변수와 동일한 method로서 지역함수가 정의된 method안에서만 접근 가능한 method입니다. 다른 언어에서는 때로 중첩된(nested) 또는 inner 함수라고도 합니다.
지역 함수는 method안이면 위, 아래, 중간 어디서든 정의될 수 있습니다.
예제를 통해 수열 계산을 구현하기 위한 지역함수를 사용할 것이며 이를 위해 PersonAutoGen.cs에서 스스로 결과를 계산하기 위해 내부에서 지역함수를 사용하는 Factorial 함수를 아래와 같이 정의합니다.
public static int Factorial(int number)
{
if (number < 0)
{
throw new ArgumentException($"{nameof(number)} cannot be less than zero.");
}
return localFactorial(number);
int localFactorial(int localNumber)
{
if (localNumber == 0) return 1;
return localNumber * localFactorial(localNumber - 1);
}
}
Program.cs에서는 Factorial함수를 호출하고 결과값을 console에 출력하는 구문을 예외처리와 함께 아래와 같이 추가합니다.
int number = 5;
try
{
Console.WriteLine($"{number}! is {Person.Factorial(number)}");
}
catch (Exception ex)
{
Console.WriteLine($"{ex.GetType()} says: {ex.Message} number was {number}.");
}
예제를 실행하면 다음과 같은 결과를 볼 수 있습니다.
number변수의 값을 -1로 변경하면 다음과 같은 예외를 확인할 수 있습니다.
8. 개체에 대한 pattern matching
아래 글을 통해 우리는 이미 기본적인 pattern matching을 사용해 본 바 있었습니다.
2024.01.17 - [.NET/C#] - [C# 12와 .NET 8] 3. 흐름제어, Type 변환, 예외 처리
그리고 이제 pattern matching에 대한 좀 더 상세한 내용을 더 알아보고자 합니다.
(1) 비행 승객 정의하기
다음 예제를 통해서는 비행기에 탑승하는 다양한 유형의 승객을 표현하기 위한 class를 정의할 것이며 그들에 대한 표값을 결정하기 위해 pattern matching과 함께 switch 문을 사용할 것입니다.
NetLibraryStandard2 project에 FlightPatterns.cs이름의 file을 추가하고 다른 속성을 가진 3개의 승객유형을 아래와 같이 정의합니다.
namespace NetLibraryStandard2
{
public class Passenger
{
public string? Name { get; set; }
}
public class BusinessClassPassenger : Passenger
{
public override string ToString()
{
return $"Business Class: {Name}";
}
}
public class FirstClassPassenger : Passenger
{
public int AirMiles { get; set; }
public override string ToString()
{
return $"First Class with {AirMiles:N0} air miles: {Name}";
}
}
public class CoachClassPassenger : Passenger
{
public double CarryOnKG { get; set; }
public override string ToString()
{
return $"Coach Class with {CarryOnKG:N2} KG carry on: {Name}";
}
}
}
ToString method에 대한 재정의 부분은 추후에 자세히 알아볼 것입니다.
Program.cs에서는 여러 유형과 속성에 대한 다섯 명의 승객을 포함하는 개체 배열을 정의하고 이들을 열거하면서 해당 승객에 대한 비용을 출력하는 구문을 아래와 같이 추가합니다.
Passenger[] passengers = {
new FirstClassPassenger { AirMiles = 1_419, Name = "Suyoung" },
new FirstClassPassenger { AirMiles = 16_562, Name = "Lyujung" },
new BusinessClassPassenger { Name = "Junghee" },
new CoachClassPassenger { CarryOnKG = 25.7, Name = "Dongsu" },
new CoachClassPassenger { CarryOnKG = 0, Name = "Ayoung" },
};
foreach (Passenger passenger in passengers)
{
decimal flightCost = passenger switch {
FirstClassPassenger p when p.AirMiles > 35000 => 1500M,
FirstClassPassenger p when p.AirMiles > 15000 => 1750M,
FirstClassPassenger _ => 2000M,
BusinessClassPassenger _ => 1000M,
CoachClassPassenger p when p.CarryOnKG < 10.0 => 500M,
CoachClassPassenger _ => 650M,
_ => 800M
};
Console.WriteLine($"Flight costs {flightCost:C} for {passenger}");
}
위 예제에서는 아래 3가지 사항에 주목합니다.
- 개체에 대한 속성에 pattern match를 구현하기 위해 표현식에서 사용될 수 있는 p와 같은 지역변수 이름을 사용해야 합니다.
- type에서만 pattern match를 구현하기 위해서는 지역변수를 사용하는 대신 _를 사용해 code를 단순화시킬 수 있습니다.
- _ (under bar)는 또한 switch 표현식에서 기본 분기를 나타내기 위해 사용할 수 있습니다.
예제를 실행하면 다음과 같은 결과를 볼 수 있습니다.
(2) C# 9부터 적용된 향상된 pattern matching
위 예제에서는 C# 8에서 작동할 수 있는 code이며 C# 9부터는 좀 더 향상된 문법을 사용할 수 있습니다. type matching에서는 더 이상 _ (under bar) 문자를 사용할 필요가 없어졌습니다.
Program.cs에서 위 예제의 C# 8 문법을 주석처리하고 C# 9부터 사용되는 문법을 적용하여 first-class승객에 대한 중첩된 switch 표현식을 사용하도록 변경하고 >와 같은 조건부에 대한 새로운 지원을 추가합니다.
//FirstClassPassenger p when p.AirMiles > 35000 => 1500M,
//FirstClassPassenger p when p.AirMiles > 15000 => 1750M,
//FirstClassPassenger _ => 2000M,
FirstClassPassenger p => p.AirMiles switch
{
> 35000 => 1500M,
> 15000 => 1750M,
_ => 2000M
},
예제를 실행하면 이전과 동일한 결과를 확인할 수 있습니다. 해당 예제와 같은 상황에서는 또한 중첩된 switch 표현식을 사용하고 싶지 않다면 속성 pattern을 관계형 pattern과 결합하여 사용할 수 있습니다.
FirstClassPassenger { AirMiles: > 35000 } => 1500,
FirstClassPassenger { AirMiles: > 15000 } => 1750M,
FirstClassPassenger => 2000M,
더 자세한 사항은 아래 공식문서를 통해 확인할 수 있습니다.
Pattern matching overview - C# guide - C# | Microsoft Learn
9. record 사용
Record라는 새로운 언어기능을 살펴보기 전에 C# 9부터 도입된 일부 관계된 기능을 먼저 알아보도록 하겠습니다.
(1) 초기화 전용(Init-only) 속성
우리는 여러 예제를 통해 개체에 대한 초기화 구문으로 개체의 instance를 생성해 왔고 초기 속성을 설정했습니다. 그런데 이들 속성은 instance이후에도 값이 바뀔 수 있습니다.
때로는 속성이 읽기 전용(read-only)과 같은 속성처럼 다뤄져야 하는 경우도 있으며 따라서 instance를 생성할 때 설정되고 이후에는 바뀔 수 없어야 하는 경우 새로운 init keyword를 사용해 이러한 고민을 해결할 수 있습니다.
NetLibraryStandard2 project에서 Records.cs이름의 새로운 file을 생성하고 2개의 불변(immutable) 속성을 가진 person class를 아래와 같이 생성합니다.
namespace NetLibraryStandard2
{
public class ImmutablePerson
{
public string? FirstName { get; init; }
public string? LastName { get; init; }
}
}
그리고 Program.cs에서 위 ImmutablePerson에 대한 instance를 생성하고 속성 중 하나의 속성에 대한 값의 변경을 시도합니다.
ImmutablePerson hong = new()
{
FirstName = "hong",
LastName = "gildong"
};
hong.FirstName = "Geoff";
그러면 다음과 같은 error를 표시하게 될 것입니다.
FirstName을 개체 초기화에서 설정하지 않는다 하더라도 일단 초기화 이후에는 값을 설정할 수 없습니다. 만약 특정 속성에 대해 값이 값이 설정되도록 강제하려면 required keyword를 사용해야 합니다.
(2) Record의 이해
초기화 전용 속성은 C#에 일부 불변성을 제공하며 이러한 개념은 record를 통해서도 가져갈 수 있습니다. record는 class keyword대신 record keyword를 사용해 정의되며 개체 전체를 불변으로 만들 수 있고 비교동작시 값처럼 동작합니다. class와 record 그리고 값 type에 대한 자세한 사항은 추후에 다뤄볼 것입니다.
Record는 instance이후에 바뀔 수 있는 어떠한 속성이나 field로 가질 수 없습니다. 대신 이미 존재하는 것으로부터 새로운 record를 생성할 수는 있는데 이를 통해 바뀐 값으로의 instance를 얻을 수 있습니다. 이러한 방식을 non-destructive mutation이라고 하는데 C# 9부터 with keyword를 통해 도입하여 사용할 수 있습니다.
Records.cs에서 ImmutableVehicle이름의 record를 아래와 같이 추가합니다.
public record ImmutableVehicle
{
public int Wheels { get; init; }
public string? Color { get; init; }
public string? Brand { get; init; }
}
Program.cs에서는 필요한 값을 통해 ImmutableVehicle에 대한 instance를 아래와 같이 생성할 수 있습니다. 해당 car instance는 이후 불변으로 속성은 값을 바꿀 수 없습니다.
ImmutableVehicle car = new()
{
Brand = "Spark",
Color = "Black",
Wheels = 4
};
상술하였듯 with keyword를 사용해 기존 instance에 대한 값이 바뀐 복사본을 생성하고 각각의 속성값을 확인하도록 합니다.
ImmutableVehicle repaintedCar = car with { Color = "White" };
Console.WriteLine($"Original car color was {car.Color}.");
Console.WriteLine($"New car color is {repaintedCar.Color}.");
예제를 실행하면 아래와 같은 결과를 표시할 것입니다. 복사본에서 car의 color가 변경되었음에 주목하시기 바랍니다.
(3) Record 비교
Record 사용으로 인해 확인가능한 가장 중요한 변화중 하나는 Record를 서로 비교하는 경우 확인할 수 있습니다. 같은 속성과 값을 가진 2개의 Record는 같은 것으로 취급됩니다. 별것 아닌 것 같이 들릴 수도 있겠지만 class를 사용하는 경우는 같은 값을 가지고 있다 하더라도 기본적으로 참조 memory주소값을 비교하기 때문에 다른 것으로 취급됩니다.
public class AnimalClass
{
public string Name { get; set; }
public string Species { get; set; }
}
public record AnimalRecord
{
public string Name { get; set; }
public string Species { get; set; }
}
예제는 같은 형식으로 class와 record type을 각각 정의하고 있습니다. 이들에 대한 비교를 수행하기 위해 각각의 instance를 생성하고 아래와 같이 비교합니다.
AnimalClass ac1 = new() { Name = "nyao", Species = "Cat" };
AnimalClass ac2 = new() { Name = "nyao", Species = "Cat" };
Console.WriteLine($"ac1 == ac2: {ac1 == ac2}");
AnimalRecord ar1 = new() { Name = "Mong", Species = "Dog" };
AnimalRecord ar2 = new() { Name = "Mong", Species = "Dog" };
Console.WriteLine($"ar1 == ar2: {ar1 == ar2}");
예제를 실행하면 다음과 같은 결과를 확인할 수 있습니다.
첫 번째(class)는 같은 속성과 값을 가지고 있음에도 불구하고 같지 않다고 표시하고 있지만 두 번째(record)는 정확히 동일함으로 표시하고 있습니다.
(4) Record에서의 positional data member
Record를 정의하는 문법은 positional data member의 사용으로 훨씬 간소화될 수 있습니다.
● Record에서 data member 간소화
중괄호를 통한 개체초기화문을 사용하는 대신 위에서 이미 해본 것처럼 positional 매개변수를 생성자에서 제공할 수 있고 또한 개체를 개별적으로 분해하기 위한 형식분해를 같이 결합할 수도 있습니다.
public record ImmutableAnimal
{
public string Name { get; init; }
public string Species { get; init; }
public ImmutableAnimal(string name, string species)
{
Name = name;
Species = species;
}
public void Deconstruct(out string name, out string species)
{
name = Name;
species = Species;
}
}
다만 위와 같은 속성과 생성자, 형식분해는 자동으로 생성될 수 있습니다.
Record.cs에서 위 예제를 아래와 같이 positional record로 알려진 간소화문을 사용하여 record를 재작성해 정의합니다.
public record ImmutableAnimal(string Name, string Species);
다수의 line에 걸쳐 작성했던 record문을 위의 한 줄로 줄였습니다. Program.cs에서는 ImmutableAnimal에 대한 생성자와 형식분해문을 아래와 같이 사용합니다.
ImmutableAnimal oscar = new("Milan", "Labrador");
var (who, what) = oscar;
Console.WriteLine($"{who} is a {what}.");
예제를 실행하면 다음과 같은 결과를 확인할 수 있습니다.
(5) 가변성과 record
Record를 생각할때 초기화이후에 instance 속성과 값형 field의 값이 바뀔 수 없는 불변성(immutable)부터 떠올리기 쉽지만 이는 사실 record가 어떻게 정의되었는가에 따라 달라질 수 있습니다.
예를 들어 아래와 같이 Record가 사용되었을 경우
public record class C1
{
public string? Name { get; set; }
}
public record class C2(string? Name);
public record struct S1
{
public string? Name { get; set; }
}
public record struct S2(string? Name);
public readonly record struct S3(string? Name);
다음과 같이 각 type에 대한 instance를 생성하고 Name에 대한 초기값을 설정한 뒤 다시 Name에 대한 속성값을 변경합니다.
C1 c1 = new() { Name = "Hong" };
c1.Name = "Kim";
C2 c2 = new(Name: "Hong");
c2.Name = "Kim";
S1 s1 = new() { Name = "Hong" };
s1.Name = "Kim";
S2 s2 = new(Name: "Hong");
s2.Name = "Kim";
S3 s3 = new(Name: "Hong");
s3.Name = "Kim";
그런뒤 build를 시도하면 compiler error CS8852를 통해 이들 중 2개의 type이 초기화 이후에 불변성이 됨을 알 수 있습니다.
C1은 가변성이지만 C2는 불변이 되며 S1과 S2는 가변이지만 C3는 불변입니다. Microsoft는 record와 관련하여 몇가지 흥미로운 design을 적용하였습니다. 때문에 record, class, struct를 섞어서 사용할고 각각의 선언에서 다른 type을 사용할때 이들 동작에 대한 차이를 기억하고 있어야 합니다.
'.NET > C#' 카테고리의 다른 글
[C# 12와 .NET 8] 7. .NET Packaging과 배포 (0) | 2024.02.20 |
---|---|
[C# 12와 .NET 8] 6. Interface와 Class상속 (0) | 2024.02.20 |
[C# 12와 .NET 8] 4. Debuging과 Testing (0) | 2024.01.22 |
[C# 12와 .NET 8] 3. 흐름제어, Type 변환, 예외 처리 (0) | 2024.01.17 |
[C# 12와 .NET 8] 2. C# (0) | 2024.01.12 |