Swift/개념 & 응용

[Swift] Equatable의 Synthesized Conformance 분석

유정주 2023. 8. 26. 15:19
반응형

서론

구조체와 클래스의 프로토콜 자동 준수 메커니즘 (w. ChatGPT)에서 Synthesized Conformance를 가볍게 다뤘습니다.

이번 포스팅에서는 SIL, CPP 코드를 분석해 Equatable의 Synthesized Conformance가 진행되는 과정을 알아보겠습니다.

 

 

Equatable

Equatable은 == static 메서드를 준수해야 하는 프로토콜로

== 연산자의 좌변과 우변을 비교해서 같은지 확인해 주는 역할입니다.

그리고 Synthesized Conformance를 지원하는 프로토콜 중 하나죠.

 

Equatable은 아래 상황에서 Synthesized Conformance를 지원합니다.

  • 구조체가 Equatable을 준수하는 저장 프로퍼티만 가지고 있는 경우
  • 열거형의 모든 케이스가 연관값을 가지지 않는 경우
  • 열거형의 모든 케이스가 Equatable을 준수하는 연관값을 가지는 경우

위 경우 직접 == 연산자를 구현하지 않아도 Swift 컴파일러가 자동으로 준수하게 해 줍니다.

 

 

SIL 살펴보기

그렇다면 어떤 과정을 거쳐서 == 연산자를 제공하는 걸까요?

 

SIL 생성

Swift Code -> Swift AST -> Raw Swift IL -> Canonical Swift IL -> LLVM IR -> Assembly -> Executable

Swift 빌드 단계 중 SIL(Swift Intermedate Representation)을 일부 살펴보겠습니다.

 

main.swift 파일에서 SIL 파일을 생성하려면 swiftc 컴파일러에 -emit-silgen 혹은 -emit-sil 옵션으로 컴파일해야 합니다.

-emit-silgen은 Raw SIL을, -emit-sil은 Canonical SIL을 생성합니다.

저는 최적화를 수행하지 않은 Raw SIL을 얻기 위해 -emit-silgen 옵션으로 컴파일했습니다.

 

//main.swift
import Foundation

struct Human: Equatable {
    var age: Int
}

이 코드의 Raw SIL을 생성해 봅시다.

Human 구조체의 저장 프로퍼티 타입인 String과 Int 모두 Equatable 하므로

컴파일러가 == 메서드를 자동으로 준수해줘야 합니다.

swiftc -emit-silgen main.swift | xcrun swift-demangle > main.sil

-emit-silgen 옵션으로 컴파일했고, 그 결과 Raw SIL 파일인 main.sil이 나왔습니다.

(전체 Raw SIL 내용은 너무 길어서 접은 글로 첨부합니다 ㅎㅎ;;)

 

구조체 정의부

9~13라인에 있는 구조체 정의부터 살펴보겠습니다.

struct Human : Equatable {
  @_hasStorage var age: Int { get set }
  @_implements(Equatable, ==(_:_:)) static func __derived_struct_equals(_ a: Human, _ b: Human) -> Bool
  init(age: Int)
}

Equatable의 준수 메서드인 ==을 자동으로 생성한 모습입니다.

__derived_struct_equals(_ a: Human, _ b: Human) -> Bool

11라인의 __derived_struct_equals가 Human 타입 a, b를 받아서 Bool 타입을 반환하는 걸 볼 수 있습니다.

 

__derived_struct_equals

66~103 라인에서는 static Human.__derived_struct_equals 동작을 볼 수 있는데요.

 

main.Human.__derived_struct_equals(main.Human, main.Human)는 두 개의 Human 인스턴스를 비교하는 함수로

%0, %1, %2는 각각 첫 번째 Human 인스턴스, 두 번째 Human 인스턴스, 그리고 Human.Type 메타타입을 나타냅니다.

 

코드의 동작 과정은 아래와 같습니다.

  1. 두 Human 인스턴스의 age 속성을 추출하여 %7와 %8에 저장
  2. Swift.Int의 == 연산자를 호출하여 두 age 값을 비교하고, 결과를 %10에 저장
  3. %10에서 Bool 값을 추출하여 %11에 저장
  4. %11 값에 따라 분기를 수행. 참이면 bb1 블록으로, 거짓이면 bb2 블록 수행
  5. bb1 블록에서는 -1 값을 가지는 Bool을 생성하여 리턴
  6. bb2 블록에서는 0 값을 가지는 Bool을 생성하여 리턴
  7. bb3 블록에서는 이전 블록에서 생성한 Bool 값을 받아 리턴

 

3, 4번 과정부터 하나씩 봐봅시다.

먼저 3, 4번 과정입니다.

%7 = struct_extract %0 : $Human, #Human.age     // user: %10
%8 = struct_extract %1 : $Human, #Human.age     // user: %10
// function_ref static Int.== infix(_:_:)
%9 = function_ref @static Swift.Int.== infix(Swift.Int, Swift.Int) -> Swift.Bool : $@convention(method) (Int, Int, @thin Int.Type) -> Bool // user: %10

$0과 $1은 Human 인스턴스이고, age 프로퍼티의 값을 추출해서 Int 타입의 == 연산자로 비교합니다.

 

5, 6번에서는 위 결과를 이용해 결과값을 생성합니다.

%10 = apply %9(%7, %8, %6) : $@convention(method) (Int, Int, @thin Int.Type) -> Bool // user: %11
%11 = struct_extract %10 : $Bool, #Bool._value  // user: %12
cond_br %11, bb1, bb2                           // id: %12

$0과 $1이 같으면 84라인의 bb1을, 다르면 92라인의 bb2를 수행합니다.

(cond_br은 조건 분기(Conditional Branch) 명령어입니다. 주어진 조건에 따라 프로그램의 흐름을 다른 두 블록 중 하나로 분기시키는 역할입니다.)

 

bb1:                                              // Preds: bb0
  ...
  br bb3(%16 : $Bool)                             // id: %17

bb2:                                              // Preds: bb0
  ...
  br bb3(%21 : $Bool)                             // id: %22
bb3(%23 : $Bool):                                 // Preds: bb1 bb2
  return %23 : $Bool                              // id: %24

bb1과 bb2에서는 bb3을 호출하고 bb3은 Bool 타입 결과값을 반환합니다.
(br은 branch 명령어입니다. 분기를 실행하는 기능입니다.)

 

 

SIL 분석 결론

SIL 분석을 통해 Equatable은 인스턴스의 모든 값을 하나하나 비교한다는 것을 배웠습니다.

왜 모든 저장 프로퍼티, 모든 연관값이 Equatable 해야 하는지도 알 거 같네요!

 

 

Synthesized Conformance 가능 여부 판단

그렇다면 컴파일러는 어떻게 Synthesized Conformance가 가능한지 판단할까요?

이 내용은 DerivedConformanceEquatableHashable.cpp에서 확인할 수 있었습니다.

/// Common preconditions for Equatable and Hashable.
static bool canDeriveConformance(DeclContext *DC,
                                 NominalTypeDecl *target,
                                 ProtocolDecl *protocol) {
  // The type must be an enum or a struct.
  if (auto enumDecl = dyn_cast<EnumDecl>(target)) {
    // The cases must not have associated values, or all associated values must
    // conform to the protocol.
    return DerivedConformance::allAssociatedValuesConformToProtocol(DC, enumDecl, protocol);
  }

  if (auto structDecl = dyn_cast<StructDecl>(target)) {
    // All stored properties of the struct must conform to the protocol. If
    // there are no stored properties, we will vaccously return true.
    if (!DerivedConformance::storedPropertiesNotConformingToProtocol(
               DC, structDecl, protocol).empty())
      return false;

    // If the struct is actor-isolated, we cannot derive Equatable/Hashable
    // conformance if any of the stored properties are mutable.
    if (memberwiseAccessorsRequireActorIsolation(structDecl))
      return false;

    return true;
  }

  return false;
}

canDeriveConformance 함수에서는 모든 저장 프로퍼티, 모든 연관값이 "프로토콜"을 준수하는지 확인합니다.

 

여기서 "프로토콜"은 파라미터로 전달받는 타입에 따라 달라집니다.

만약 protocol로 Equatable 정보를 받으면 모든 저장 프로퍼티, 모든 연관값이 Equatable한지 판단하는 것입니다.

canDeriveConformance를 사용하는 canDeriveEquatable 함수에 대해 알아보기 전에,

canDeriveConformance를 먼저 분석해 보겠습니다.

 

열거형 판단

코드 순서대로 열거형의 Conformance 판단을 먼저 보겠습니다.

위 전체 코드에서

// The type must be an enum or a struct.
if (auto enumDecl = dyn_cast<EnumDecl>(target)) {
    // The cases must not have associated values, or all associated values must
    // conform to the protocol.
    return DerivedConformance::allAssociatedValuesConformToProtocol(DC, enumDecl, protocol);
}

이 부분이 열거형 관련 코드입니다.

Conformance의 조건 중

  • 열거형의 모든 케이스가 연관값을 가지지 않는 경우
  • 열거형의 모든 케이스가 "프로토콜"을 준수하는 연관값을 가지는 경우

를 구현하는 코드입니다.

 

dyn_cast는 Swift의 as? 와 같은 기능을 하는 동적 캐스팅 문법이며

파라미터로 들어온 타입이 Enum이면 if문 내부 코드를 수행한다는 코드입니다.

 

if문 내부에서는 allAssociatedValuesConformToProtocol 결과를 반환합니다.

allAssociatedValuesConformToProtocol 함수는 모든 연관값이 프로토콜을 준수하고 있는지 확인하는 역할이고,

코드는 여기에서 볼 수 있습니다.

 

제가 참고한 블로그에서는 연관값의 유무도 체크했는데 allAssociatedValuesConformToProtocol 함수로 통합되었네요.

 

구조체 판단

다음은 구조체를 판단합니다.

if (auto structDecl = dyn_cast<StructDecl>(target)) {
    // All stored properties of the struct must conform to the protocol. If
    // there are no stored properties, we will vaccously return true.
    if (!DerivedConformance::storedPropertiesNotConformingToProtocol(
               DC, structDecl, protocol).empty())
      return false;

    // If the struct is actor-isolated, we cannot derive Equatable/Hashable
    // conformance if any of the stored properties are mutable.
    if (memberwiseAccessorsRequireActorIsolation(structDecl))
      return false;

    return true;
}

구조체의 조건인

  • 구조체가 "프로토콜"을 준수하는 저장 프로퍼티만 가지고 있는 경우

를 구현한 코드입니다.

 

storedPropertiesNotConformingToProtocol에서는 프로토콜을 준수하지 않는 프로퍼티 리스트를 반환합니다. (코드는 여기요!)

프로퍼티 리스트가 empty라면 다음 코드를 수행하고,

empty가 아니라면 false를 반환해서 Conformance를 지원하지 않는다고 알립니다.

 

empty가 아닐 때는 actor-isolated 여부도 판단하고 있습니다.

제가 참고한 블로그에 이내용은 없었는데 actor가 들어오면서 추가되었나 봅니다.

 

이외 타입은 불가

return false;

열거형과 구조체가 아니라면 false를 반환해서

열거형, 구조체가 아닌 타입은 Conformance를 지원하지 않습니다.

 

 

Equatable Conformance 체크

Equatable Conformance 체크는 canDeriveEquatable 함수에서 수행됩니다.

canDeriveEquatable 함수는 지금까지 봤던 canDeriveConformance에 Equatable을 전달해서 결괏값을 반환하는 역할입니다.

bool DerivedConformance::canDeriveEquatable(DeclContext *DC,
                                        NominalTypeDecl *type) {
    ASTContext &ctx = DC->getASTContext();
    auto equatableProto = ctx.getProtocol(KnownProtocolKind::Equatable);
    if (!equatableProto) return false;
    return canDeriveConformance(DC, type, equatableProto);
}

DC로부터 ASTContext를 얻어서 Equatable 프로토콜 정보를 가져옵니다.

만약 Equatable 프로토콜이 존재하지 않으면 바로 false를 반환해서 함수를 종료하고,

Equatable이 존재한다면 canDeriveConformace 함수를 실행시킵니다.

이때 canDeriveConformace에 Equatable 정보를 전달해서 모든 연관값 혹은 저장 프로퍼티가 Equatable을 준수하는지 체크합니다.

 

여기까지가 컴파일러가 Equatable을 판단하는 방법이었습니다.

 

 

Generic 구조체 Equatable 사용법

Generic 타입을 사용하는 구조체에서 Equatable을 제대로 쓰는 방법을 알아봅시다.

지금까지 알아봤던 과정과 원리를 생각하면 아! 하고 이해가 될 거예요.

 

먼저 잘못된 방법을 보겠습니다.

struct Bad<T>: Equatable {
    var x: T
}

위 코드는 Synthesized Conformance가 가능할까요?

정답은 "아니요"입니다.

왜냐하면 T가 Equatable 하지 않기 때문입니다.

 

실제로 코드를 작성해보면 컴파일 에러가 발생합니다.

 

 

이제 제대로 쓴 코드를 봅시다.

struct Good<T> {
    var x: T
}
extension Good: Equatable where T: Equatable {} // synthesis works, T is Equatable

where 절을 이용해 T가 Equatable일 때만 Equatable 프로토콜을 채택하게 했습니다.

이러면 T가 Equatable 하기 때문에 Synthesized Conformance가 가능합니다.

 

 

마무리

이번 포스팅에서는 Equatable을 자동 준수하는 동작에 대해 알아봤습니다.

프로토콜을 자동으로 준수해주는 기능은 정말 편리하고 간단한데,

내부적으로 이런 복잡한 동작이 수행되고 있는줄은 처음 알았습니다.

 

구조체와 클래스의 프로토콜 자동 준수 메커니즘 (w. ChatGPT)에서 알아본 내용도 더 확실하게 이해가 되었네요.

특히 Equatable의 Synthesized Conformance 조건 세 가지가 왜 필요한지 알 수 있어 좋았습니다.

 

포스팅을 쓰기 전에는 "너무 과한 학습인가?"라는 생각이 들었는데

막상 해보니 재밌었네요. (백수라 시간이 철철 넘쳐서 그런걸지도 ㅎㅎ)

 

감사합니다.

 

참고

https://swiftunboxed.com/internals/synthesized-equatable-conformance/

https://github.com/apple/swift-evolution/blob/main/proposals/0185-synthesize-equatable-hashable.md#synthesis-for-class-types-and-tuples

https://github.com/apple/swift/blob/main/lib/Sema/DerivedConformanceEquatableHashable.cpp


아직은 초보 개발자입니다.

더 효율적인 코드 훈수 환영합니다!

공감 댓글 부탁드립니다.

 

 

반응형