Disposable객체의 Initialize중 예외를 어떻게 핸들링해야하나요?

using System;

using var t = new Test();
using var tt = new Test
{
    Member = default(int?) ?? throw new() // dispose 호출 불가!
};

ref struct Test
{
    public Test()
    {
        Console.WriteLine("create!");
        throw new(); // dispose 호출 불가!
    }
    public void Dispose() => Console.WriteLine("dispose!");
    public int Member
    {
        get => field;
        init => throw new(); // dispose 호출 불가!
    }
}

일반적으로 IDisposable이 구현된 객체는, 생성되고 사용한 이후 반드시 Dispose()를 호출하여 폐기할 것을 요구합니다.

하지만 객체가 생성까지는 되었으나 아직 변수에 할당되지 않은 시점에서 예외가 발생한다면, 어차피 아무 접근도 불가능하니 Dispose()를 호출하는 것 또한 불가능 하게 됩니다.

생성자나 init { .. } 같이 해당 객체의 내부라면 당연히 예외가 발생하지 않도록 통제할 책임이 객체에게 있는것이지만, 외부 initializer 영역에서의 예외발생은 사용자에게 너무 손쉽게 해소불가능한 누수 유발 가능성을 제공합니다.

 using System;
 using System.Collections.Generic;

 MyList<int>? list = null;
 try
 {
-    list = new();
-    list.Add(default(int?) ?? throw new());
+    list = [default(int?) ?? throw new()]; // IDE0028
 }
 finally
 {
     list?.Dispose();
 }
 class MyList<T> : List<T>, IDisposable
 {
     public MyList() => Console.WriteLine("create!");
     public void Dispose() => Console.WriteLine("dispose!");
 }

위 같은 코드에서는 dispose가 적절히 기능하던 기존 코드를 소스분석기의 권장사항을 따르는경우 dispose가 발생하지 않게 부적절하게 마이그레이션 되는걸로 보입니다.

객체가 생성은 됬으나, 추가 초기화 과정에서의 예외로 인해 적절히 Dispose하지 못하고 누수가 발생한다는 최악의 경우를 피하기 위해선 보통 어떻게 하나요?

  • 초기화 블럭에서 예외발생이 안생기도록 사용자가 알아서 유의토록 한다.
  • Disposable객체 생성에서 Initializer사용을 금지하는 분석기를 도입한다.
  • Disposable객체 선언부터 Initializer를 활용할수 없도록 디자인한다.
  • Disposable객체의 설계를 Open() Connect() 등 정상적으로 변수에 할당된 이후에 가능한 호출을 통해 활성화하기 전까지는 굳이 폐기하지 않아도 폐기된것과 동일한 상태를 가지도록 한다.
2개의 좋아요

불완전한 상태의 객체를 생성하지 말라

는 원칙은 언어가 아니라 객체 설계자의 책임입니다.

이 책임에는 파라미터의 검증 뿐만 아니라, 생성 및 초기화에 예외를 막는 책임도 포함된다고 생각합니다.

ref struct Test : IDisposable
{
    public Test() => Member = 0;

    public void Dispose() => Console.WriteLine("dispose!");

    public int Member
    {
        get;
        init => field = value < 0 ? 0 : value;
    }
}

참고로, 아래 코드는 널병합 연산자 평가 중, 다시 말하면 MyList 객체의 생성 이전에 예외가 발생하여, 질문의 맥락과는 동떨어진 것 같습니다.

  var n = default(int?) ?? throw new(); // 예외 발생
  list = [n];  // 생성되지 않음. 

개인적 경험을 비춰 보면, 도메인 규칙은 도메인 객체의 생성과 초기화 시점부터 고려되어야 해서 Foo/Bar 수준의 클래스 코드는 거의 적지 않는 것 같습니다.

1개의 좋아요

네, 제가 주목하는부분은 객체 설계자가 책임질 수 없는 영역인 initializer식을 허용하는 이상, 불완전한 객체 초기화는 항상 예견가능하다는것입니다.

초기화식 바깥으로 옮긴다던지, 초기화식에 별다른 코드를 적지않는다던지의 행위는 객체 사용자가 눈치껏 문제를 적절히 수습한것에 불과하며, IDE0028분석기는 심지어 예외발생가능성이 초기화식 내부에 있는지 외부에있는지 염두하지않고 명백하게 호환성이 깨지는 마이그레이션 동작을 수행했다는걸 보여드린겁니다. 널병합은 그냥 예외를 터뜨리는 간단한 식중에 고른거구요.

실질적으로 모든 객체는 불완전한 초기화로 인한 할당실패 및 폐기불가 상태에 빠질 가능성을 내포하고있을것으로 간주해야 하는것 아닌가? 그렇다면 그 필연적인 경우를 맞이했을 때 어떻게 대응해야 하는가? 가 제 궁금증입니다.

언어가 제공하는 도구는 강제 사항이 아닙니다.

도구가 규칙의 강제를 담보하지 않는다면 굳이 사용할 이유가 없습니다.
정확히 말하자면, 규칙을 강제하도록 도구를 사용해야 합니다.

그것을 게을리할 때, 아래와 같은 걱정을 피할 길이 없을 것입니다.
(이런 걱정은 AI 에게도 전파될 것입니다.)

게을리한다는 의미는:

  • 큰 고민 없는 자동 속성의 남발,
  • 기본 자료형 집착
  • 규칙 자체에 대한 설계 미숙

등등, 대부분 덜 성숙한 OOP 설계의 예시들입니다.

예를 들어 아래 객체의 클라이언트 코드는 Lower, Upper 를 항상 검증해야 합니다.

record ValueRange(int Lower, int Upper);

이 검증 코드는 온 사방 천지에 토끼굴로 퍼질 것입니다.

이를 효율적으로 막는 방법은 신중한 설계에 있습니다.

record ValueRange(int Lower, int Upper)
{
  public int Lower { get; init; } = Lower < 0 ? 0 
      : Lower > Upper ? Upper : Lower;

  public int Upper { get; init; } = Upper < 0 ? 0 
      : Lower > Upper ? Lower : Upper;
}

또는

record ValueRange
{
  private ValueRange(int lower, int upper) => 
     (Lower, Upper) = (lower, upper);

  public int Lower { get; init; } = Lower < 0 ? 0 
      : Lower > Upper ? Upper : Lower;

  public int Upper { get; init; } = Upper < 0 ? 0 
      : Lower > Upper ? Lower : Upper;
  
  public static ValueRange Create(int lower, int upper)
  {
    // Sanitizing 도 규칙
    var l = lower < 0 ? 0 : lower;
    var u = upper < 0 ? 0 : upper;
    if (l <= u) return new(l, u);

    // 기타 규칙 위배를 어떻게 처리할 것인가?
    // 1. 생성 전 예외 발생
    throw new();
    // 2. 최대한 생성 시도 (규칙의 재정립)
    // return new(l, l);
    // return new(u, u);
    // return new(u, l);
  }
}

마지막으로, 예제 코드에서 매개 변수 없는 생성자를 강제하는 struct를 사용하셨는데, 그 생성자가 설계자의 통제를 받지 않는 개구멍으로 작용할 확률이 높습니다.

이 구멍난 형식을 쓸지 말지 결정하는 것도 설계자의 선택 중 하나일 것입니다.

성능을 위헤서라면 참조 자료형이라도 가급적 GEN0 힙에 할당되도록 코드를 적는 방법도 있습니다.

1개의 좋아요

저도 이 의견에 한 표입니다.

생성자에서 의도적으로 예외를 발생시키거나, 객체 외부의 initializer 구문에서 직접 예외를 발생시키는 패턴 자체는 지양하는 편이 좋다고 봅니다.

initializer를 평가하는 과정에서 예외가 발생할 수 있다는 사실 자체는 문제가 되지 않는다고 생각합니다. 예를 들어 "생성자 호출 시 인자에 예외 발생을 넣는다"는 것은 그렇게 할지 말지, 어떻게 처리할지는 사용자의 몫입니다. initializer 평가 중 예외가 발생하는 것 자체는 일반적인 생성 실패의 일부입니다.

보여주신 사례처럼 using 선언과 결합했을 때 생성된 객체의 Dispose가 호출되지 않을 수 있다는 점이 문제로 보입니다.

따라서 최소한 다음과 같은 규칙은 갖고 가는 것이 적절하다고 생각합니다.

using 선언의 initializer에서는 예외를 직접 발생시키지 않는다.

혹은

using 선언의 initializer에는 예외가 발생할 수 있는 연산을 두지 않는다.

1개의 좋아요

글을 다시 읽어 보니 제가 뭔가 논지를 벗어났었던 것 같습니다.

그럼에도 결론은 객체는 올바른 상태로 생성되도록 설계하는 게 맞고, 말씀하신 논지는 아래의 코딩 원칙을 지키는 게 좋을 것 같습니다.

// 위험한 처리는 생성/초기화 단계에서 배제 시킨다.
var member = default(int?) ?? throw new();

using var t = new Test();
using var tt = new Test
{
    Member = member;
};

이런 패턴을 팀원과 공유하고 싶다면 로슬린 분석기를 정의하는 것도 방법일 것 같습니다.

IDisposable 을 구현하는 객체의 초기화 시에 init 이나 required 가 붙은 속성의 우변 항에 expression 이 들어가면 경고. (리터럴, 상수, 변수 는 가능)