7. 상속(Inheriting)
상속은 기존의 Type에서 새로운 Type을 생성하는 것을 말합니다.
namespace mylibrary;
public class Car
{
public int Speed { get; set; }
public int Drive(int accelerate)
{
Speed += accelerate;
return Speed;
}
public void Stop()
{
Speed = 0;
}
}
Sedan에서 Car클래스를 상속받는 처리는 다음과 같이 구현할 수 있습니다.
public class Sedan : Car //상속
{
}
실제 Sedan은 아무런 Member도 포함하고 있지 않지만 Car의 Member를 그대로 상속 받음으로써 다음과 같이 Sedan을 활용할 수 있습니다.
static void Main(string[] args)
{
Sedan sedan = new();
sedan.Drive(100);
Console.WriteLine($"현재 속도 : {sedan.Speed}");
//현재 속도 : 100
}
● 파생 클래스 확장
상속받은 클래스를 파생 클래스(혹은 서브클래스)라고 하며 예제에서 파생 클래스는 Sedan에 해당합니다. 비록 다른 클래스로부터 상속받은 클래스지만 또 다른 하나의 Type이므로 자신만의 멤버를 가질 수 있습니다.
public class Sedan : Car //상속
{
private bool reverse = false;
public void Reverse()
{
reverse = true;
}
}
Sedan sedan = new();
sedan.Drive(100);
sedan.Stop();
sedan.Reverse();
● Member 숨기기
Car를 상속받은 파생 클래스 Sedan에서는 Drive()를 새롭게 정의하여 Sedan 멤버로 추가된 reverse의 상태를 true로 바꾸고자 아래와 같이 Drive() 메서드를 구현하였습니다.
public class Sedan : Car //상속
{
private bool reverse = false;
public void Reverse()
{
reverse = true;
}
public new int Drive(int accelerate)
{
reverse = false;
Speed += accelerate;
return Speed;
}
}
새롭게 만든 Drive() 메서드는 기반 클래스(예제에서는 Car)의 Drive()와 내부 동작에서만 약간의 차이를 가질 뿐 외적으로는 완전히 동일한 메서드입니다. 파생 클래스에서 이와 같이 동일한 형태의 메서드를 작성하고자 한다면 해당 메서드는 new키워드를 통해 정의되어야 하며 이때 같은 이름의 기반 클래스 메서드는 무효화됩니다.
● 메서드 오버 라이딩(Overriding)
기반 클래스의 메서드를 새롭게 정의하는 것을 '메서드 오버라이드'이라고 합니다. 이것은 위에서 언급한 기반 클래스의 메서드를 무효화하는 것과는 조금 다른 개념인데 일단 오버 라이딩이 가능한 메서드는 기반 클래스에서 해당 메서드가 virtual로 정의된 것만이 가능합니다.
한 가지 예로 C#의 모든 클래스는 잠재적으로 또는 명시적으로 System.Object로부터 상속받는데 System.Object는 자체적으로 ToString()이라는 메서드를 가지고 있습니다. 그런데 이 메서드를 보면 virtual로 정의되어 있는 것을 확인할 수 있는데
public virtual string? ToString();
이는 파생 클래스에서 ToString() 메서드를 오버라이드 할 수 있음을 의미하는 것입니다.
public override string ToString()
{
return $"파생클래스에서 기반클래스의 ToString()를 호출함 : {base.ToString()}";
}
Car클래스에서 위와 같은 메서드를 추가한 후 Car의 인스턴스에서 ToString()을 호출합니다.
static void Main(string[] args)
{
Car car = new();
Console.WriteLine(car.ToString());
//파생클래스에서 기반클래스의 ToString()를 호출함 : mylibrary.Car
}
override 할 때 구현된 base는 기반 클래스의 멤버에 명시적으로 접근하기 위한 키워드로서 이때는 System.Object의 ToString()을 호출하게 됩니다. 그러면서 Car에서 추가한 문자열 내용과 함께 해당 내용을 반환하게 되는 것입니다.(System.Object의 ToString()은 자신의 네임스페이스와 Type명을 출력합니다.)
● 추상 클래스 (abstract)
위에서 인터페이스에 대해 살펴본 바 있는데 인터페이스는 특정 Member의 구현을 강제하여 필요한 Member를 반드시 가지게 된다는 것을 보증할 수 있었습니다. 하지만 Member에 대한 자체적인 구현은 가질 수 없다는 제약이 있습니다.(C# 8까지는)
추상 클래스는 구현해야 할 멤버만을 가지고 있는 인터페이스와 멤버의 실체를 구현하는 클래스 사이의 중간쯤에 해당하는 개념으로 모든 기능을 구현하는 완전한 클래스가 아닌 기능에서 필요로 하는 일반적인 부분만 구현되고 이를 상속받는 클래스에서 자체적으로 필요한 기능을 완성하는 방식으로 활용됩니다. 추상 클래스 자체가 미완의 클래스이기에 자체적인 인스턴스 객체를 생성하는 것은 불가능하고 다른 클래스에서 오로지 상속받는 용로도 만 사용될 수 있습니다.
예를 들어 System.IO.Stream클래스는 추상 클래스에 해당합니다. 이 클래스는 Stream에서 필요로 하는 일반적인 기능만을 구현하고 있는 미완성 상태이므로 new를 통해 인스턴스 객체를 생성하는 것은 불가능하며 이 클래스를 상속해 필요한 나머지 기능을 구현하는 방식으로 추상 클래스가 활용되는 것입니다.
public abstract class Car
{
public int Speed;
public abstract void Drive(int speed);
public void Brake()
{
Speed = 0;
}
}
예제에서 Car는 abstract가 사용되었기에 추상 클래스입니다. Car는 Brake가 걸리는 경우에 대한 처리만 구현되어 있고 Drive() 메서드는 abstract키워드를 적용하여 인터페이스처럼 상속받는 클래스에서 구현해야 할 메서드로 지정하고 있습니다.
public class Sedan : Car
{
public override void Drive(int speed)
{
Speed += speed;
}
}
Car 추상 클래스를 Sedan클래스에서 상속받아 미완의 Drive() 메서드를 구현하고 있습니다.
static void Main(string[] args)
{
Sedan sedan = new();
sedan.Drive(80);
Console.WriteLine(sedan.Speed); //80
sedan.Brake();
Console.WriteLine(sedan.Speed); //0
}
● 봉인 클래스
클래스를 만드는 어떤 경우에는 다른 개발자가 해당 클래스를 상속하는 것 자체를 막아야 하는 경우가 있습니다. 이런 경우 클래스에 sealed키워드를 적용해 봉인 클래스를 만들 수 있습니다.
public sealed class Car
{
public int Speed { get; set; }
public void Drive(int speed)
{
Speed = speed;
}
public void Brake()
{
Speed = 0;
}
}
예제는 하나의 온전한 클래스지만 sealed 키워드가 적용되어 있으므로 다른 클래스에서 상속하는 것은 불가능합니다.
그렇다면 상속도 안 되는 클래스를 왜 만드는 걸까? 이에 대한 대표적인 사례로 들 수 있는 것이 바로 string 클래스입니다. string은 막대한 문자열을 처리하기 위한 나름대로의 성능 최적화를 통해 클래스를 구현하였는데 이를 상속하는 클래스가 존재한다면 그 성능에 부정적인 영향을 줄 수 있으므로 마이크로소프트는 이 클래스를 봉인 클래스로 만들어 두기로 하였습니다.
클래스와 동일하게 메서드에도 sealed를 적용할 수 있습니다.
public class Car
{
public virtual void Drive()
{
}
}
public class Sedan : Car
{
public sealed override void Drive()
{
base.Drive();
}
}
Car 클래스에서는 오버 로딩이 가능한 Drive() 메서드를 정의하고 있고 Sedan에서 Car를 상속해 Drive() 메서드를 오버라이드 하여 구현하였습니다. 이때 sealed키워드를 사용했는데 이는 Sedan을 상속하는 모든 클래스에서는 더 이상 Drive() 메서드를 오버 로딩할 수 없다는 의미가 됩니다.
● 다형성 (polymorphism)
위에서 이미 기반 클래스에 존재하는 메서드의 동작을 바꾸는 2가지 방법에 대해 알아보았습니다. 하나는 new를 사용해 기반 클래스의 메서드를 숨기는 것이고 다른 하나는 override를 통해 기반 클래스의 메서드를 재정의 하는 것입니다.
그렇다면 이 둘의 차이는 뭘까? 이 것은 객체의 참조를 가진 변수의 유형에 따라 달라집니다. 예를 들어
public class Car
{
public string? CarDriver { get; set; }
public void Drive()
{
Console.WriteLine($"운전자 {CarDriver}");
}
}
public class Sedan : Car
{
public string? SedanDriver { get; set; }
public new void Drive()
{
Console.WriteLine($"운전자 {SedanDriver}");
}
}
Car와 Car를 상속받는 2개의 클래스가 있는 경우를 보면 각자의 클래스마다 string형식의 운전자 이름을 갖는 변수가 있고 Drive() 메서드를 통해 이를 표시하고 있습니다. 단, Sedan에서의 Drive()는 기반 클래스(Car)의 Drive()를 new를 통해 숨기고 있습니다.
static void Main(string[] args)
{
Sedan sedan = new() { SedanDriver = "홍길동" };
Car car = sedan;
car.Drive();
sedan.Drive();
//운전자
//운전자 홍길동
}
Car형식의 변수 car는 Sedan에서 생성된 객체의 참조를 갖고 있습니다. 이 상태에서 각자의 Drive()를 호출해 보면 car의 Drive()에서는 Car형식의 Drive()가 실행됨을 알 수 있습니다.(Sedan의 인스턴스 객체인 sedan은 SedanDriver를 통해 운전자의 이름을 갖고 있지만 Car의 CarDriver에는 이름이 할당되어 있지 않기 때문에 이름이 나올 수 없습니다.) 이는 컴파일러가 car객체가 곧 Sedan임을 알지 못하기 때문입니다.
하지만 Car클래스에서 Drive()에 virtual키워드를 적용하고 Sedan에서 override키워드를 적용해 메서드를 오버 로딩하도록 하면
public class Car
{
public string? CarDriver { get; set; }
public virtual void Drive()
{
Console.WriteLine($"운전자 {CarDriver}");
}
}
public class Sedan : Car
{
public string? SedanDriver { get; set; }
public override void Drive()
{
Console.WriteLine($"운전자 {SedanDriver}");
}
}
이때는 컴파일러가 car객체는 곧 sedan임을 인지하게 되어서 Drive() 메서드를 모두 Sedan에서 호출하게 됩니다.
static void Main(string[] args)
{
Sedan sedan = new() { SedanDriver = "홍길동" };
Car car = sedan;
car.Drive();
sedan.Drive();
//운전자 홍길동
//운전자 홍길동
}
new는 기반 클래스의 메서드 동작을 아예 숨기는 탓에 예기치 않은 동작을 실행할 가능성이 많다는 이유로 잘 사용되지 않습니다. 따라서 가능하면 virtual과 override를 대신 사용할 것을 권장합니다.
8. 상속계층 간 캐스팅(Casting)
우선 캐스팅과 컨버팅(Converting)의 명확한 차이부터 짚고 넘어가고자 합니다. 컨버팅은 서로 다른 타입 간 형 변환에 적용되는데, 예를 들면 interger값을 string으로 혹은 string을 interger로 변환하는 것이 여기에 해당합니다. 반면 캐스팅은 서로 비슷한 Type 간 변환에 적용됩니다. 16bit integer를 32bit integer로 변환하거나 혹은 기반 클래스와 파생 클래스와의 변환이 바로 캐스팅이라고 할 수 있습니다.
● 암시적 캐스팅
암시적 캐스팅은 말 그대로 캐스팅이 필요함을 명시화 하지 않은 경우입니다. 예를 들어
int i = 10;
long l = i;
Console.WriteLine(l);
//10
위 예제의 경우 'long l = i'에서 int가 long로 변환되는데 이와 같이 비슷한 타입 간 변환 시에는 캐스팅을 명시하지 않아도 데이터에 대한 손실이 없으므로 알아서 캐스팅이 이루어집니다. 이런 경우를 '암시적 캐스팅'이라고 합니다.
● 명시적 캐스팅
암시적 캐시팅이 불가능한 모든 경우에는 '명시적 캐스팅'을 통해 형 변환을 수행해야 합니다. 예를 들어 아래오 같이 Sedan이 Car를 상속받게 된 경우
public class Car
{
}
public class Sedan : Car
{
}
Car형식은 Sedan의 기반 클래스로서 Sedan에서 Car로의 암시적 캐스팅이 가능하지만
Car c = new();
Sedan s = new();
c = s;
Console.WriteLine(c.ToString());
그 반대로는 암시적 캐스팅이 불가능합니다. 대신 아래와 같이 c가 Sedan Type으로 바뀌어야 한다는 것을 괄호를 통해 명시해주면 컴파일이 정상적으로 진행되며 이러한 변환 방식을 '명시적 캐스팅'이라고 합니다.
Car c = new();
Sedan s = new();
s = (Sedan)c;
Console.WriteLine(s.ToString());
● 형 변환 예외처리
예제에서 c는 Sedan이 Car를 상속했으므로 암시적인 것이 불가능해 이것을 명시적 캐스팅을 통해 형 변환을 시도하고 있습니다. 하지만 컴파일은 정상적으로 진행이 되지만 실제 기반 클래스가 파생 클래스로 형 변환 이루어지는 것은 불가능합니다.
이때 아래와 같은 예외가 발생할 수 있는 것입니다.
이와 같은 예외를 피하기 위해서는 가장 확실하게 try ~ catch를 통해 예외가 발생하는 경우를 따로 제어할 수 있도록 해줄 수 있으며 더 간단하게는 형 변환을 하기 전 실제 형 변환이 가능한지를 is 연산자를 통해 확인해 줄 수 있습니다.
Car c = new();
Sedan s = new();
if (c is Sedan)
{
s = (Sedan)c;
Console.WriteLine(s.ToString());
}
else {
Console.WriteLine("형변환 불가능.");
}
//형변환 불가능.
만약 c가 Sedan으로의 형 변환이 가능하다면 우선 형 변환을 통해 s변수에 c를 담은 다음 s의 ToString()을 호출하도록 되어 있는데 이때 패턴을 적용하면 형 변환 과정까지 한꺼번에 구현해 줄 수 있습니다.
Car c = new();
Sedan s = new();
if (c is Sedan x)
{
Console.WriteLine(x.ToString());
}
else {
Console.WriteLine("형변환 불가능.");
}
혹은 비교 대신 as 키워드를 통해 형 변환을 시도할 수 있습니다. as는 형 변환이 불가능하다면 null값을 반환합니다.
Car c = new();
Sedan? s = c as Sedan;
if (s == null)
{
Console.WriteLine("형변환 실패");
}
참고로 is에서 부정인 경우의 처리를 필요로 한다면 아래와 같이! 연산자를 사용하거나
if (!(c is Sedan))
C# 9 이상의 환경이라면 is not을 사용해 부정을 처리해 줄 수 있습니다.
Car c = new();
if (c is not Sedan)
{
Console.WriteLine("형변환 실패");
}
만약 캐스팅에서 is나 as를 사용하지 않고 try ~ catch를 대신 사용하고자 한다면 해당 형 변환의 예외 타입은 'InvalidCastException'으로 받을 수 있습니다.
9. .NET types의 상속과 확장
.NET은 이미 프로그램 개발에 필요한 수백 가지의 클래스 라이브러리를 포함하고 있습니다. 따라서 모든 경우에 개발자 자신이 필요한 Type을 모두 직접 만들어 내는 대신 기존에 존재하는 Type을 상속받아 새로운 Type을 구현하는 것도 개발의 효휼성을 위한 방법이 될 수 있습니다.
예를 들어 기존의 Exception클래스를 상속해 자신만의 새로운 Exception기능을 구현하는 경우가 그러한 것입니다.
public class CarException : Exception
{
public CarException() : base() { }
public CarException(string message) : base(message) { }
public CarException(string message, Exception innerException) : base(message, innerException) { }
}
예제를 보면 .NET의 Exception을 상속하여 새로운 CarException을 생성하고 있습니다. 다른 Member는 아직 구현되지 않았지만 CarException에서 Exception의 생성자를 모두 호출하여 새로운 생성자를 구현하고 있는데 일반적인 메서드와 달리 생성자는 상속되지 않으므로 만약 CarException예외 클래스를 사용하려는 누군가 기본적인 Exception을 호출하기를 원할 수 있으므로 미리 이를 정의해 두는 것입니다.
이제 예외처리를 구현하는 클래스에서 필요한 메서드를 작성하고
public class Car
{
public int Speed { get; set; }
public int RPM { get; set; }
public int Drive(int speed)
{
this.Speed += speed;
if (RPM == 0)
{
throw new CarException("엔진고장!!");
}
return this.Speed;
}
}
해당 메서드를 호출하면
Car c = new();
c.Drive(80);
위에서 이미 지정된 내용의 예외가 등장하게 될 것입니다.
● Type 확장하기
위에서 이미 언급했듯 sealed키워드가 적용된 클래스라면 상속이 불가능합니다. 대표적인 게 System.String인데 만약 String에 특정한 기능을 부여해 사용하고 싶다면 상속의 대안으로 'extension methods'즉, 확장 메서드를 사용할 수 있습니다.
예를 들어 문자열에 숫자만을 추출하여 보여주는 아래와 같은 메서드에서는
using System.Text.RegularExpressions;
namespace mylibrary;
public class MyStringExtension
{
public int OnlyNumber(string sValue)
{
return int.Parse(Regex.Replace(sValue, @"\D", string.Empty));
}
}
OnlyNumber() 메서드에서 정규식을 통해 숫자만을 골라내어 이를 정수 형태로 반환하고 있습니다. 참고로 예제에서 사용된 'using System.Text.RegularExpressions'구문은 정규식을 사용하기 위한 Namespace선언 부분입니다.
그리고 위의 메서드는 아래와 같이 호출될 수 있습니다.
MyStringExtension mse = new();
int i = mse.OnlyNumber("a1b2c3");
Console.WriteLine(i);
//123
위와 같은 메서드는 동작하는 것 자체는 큰 무리가 없지만 이를 '확장 메서드'로 전환하면 타이핑해야 하는 코드의 양을 줄일 수 있을 뿐만 아니라 클래스가 메서드를 사용하는 것 자체도 간소화할 수 있습니다.
확장 메서드를 위해서는 메서드와 클래스에 static한정자를 적용하고 더불어 this한정자를 통해서 메서드를 연결할 Type을 명시합니다.
using System.Text.RegularExpressions;
namespace mylibrary;
public static class MyStringExtension
{
public static int OnlyNumber(this string sValue)
{
return int.Parse(Regex.Replace(sValue, @"\D", string.Empty));
}
}
예제에서는 this string을 통해서 해당 메서드가 string Type의 확장 메서드임을 컴파일러에게 알려주고 있습니다. 이러한 방법을 통해서 OnlyNumber() 메서드는 본래 string Type에 있었던 메서드처럼 사용할 수 있게 됩니다.
string s = "a1b2c3";
int i = s.OnlyNumber();
Console.WriteLine(i);
'.NET > C#' 카테고리의 다른 글
[C#] File 다루기 - 1. 파일 시스템(Filesystem) (0) | 2022.06.24 |
---|---|
[C#] 인터페이스(Interface)와 상속(Inheriting) - 7. Code분석(StyleCop) (0) | 2022.06.24 |
[C#] 인터페이스(Interface)와 상속(Inheriting) - 5. NULL (0) | 2022.06.24 |
[C#] 인터페이스(Interface)와 상속(Inheriting) - 4. 참조타입과 값타입 (0) | 2022.06.24 |
[C#] 인터페이스(Interface)와 상속(Inheriting) - 3. 인터페이스(Interface) (0) | 2022.06.24 |