Notice
Recent Posts
Recent Comments
Link
관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] [오픈소스 까보기] apple foundation - AttributedString (#Guts, #Run) 본문

오픈소스 까보기

[iOS - swift] [오픈소스 까보기] apple foundation - AttributedString (#Guts, #Run)

jake-kim 2023. 8. 23. 16:11

* foundation의 AttributedString.swift Github 주소 참고

AttributedString.swift 구현부

* NSAttributedString 개념은 이전 포스팅 글 참고

  • AttributedString은 Sendable을 따르고 있으므로 동시성 처리에 안전
    • 내부적으로 _guts를 가지고 있는데 guts는 core라는 의미
// AttributedString.swift

@dynamicMemberLookup
@available(macOS 12, iOS 15, tvOS 15, watchOS 8, *)
public struct AttributedString : Sendable {
    internal var _guts: Guts

    internal init(_ guts: Guts) {
        _guts = guts
    }
}
  • AttributedString+Guts.swift에서 Guts를 정의
extension AttributedString {
    internal final class Guts : @unchecked Sendable {
        typealias Index = AttributedString.Index
        typealias Runs = AttributedString.Runs
        typealias AttributeMergePolicy = AttributedString.AttributeMergePolicy
        typealias AttributeRunBoundaries = AttributedString.AttributeRunBoundaries
        typealias _InternalRun = AttributedString._InternalRun
        typealias _InternalRuns = AttributedString._InternalRuns
        typealias _AttributeValue = AttributedString._AttributeValue
        typealias _AttributeStorage = AttributedString._AttributeStorage

        var string: BigString
        var runs: _InternalRuns

        // Note: the caller is responsible for performing attribute fix-ups if needed based on the source of the runs
        init(string: BigString, runs: _InternalRuns) {
            precondition(string.isEmpty == runs.isEmpty, "An empty attributed string should not contain any runs")
            self.string = string
            self.runs = runs
        }

        // Note: the caller is responsible for performing attribute fix-ups if needed based on the source of the runs
        convenience init(string: String, runs: _InternalRuns) {
            self.init(string: BigString(string), runs: runs)
        }

        convenience init() {
            self.init(string: BigString(), runs: _InternalRuns())
        }
    }
}
  • Guts를 잠깐 살펴보면 내부적으로 startIndex, endIndex 이런 값들을 가져오는 core 역할을 수행
public var startIndex : Index {
    Index(_guts.string.startIndex)
}

public var endIndex : Index {
    Index(_guts.string.endIndex)
}

@preconcurrency
public subscript<K: AttributedStringKey>(_: K.Type) -> K.Value? where K.Value : Sendable {
    get {
        _guts.getUniformValue(in: _stringBounds, key: K.self)?.rawValue(as: K.self)
    }
    set {
        ensureUniqueReference()
        if let v = newValue {
            _guts.setAttributeValue(v, forKey: K.self, in: _stringBounds)
        } else {
            _guts.removeAttributeValue(forKey: K.self, in: _stringBounds)
        }
    }
}
  • 아래쪽 코드를 보면 public init()으로 생성자를 정의하고 여기서 내부적으로 구현한 Guts()값을 넣어주어서 사용
    • Guts()는 내부적으로 가지고 있고 여기서 문자열 처리에 관한 코어 기능을 담당
    • AttributedString 자체는 인터페이스만 있고, 이 안에 코어 기능을 담당하는 Guts을 두어서 기능과 인터페이스를 분리하여 개발된 형태
// MARK: Initialization
@available(macOS 12, iOS 15, tvOS 15, watchOS 8, *)
extension AttributedString {
    public init() {
        self._guts = Guts()
    }

    internal init(_ s: some AttributedStringProtocol) {
        if let s = _specializingCast(s, to: AttributedString.self) {
            self = s
        } else if let s = _specializingCast(s, to: AttributedSubstring.self) {
            self = AttributedString(s)
        } else {
            // !!!: We don't expect or want this to happen.
            let substring = AttributedSubstring(s.__guts, in: s._stringBounds)
            self = AttributedString(substring)
        }
    }

    ...
}

코어 기능을 담당하는 Guts 살펴보기

  • AttributedString+Guts.swift 파일에 정의되어 있는 Guts
extension AttributedString {
    internal final class Guts : @unchecked Sendable {
        typealias Index = AttributedString.Index
        typealias Runs = AttributedString.Runs
        typealias AttributeMergePolicy = AttributedString.AttributeMergePolicy
		...

        var string: BigString
        var runs: _InternalRuns

        // Note: the caller is responsible for performing attribute fix-ups if needed based on the source of the runs
        init(string: BigString, runs: _InternalRuns) {
            precondition(string.isEmpty == runs.isEmpty, "An empty attributed string should not contain any runs")
            self.string = string
            self.runs = runs
        }

        // Note: the caller is responsible for performing attribute fix-ups if needed based on the source of the runs
        convenience init(string: String, runs: _InternalRuns) {
            self.init(string: BigString(string), runs: runs)
        }

        convenience init() {
            self.init(string: BigString(), runs: _InternalRuns())
        }
    }
}
  • 애플의 코딩 스타일을 보면 extension으로 기능별로 분리를 한 상태

(첫번째 extension - 타입 정의, 초기화 코드)

extension AttributedString {
    internal final class Guts : @unchecked Sendable {
        typealias Index = AttributedString.Index
        typealias Runs = AttributedString.Runs
		...

        // Note: the caller is responsible for performing attribute fix-ups if needed based on the source of the runs
        init(string: BigString, runs: _InternalRuns) {
            precondition(string.isEmpty == runs.isEmpty, "An empty attributed string should not contain any runs")
            self.string = string
            self.runs = runs
        }

        ...
    }
}

(두번째 extension - copy와 관련된 함수)

  • __consuming 키워드는 성능 최적화를 위해 사용한 것 - 구체적인 개념은 이전 포스팅 글 참고
extension AttributedString.Guts {
    __consuming func copy() -> AttributedString.Guts {
        AttributedString.Guts(string: self.string, runs: self.runs)
    }

    __consuming func copy(in range: Range<BigString.Index>) -> AttributedString.Guts {
        let string = BigString(self.string.unicodeScalars[range])
        let runs = self.runs.extract(utf8Offsets: range._utf8OffsetRange)
        let copy = AttributedString.Guts(string: string, runs: runs)
        // FIXME: Extracting a slice should not invalidate anything but .textChanged attribute runs on the edges
        if range.lowerBound != string.startIndex || range.upperBound != string.endIndex {
            var utf8Range = copy.stringBounds._utf8OffsetRange
            utf8Range = copy.enforceAttributeConstraintsBeforeMutation(to: utf8Range)
            copy.enforceAttributeConstraintsAfterMutation(in: utf8Range, type: .attributesAndCharacters)
        }
        return copy
    }
}

runs 인스턴스 분석

  • 첫번째 copy() 메소드는 Guts인스턴스를 리턴하는데, 여기서 string, runs 프로퍼티를 받아서 리턴
extension AttributedString.Guts {
    __consuming func copy() -> AttributedString.Guts {
        AttributedString.Guts(string: self.string, runs: self.runs)
    }
}
  • 여기서 Guts가 가지고 있는 runs 값이란?
    • InternalRuns라는 구조체
    • 안에 Rope를 가지고 있는 상태
    • 이 rope를 단순히 wrapping하고 있는 구조체이며, extension으로 각 필요한 기능들을 확장해나가는 구현
    • (예측할 수 있는 것은 Rope라는 곳은 여러곳에서 사용되기 때문에 InternalRuns안에서는 wrapping해서 사용하여 Rope의 변화를 안주고 사용할 수 있도록 구현된 것)
typealias _InternalRuns = AttributedString._InternalRuns
var runs: _InternalRuns

... 

/// An internal convenience wrapper around `Rope<_InternalRun>`, giving it functionality
/// that's specific to attributed strings.
struct _InternalRuns: Sendable {
    typealias _InternalRun = AttributedString._InternalRun
    typealias Storage = Rope<_InternalRun>

    var _rope: Rope<_InternalRun>

    init() {
        self._rope = Rope()
    }

    init(_ rope: Storage) {
        self._rope = rope
    }

    init(_ runs: some Sequence<_InternalRun>) {
        self._rope = Rope(runs)
    }
}

extension AttributedString._InternalRuns {

...

}
  • Rope란?
    • 데이터 조회를 빠르게하기 위해서 정의된 ordered - tree 자료구조
    • String에 관한 데이터를 처리할 때 사용하며, 특정 데이터를 조회할 때 O(logN)으로 빠르게 탐색할 수 있도록 사용한 것
/// An ordered data structure of `Element` values that organizes itself into a tree.
/// The rope is augmented by the commutative group specified by `Element.Summary`, enabling
/// quick lookup operations.
public struct Rope<Element: RopeElement> {
  @usableFromInline
  internal var _root: _Node?

  @usableFromInline
  internal var _version: _RopeVersion

  @inlinable
  public init() {
    self._root = nil
    self._version = _RopeVersion()
  }

  @inlinable
  internal init(root: _Node?) {
    self._root = root
    self._version = _RopeVersion()
  }

  @inlinable
  internal var root: _Node {
    @inline(__always) get { _root.unsafelyUnwrapped }
    @inline(__always) _modify { yield &_root! }
  }

  @inlinable
  public init(_ value: Element) {
    self._root = .createLeaf(_Item(value))
    self._version = _RopeVersion()
  }
}
  • 즉, Guts안에서 가지고 있는 인스턴스인 runs는 데이터들을 tree 자료구조로 정의하고 있으며 어떤 데이터를 가지고 있는지 O(logN)으로 빠르게 조회 가능하고, 이 run을 가지고 속성값들을 가져와서 처리하는 형태
    • run 단어의 의미: 연속된 데이터 블록을 의미
// Guts에서 runs를 가지고 있는 형태
var runs: _InternalRuns
  • run을 가져와서 처리
    • run 쓰임1: run에서 attributes, range값을 가져와서 hash function에 사용
    • run 쓰임2: run에서 가지고 있는 utf8OffsetRange를 가져와서 uniformValue로 반환할 때 사용
    • run 쓰임3: run이 가지고 있는 Attributes들을 가져와서 AttributedStorage로 반환
// run 쓰임1
internal func characterwiseHash(
    in range: Range<BigString.Index>,
    into hasher: inout Hasher
) {
    let runs = AttributedString.Runs(self, in: range) // <- run 사용
    hasher.combine(runs.count) // Hash discriminator

    for run in runs {
        hasher.combine(run._attributes)
        hasher.combine(string[run._range])
    }
}

// run 쓰임2
func getUniformValue<K: AttributedStringKey>(
    in range: Range<BigString.Index>, key: K.Type
) -> _AttributeValue? {
    var result: _AttributeValue? = nil
    for run in self.runs(in: range._utf8OffsetRange) {
        guard let value = run.attributes[K.name] else {
            return nil
        }
        if let previous = result, value != previous {
            return nil
        }
        result = value
    }
    return result
}

// run 쓰임3
func getUniformValues(in range: Range<BigString.Index>) -> _AttributeStorage {
    var attributes = _AttributeStorage()
    var first = true
    for run in self.runs(in: range._utf8OffsetRange) {
        guard !first else {
            attributes = run.attributes
            first = false
            continue
        }
        attributes = attributes.filterWithoutInvalidatingDependents {
            guard let value = run.attributes[$0.key] else { return false }
            return value == $0.value
        }
        if attributes.isEmpty {
            break
        }
    }
    return attributes
}

정리

  • 애플이 구현한 foundation의 attributedString 구조는 안에서 Guts라는 인스턴스를 따로 두어서 기능을 담당하는 인스턴스를 별도로 분리 (캡슐화)
  • 정의, 초기화, 주요 기능 단위 별로 extension으로 나누어서 구현
  • Guts안에서는 연속된 데이터 블록을 의미하는 Run이라는 것을 사용하고 있는데, ordered 트리 구조이며 이것을 통해 문자열의 Attributes, range 값등을 저장하여 필요할때 가져와서 처리하는 형태
  • 보완하면 좋을점: Guts를 인스턴스 그대로 사용하고 있는데, 만약 Guts를 프로토콜로 설계했으면 testable한 코드를 가질 수 있기 때문에 더욱 AttributedString을 테스트하기 좋고 유지보수하기 쉬울 것

* 참고

https://github.com/apple/swift-foundation/blob/c7f4de5590d6040db6436ac67d12da9ffab510f0/Sources/FoundationEssentials/AttributedString/AttributedString.swift#L4

Comments