openlang

같은 인스턴스를 가리키는 인터페이스 참조와 객체 참조

오래된 Delphi 코드에서 제가 가장 자주 만나는 수명 실수는 한 인스턴스에 두 가지 소유 규칙을 섞는 데서 나옵니다. TInterfacedObject를 상속한 클래스는 인터페이스 참조 카운트가 0이 될 때 해제됩니다. 클래스 타입의 변수는 전혀 세지 않습니다. 같은 인스턴스를 두 종류의 참조로 함께 붙잡으면, 두 규칙은 그 객체가 언제 사라지는지에 대해 서로 다른 답을 냅니다.

program Greeter;
{$APPTYPE CONSOLE}
type
  IGreeter = interface
    procedure Hello;
  end;

  TGreeter = class(TInterfacedObject, IGreeter)
    procedure Hello;
  end;

procedure TGreeter.Hello;
begin
  WriteLn('hello');
end;

procedure Use(G: IGreeter);
begin
  G.Hello;
end;

var
  Obj: TGreeter;
begin
  Obj := TGreeter.Create; // count 0, a class reference is not counted
  Use(Obj);               // 1 inside Use, back to 0 on the way out
  Obj.Free;               // the object is already gone
end.

인스턴스는 카운트 0으로 시작합니다. 클래스 참조는 카운트를 건드리지 않기 때문입니다. Use에 넘기면 IGreeter로 변환되고, 호출하는 동안 카운트가 1이 되었다가 빠져나오면서 0으로 돌아가며, 0이 되는 순간 객체는 스스로를 파괴합니다. 그 뒤의 Obj.Free는 이미 해제된 메모리를 두 번째로 파괴하는 것입니다. 컴파일러는 이 모두를 받아들입니다.

제 규칙은 이렇습니다. 클래스가 TInterfacedObject로 인터페이스를 구현하는 순간부터, 인스턴스를 오래 붙잡는 참조는 모두 인터페이스 참조로 두고, 클래스 타입은 생성자를 부르는 줄에만 나타나게 합니다. 다른 분들도 같은 자리에 선을 긋는지 궁금합니다.

이어지는 대화 5

서로의 근거를 따라
  1. 고유 링크

    순서를 제대로 이해했는지 확인하고 싶습니다. 결국 시점이 핵심이기 때문입니다. 첫째, Create가 반환될 때 카운트는 0입니다. 둘째, Use에 들어가면서 1이 됩니다. 셋째, Use를 빠져나오면서 0으로 떨어지고, 소멸자는 호출자에게 제어가 돌아오기 전에 Use의 종료 처리 코드 안에서 바로 실행됩니다. 그러니 Obj.Free가 실행될 때는 파괴가 예약된 것이 아니라 이미 끝난 상태입니다.

    이것이 맞다면, 매개변수를 const G: IGreeter로 선언하면 둘째와 셋째 단계가 아예 사라집니까? 그렇다고 읽은 적이 있고, 인자가 방금 생성한 객체일 때는 다른 문제가 생긴다고도 읽었습니다.

  2. 고유 링크

    제가 볼 줄은 선언 쪽, Obj: TGreeter예요. 이걸 Obj: IGreeter로 바꾸면 예제가 Obj.Free에서 컴파일이 안 돼요. Free는 TObject의 메서드인데 인터페이스는 그걸 선언하지 않거든요. 말씀하신 규칙의 좋은 점이 바로 이거예요. 두 번째 파괴를 통과시키지 않고 컴파일러가 거절해요.

    다만 인터페이스가 클래스보다 정말 좁을 때만 그래요. 누가 편하라고 IGreeter에 Dispose 메서드를 넣으면, 변수 타입만 보고는 누가 무엇을 해제하는지 알 수 없게 돼요.

  3. 고유 링크

    선언 얘기에는 동의해요. 그런데 변수 타입으로는 안 보이는, 세지 않는 참조가 하나 더 있어요. 어딘가에서 Obj.Hello를 procedure of object 필드에 대입하면 그 메서드 포인터도 인스턴스를 붙잡는데, 카운트는 없어요. 인터페이스 참조가 0이 되면 객체는 사라지고, 이벤트는 나중에 그 자리로 그대로 호출돼요. 에디터에서는 전부 인터페이스 코드처럼 보이고요.

    C#에도 이벤트 핸들러가 구독자를 살려 두는 비슷한 문제가 있어요. 방향은 반대지만, 그래서 여기서도 찾아봤어요.

  4. 고유 링크

    이 규칙은 싼 확인 방법도 줘요. 클래스 타입이 Create 옆에만 나타난다면 : TGreeter로 텍스트 검색을 했을 때 아무것도 안 나와야 하고, 걸리는 게 있으면 그게 살펴볼 목록이에요.

  5. 고유 링크

    세 단계는 맞습니다. 파괴는 Use 안에서, 반환하기 직전의 마지막 동작으로 일어납니다. const G: IGreeter로 선언하면 호출되는 쪽이 카운트를 전혀 하지 않으므로 둘째와 셋째 단계가 사라지고, 위의 예제도 객체를 일찍 파괴하지 않게 됩니다.

    읽으셨다는 다른 문제는 Use(TGreeter.Create)에 관한 것입니다. const 매개변수라면 그 호출에서 새 인스턴스를 소유하는 것이 없습니다. 호출자 쪽에 카운트를 쥘 변수가 없기 때문입니다. 누수라고 설명한 글을 본 적이 있지만 모든 컴파일러 버전이 같게 처리하는지는 확신하지 못하므로, 저는 그 형태를 아예 쓰지 않습니다. 인스턴스를 먼저 인터페이스 변수에 넣고 거기서 넘깁니다.

    제 규칙에 예외 하나를 덧붙여야 하겠습니다. TComponent는 _AddRef와 _Release를 카운트 없이 구현하므로, 인터페이스로 접근한 컴포넌트도 여전히 Owner가 소유합니다. 이 규칙은 TInterfacedObject 후손에 적용되는 것이지, 인터페이스를 구현하는 모든 클래스에 적용되는 것은 아닙니다.

같은 질문의 다른 갈래

  1. 댕글링 포인터 경고는 사라졌고 포인터는 남았다

    래퍼를 통해 C++ 라이브러리에 이름을 넘기는데, 처음엔 한 줄로 CString::new(name).unwrap().as_ptr()라고 썼어요. rustc가 바로 임시 CString이 문장 끝에서 사라지니 포인터가 댕글링이…

    답글 1rustcpp
  2. getline은 실패해도 버퍼를 호출자에게 남긴다

    POSIX getline은 넘겨받은 포인터를 통해 버퍼를 할당하거나 키우며, 그 버퍼는 첫 호출부터 호출자의 것입니다. -1을 반환하는 호출도 예외가 아닙니다. 명세는 해제를 애플리케이션에 맡기고, Linux 매뉴얼 페이지는…

    답글 4c
  3. 같은 sink 호출인데 한 번은 복사, 한 번은 이동

    Nim 2는 복사냐 이동이냐를 호출 지점마다 정한다. proc 단위로 정하지 않는다. 같은 인자, 같은 proc. 첫 호출은 a가 아직 쓰이니 복사한다. 둘째 호출은 이동한다. 호출 지점에는 어느 쪽인지 아무 표시가 없다.…

    답글 2nim