null 가능(Nullable) 값 타입
개념과 필요성
- 값 형식(Value Type)은 일반적으로 null을 표현할 수 없음
- 참조 형식(Reference Type)은 null로 존재하지 않는 값을 표현 가능함
string s = null; // OK, 참조 형식
int i = null; // 컴파일 에러, 값 형식은 null이 될 수 없음
- 특수한 구문을 사용하여 값 형식에도 null 표현이 가능함
- nullable int 형이라고 불러야한다. (그냥 int형이 아니다)
- 작성하면 쓸 수 있는 프로퍼티가 제공된다.
- 타입명 뒤에 ? 기호를 붙여 Nullable 타입을 표현함
int? i = null; // OK
Console.WriteLine(i == null); // True
Nullable<T> 구조체
- Nullable 타입은 System.Nullable<T> 구조체로 구현됨
- 두 개의 필드만을 가진 경량 구조체임
- Value: 실제 값을 저장
- HasValue: 값의 존재 여부를 저장
- true면 null이 아니고, false면 null이다.
public struct Nullable<T> where T : struct
{
public T Value { get; }
public bool HasValue { get; }
public T GetValueOrDefault();
public T GetValueOrDefault(T defaultValue);
}
int? i = null;
Console.WriteLine(i.HasValue); // False
i = 5;
Console.WriteLine(i.HasValue); // True
Console.WriteLine(i.Value); // 5
변환 규칙
- T에서 T?로의 변환은 암시적(implicit)임
- T?에서 T로의 변환은 명시적(explicit)임
int? x = 5; // 암시적 변환
int y = (int)x; // 명시적 변환 필요
// HasValue가 false일 때 Value 프로퍼티 접근 시 예외 발생
int? z = null;
int v = (int)z; // InvalidOperationException 발생
박싱과 언박싱
- Nullable<T>를 object로 변환할 때만 박싱이 발생함
- 이때 T? 값이 아닌 내부의 T 값만 박싱됨 (최적화)
- null 값은 null 참조로 박싱됨
int? n = 5;
object obj = n; // 5만 박싱됨
Console.WriteLine(obj.GetType()); // System.Int32
int? n2 = null;
object obj2 = n2; // null 참조가 됨
Console.WriteLine(obj2 == null); // True
// as 연산자로 nullable 타입의 언박싱이 가능
object o = "string";
int? x = o as int?;
Console.WriteLine(x.HasValue); // False
연산자 리프팅(Operator Lifting)
- Nullable<T> 구조체는 자체적으로 연산자를 정의하지 않음
- 대신 기본 타입의 연산자를 "들어올려서(lift)" 사용함
- 연산자 종류에 따라 서로 다른 null 처리 규칙이 적용됨
동등 연산자(== 와 !=)
- null과 null을 비교하면 true를 반환
- null과 non-null을 비교하면 false를 반환
- non-null 값끼리는 실제 값을 비교
int? x = 5;
int? y = null;
int? z = null;
Console.WriteLine(x == null); // False
Console.WriteLine(y == null); // True
Console.WriteLine(y == z); // True (null == null)
Console.WriteLine(x == 5); // True
비교 연산자(<, >, <=, >=)
- null을 포함한 비교는 항상 false를 반환
- 이는 "알 수 없는 값은 비교할 수 없다"는 개념을 반영
int? a = 5;
int? b = null;
Console.WriteLine(a > 3); // True
Console.WriteLine(b > 3); // False
Console.WriteLine(b <= 3); // False
산술 연산자(+, -, *, /, %, &, |, ^, <<, >>, +, ++, --, !, ~)
- 피연산자 중 하나라도 null이면 결과도 null
- SQL의 null 처리 방식과 유사함
int? x = 5;
int? y = null;
Console.WriteLine(x + 2); // 7
Console.WriteLine(x + y); // null
Console.WriteLine(y * 10); // null
Console.WriteLine(++x); // 6
Console.WriteLine(++y); // null
Nullable bool과 논리 연산자
- null은 "알 수 없는 값"으로 처리됨
- 논리표에 따라 결과가 확정되는 경우가 있음
bool? n = null;
bool? t = true;
bool? f = false;
Console.WriteLine(n | t); // True (알 수 없는 값 OR true = true)
Console.WriteLine(n & f); // False (알 수 없는 값 AND false = false)
Console.WriteLine(n | f); // null (알 수 없는 값 OR false = 알 수 없음)
Console.WriteLine(n & t); // null (알 수 없는 값 AND true = 알 수 없음)
bool? 타입의 특수한 & 및 | 연산
- bool? 타입은 SQL과 유사한 3가지 상태 논리를 구현함
- & 및 | 연산자는 null을 알 수 없는 값으로 처리함
bool? n = null;
bool? f = false;
bool? t = true;
Console.WriteLine(n | n); // null
Console.WriteLine(n | f); // null
Console.WriteLine(n | t); // True
Console.WriteLine(n & n); // null
Console.WriteLine(n & f); // False
Console.WriteLine(n & t); // null
실제 활용 시나리오
- 데이터베이스의 nullable 컬럼과 매핑할 때 유용함
- 존재하지 않는 값을 명확하게 표현할 수 있음
public class Customer
{
public decimal? AccountBalance; // null은 잔액 정보 없음을 의미
}
- Null 병합 연산자(??)와 함께 사용하면 효과적임
int? x = null;
int y = x ?? 5; // x가 null이면 5를 사용
Console.WriteLine(y); // 5
int? a = null, b = 1, c = 2;
Console.WriteLine(a ?? b ?? c); // 1 (첫 번째 non-null 값)
- GetValueOrDefault() 메서드 사용 예시
- 만약 x의 값이 들어있다면 x값 리턴, 아니면 default값을 내가 넘긴 값으로 리턴해랴 라는 메서드다.
int? x = null;
int y = x.GetValueOrDefault(); // 0 (int의 기본값)
int z = x.GetValueOrDefault(123); // 123 (지정한 기본값)
Nullable Value Types의 대안들
매직값(Magic Value) 패턴의 문제점
- 특정 값을 null 대신 사용하는 패턴
- 코딩에서 magic이 들어가는 형식은 되도록 피해야한다.
- 예: String.IndexOf는 찾지 못했을 때 -1을 반환함
int i = "Pink".IndexOf('b');
Console.WriteLine(i); // -1
- IndexOf : 인덱스를 반환해야하지만 인덱스가 없기에 임의로 지정한 값이 반환된다.
매직값 패턴의 단점
- 각 타입마다 다른 null 표현 방식을 사용해야 함
- Nullable Value Types는 모든 값 타입에 동일한 패턴 제공
- 적절한 매직값이 없는 경우가 있음 (예: decimal 잔고)
- 매직값 검사를 빼먹으면 잘못된 값이 조용히 전파될 수 있음
- 타입 시스템에서 null 가능성이 표현되지 않음
확장 메서드
개념과 기본 사용법
- 익스텐션 메서드는 기존 타입의 정의를 변경하지 않고 새로운 메서드를 추가할 수 있게 해주는 기능임
- 주요 특징:
- 정적(static) 클래스 내에 정적 메서드로 정의함
- 첫 번째 매개변수에 this 수식어를 붙임
- 첫 번째 매개변수의 타입이 확장하려는 대상 타입임
- 굳이 수정해야겠다면 ‘상속’을 받고 재정의를 하는게 좋다.
- 기본 예제:
// 사용 예시
Console.WriteLine("Perth".IsCapitalized()); // True
public static class StringHelper
{
public static bool IsCapitalized(this string s)
{
if (string.IsNullOrEmpty(s)) return false;
return char.IsUpper(s[0]);
}
}
- static 메서드의 첫번째 매개변수에 this 키워드가 온다면 해당 데이터형의 호출자가 매개변수로 온다고 알리는 것이다.
- 즉, 리터럴에서 호출하게 되면 해당 리터럴이 매개변수가 되는것이다.
- this가 첫번째 매개변수에 있다면 ‘확장 메서드’가 되는것이다.
컴파일러의 변환 과정
- 컴파일러는 익스텐션 메서드 호출을 일반 정적 메서드 호출로 변환함
- 예시:
// 익스텐션 메서드 호출
"Perth".IsCapitalized();
// 컴파일러가 변환한 형태
StringHelper.IsCapitalized("Perth");
메서드 체이닝(Method Chaining)
- 익스텐션 메서드는 메서드 체이닝을 구현하는데 유용함
- 즉, 메서드를 호출하면서 또 메서드를 호출하는 것이다.
- 예제:
public static class StringHelper
{
public static string Pluralize(this string s) => s + "s"; // 단순화를 위한 예시
public static string Capitalize(this string s) =>
string.IsNullOrEmpty(s) ? s : char.ToUpper(s[0]) + s.Substring(1); //substring : 문자열의 일부를 떼어 반환하는 것이다. / 1이면 문자열의 첫번쨰 문자 빼고 끝까지의 문자열을 반환하게 된다.
}
// 메서드 체이닝 사용
string result = "sausage".Pluralize().Capitalize();
Console.WriteLine(result); // 출력: Sausages
// 위 코드는 다음과 동일함
string result2 = StringHelper.Capitalize(StringHelper.Pluralize("sausage"));
Console.WriteLine(result2); // 출력: Sausages
- substring : 문자열의 일부를 떼어 반환하는 것이다.
- 1이면 문자열의 첫번쨰 문자 빼고 끝까지의 문자열을 반환하게 된다.
모호성과 해결 방법
네임스페이스 범위
- 익스텐션 메서드는 해당 정적 클래스의 네임스페이스가 using 지시문으로 포함된 경우에만 사용 가능함
using System;
namespace Utils
{
public static class StringHelper
{
public static bool IsCapitalized(this string s)
{
if (string.IsNullOrEmpty(s)) return false;
return char.IsUpper(s[0]);
}
}
}
namespace MyApp
{
using Utils; // 이 using 지시문이 필요함
class Test
{
static void Main()
{
Console.WriteLine("Perth".IsCapitalized()); // True
}
}
}
인스턴스 메서드와의 관계
- 동일한 시그니처를 가진 인스턴스 메서드가 있는 경우, 항상 인스턴스 메서드가 우선함
class Test
{
public void Foo(object x) { } // 인스턴스 메서드
}
static class Extensions
{
public static void Foo(this Test t, int x) { } // 익스텐션 메서드
}
// 사용 예시
Test t = new Test();
t.Foo(1); // Test 클래스의 인스턴스 메서드가 호출됨
- 익스텐션 메서드를 명시적으로 호출하려면 정적 메서드 구문을 사용해야 함:
Extensions.Foo(t, 1); // 익스텐션 메서드를 명시적으로 호출
익스텐션 메서드 간의 모호성
- 동일한 시그니처를 가진 두 익스텐션 메서드가 있는 경우, 더 구체적인 타입의 메서드가 우선됨
- 즉, 부모클래스보다 자식클래스의 데이터형의 메서드가 먼저 호출된다.
static class StringHelper
{
public static bool IsCapitalized(this string s) { ... }
}
static class ObjectHelper
{
public static bool IsCapitalized(this object s) { ... }
}
// string은 object보다 더 구체적인 타입이므로 StringHelper의 메서드가 호출됨
bool test = "Perth".IsCapitalized();