感受 Swift 的魅力:一键导出 DataFrame

发表于

几个月前,我和几位开发者聚餐闲聊。席间,我是唯一专注于 Swift 的开发者,其他同伴则拥有丰富的 Python、Java、JavaScript 等多语言经验。出乎意料的是,他们对 Swift 的评价并不高,认为 Swift 语法糖过多,很多特性看似只是为了炫技,实用性有限。他们好奇地问我:“如果不是因为开发苹果平台的需求,你还会选择使用 Swift 吗?”

我坚定地说:当然会,我是真的喜欢 Swift!

随着对 Swift 理解的不断深入,它的魅力在我眼中愈发耀眼。Swift 让我能以清晰、准确、安全、现代且优雅的方式表达编程思想。然而,当我想细数这些闪光点时,却一时语塞。直到几天前,我在项目中实现的一个仅百余行的功能模块,恰好将一些吸引我的特性完美串联。今天,就让我用这段代码,与你一同领略 Swift 的独特魅力。

完整代码可在 Gist 查看

需求背景

我当前的项目需要将 Core Data 中的对象数组转换成 TabularData 框架的 DataFrame,以实现两个核心目标:

  1. 数据导出:将数据轻松保存为 JSON/CSV 文件;
  2. 数据分析:借助 TabularData 强大的 API,高效实现筛选、聚合、统计等复杂操作。

TabularData 是苹果提供的数据操纵与分析库,可视为 Pandas 的 Swift 实现版本。它是一个功能强大却常被忽视的官方库。

表面上看,这只是将“行优先”数据结构转换为“列优先”的简单工作;但考虑到项目中存在几十个不同实体(Entity),为每一实体手写转换代码既繁琐又容易出错。因此,我设计了一套通用方案,必须具备以下能力:

  • 通用数据集导出 无侵入地将任意对象数组(Core Data、普通结构体等)转换为 TabularData 框架的 DataFrame 类型。
  • 按需列选择与顺序控制 精确指定要导出的属性,并能在结果中自定义列的排列顺序。
  • 自定义列名 为每一列设置友好、直观的显示名称。
  • 高阶映射(map) 为每列提供映射闭包,在导出前对原始值进行任意转换、格式化或类型适配。
  • 条件映射(conditional) 内建过滤与条件映射机制,根据自定义的 filter 逻辑动态切换不同的映射函数。

接下来,让我们在实现过程中,一步步体会 Swift 的特点。

TabularColumn —— 用泛型构建列的抽象模型

首先,我们需要一个泛型类型,来精确描述“列名”、“属性 KeyPath”、“映射逻辑”等信息:

在初始阶段,我们暂时先不添加条件映射功能。

Swift
public struct TabularColumn<Object, Value, Output> {
    // 列名
    public let name: String
    // 对应的属性
    public let keyPath: KeyPath<Object, Value>
    // 映射操作
    public let mapping: (Value) -> Output

    public init(
        name: String,
        keyPath: KeyPath<Object, Value>,
        mapping: @escaping (Value) -> Output)
    {
        self.name = name
        self.keyPath = keyPath
        self.mapping = mapping
    }
}

短短几行代码,却已经展示了 Swift 的多项优势特点:

  • 类型安全与强类型系统TabularColumn 是一个包含三个泛型参数的类型,Object 对应实体类型,Value 对应属性的原始类型,Output 对应经 mapping 转换后的类型。这种类型安全的数据转换方式,远比动态类型语言(如 Python 或 JavaScript)的实现更加安全可靠。
  • KeyPath:KeyPath 是 Swift 的强大特性之一,允许我们以类型安全的方式引用对象的属性,相比字符串或反射方式更加优雅。

想深入了解 KeyPath 的高阶用法,请阅读《从基础到进阶:Swift 中的 KeyPath 完全指南》

现在,我们为 TabularColumn 添加一个方法,用于将给定的 Object 数组根据设置的列名和属性,转换成组成 DataFrame 的 Column

Swift
public func makeColumn(objects: [Object]) -> Column<Output> {
    let values = objects.map { object in
        let raw = object[keyPath: keyPath]
        return mapping(raw)
    }
    return Column(name: name, contents: values)
}

通过以下代码,我们可以分别生成 nameage 的 Column 数据:

Swift
let students = [
  Student(name: "fat", age: 100)
  // 更多学生数据...
]
let ageColumn = TabularColumn(
    name: "student_age",
    keyPath: \Student.age,
    mapping: { Double($0) }) // 将 Int 转换成 Double
let nameColumn = TabularColumn(
    name: "student_name",
    keyPath: \Student.name,
    mapping: { $0 } // 不进行转换
)

let ageColumnData = ageColumn.makeColumn(objects: students)
let nameColumnDate = nameColumn.makeColumn(objects: students)

where 约束 —— 简化默认构造器

在上面的示例中,虽然我们不打算转换 name 数据,但仍需提供 mapping 方法,否则编译器无法推断 Output 类型。我们可以通过添加一个带约束的构造方法来简化这一操作:

Swift
public init(
    name: String,
    keyPath: KeyPath<Object, Value>) where Value == Output
{
    self.init(name: name, keyPath: keyPath, mapping: { $0 })
}

通过 where Value == Output 约束,我们帮助编译器确定了 Output 类型,使用时变得更加简洁:

Swift
TabularColumn(name: "student_name", keyPath: \Student.name)

AnyTabularColumn —— 泛型擦除与列顺序控制

为了实现对列顺序的控制,我们需要将不同属性创建的 TabularColumn 实例按顺序放入数组中,但操作时会遇到这样的错误:

Swift
let columns = [ageColumn, nameColumn] // Heterogeneous collection literal could only be inferred to '[Any]'; add explicit type annotation if this is intentional

显然,ageColumnnameColumn 的泛型参数中,只有 Object 相同,无法直接放入同一数组。为保留所有泛型细节,我们需要创建一个静态类型擦除包装:

Swift
public struct AnyTabularColumn<Object> {
  	// AnyColumn 是 TabularData 提供的列擦除类型,用于构建 DataFrame
    private let _make: ([Object]) -> AnyColumn

    public init<Value, Output>(
        _ column: TabularColumn<Object, Value, Output>)
    {
        _make = { objects in
            column.makeColumn(objects: objects).eraseToAnyColumn()
        }
    }

    public func makeColumn(objects: [Object]) -> AnyColumn {
        _make(objects)
    }
}

通过 AnyTabularColumn 包装不同泛型的 TabularColumn 实现,我们不仅可以将它们放在同一数组中,还能在调用 makeColumn 方法时保留每个 TabularColumn 的泛型细节:

Swift
let columns = [AnyTabularColumn(ageColumn), AnyTabularColumn(nameColumn)]

let ageColumnData = AnyTabularColumn(ageColumn).makeColumn(objects: students)

协议扩展 —— 为任意类型注入导出能力

协议是 Swift 的另一大特色。Swift 协议不仅能约定接口,还可提供默认实现,这进一步简化了为任意类型添加转换成 DataFrame 功能的操作:

Swift
public protocol DataFrameConvertible {
    static func makeDataFrame(objects: [Self], anyColumns: [AnyTabularColumn<Self>]) -> DataFrame
}

extension DataFrameConvertible {
    public static func makeDataFrame(
        objects: [Self],
        anyColumns: [AnyTabularColumn<Self>]) -> DataFrame
    {
        let columns = anyColumns.map { $0.makeColumn(objects: objects) }
        return DataFrame(columns: columns)
    }
}

现在,让任意类型具备导出功能只需遵循 DataFrameConvertible 协议:

Swift
extension Student: DataFrameConvertible {}

let columns = [AnyTabularColumn(ageColumn), AnyTabularColumn(nameColumn)]
// 生成 DataFrame
let dataFrame = Student.makeDataFrame(objects: students, anyColumns: columns)

conditional 工厂 —— 条件映射实现

根据需求列表,我们还需添加条件映射功能,使其能选择性地决定转换操作。例如,每个 Student 实例的 id 都有值,但我们只想在导出时保留 age > 50 实例的 id,不满足条件的返回 nil

首先,为 TabularColumn 增加两个属性:

Swift
public var conditionalMapping: ((Bool, Value) -> Output)?
public var filter: ((Object) -> Bool)?

虽然可以为 TabularColumn 添加新的构造方法,但我们选择使用更符合值类型和点语法编程风格的工厂方法来处理这类特殊情况:

Swift
public static func conditional(
    name: String,
    keyPath: KeyPath<Object, Value>,
    filter: @escaping (Object) -> Bool,
	// 满足条件时的分支
    then thenMap: @escaping (Value) -> Output,
    // 不满足条件时的分支
    else elseMap: @escaping (Value) -> Output) -> Self
{
    var col = Self(name: name, keyPath: keyPath, mapping: thenMap)
    col.conditionalMapping = { passed, raw in
        passed ? thenMap(raw) : elseMap(raw)
    }
    col.filter = filter
    return col
}

// 调整 makeColumn,处理包含 filter 的情况
public func makeColumn(objects: [Object]) -> Column<Output> {
    let values = objects.map { object in
        let raw = object[keyPath: keyPath]
        if let filter, let conditionalMapping {
            return conditionalMapping(filter(object), raw)
        } else {
            return mapping(raw)
        }
    }
    return Column(name: name, contents: values)
}

这样,我们可以更优雅地构建 Student 的 id 列:

Swift
let idColumn = TabularColumn
                    .conditional(
                        name: "id",
                        keyPath: \Student.id,
                        filter: { $0.age > 50 },
                        then: { $0 as UUID? }, // age > 50 时导出 UUID
                        else: { _ in nil })

let columns = [AnyTabularColumn(ageColumn), AnyTabularColumn(nameColumn), AnyTabularColumn(idColumn)]
let dataFrame = Student.makeDataFrame(objects: students, anyColumns: columns)

Result Builder —— 打造优雅的列定义 DSL

截至目前,我们已完成需求列表中的所有要求。但声明 AnyTabularColumn 数组的方式仍有提升空间。

当前方式需要手动添加 AnyTabularColumn 转换,且过多的逗号也会影响代码可读性。在 Swift 中,这正是 Result Builder 大显身手的场景:

Swift
@resultBuilder
public enum TabularColumnBuilder<Object> {
    public static func buildBlock(
        _ components: [AnyTabularColumn<Object>]...) -> [AnyTabularColumn<Object>]
    {
        components.flatMap(\.self)
    }

    public static func buildExpression<Value, Output>(
        _ column: TabularColumn<Object, Value, Output>) -> [AnyTabularColumn<Object>]
    {
        [AnyTabularColumn(column)]
    }

    public static func buildExpression(
        _ any: AnyTabularColumn<Object>) -> [AnyTabularColumn<Object>]
    {
        [any]
    }

    public static func buildExpression(
        _ columns: [AnyTabularColumn<Object>]) -> [AnyTabularColumn<Object>]
    {
        columns
    }
}

// 在协议中添加使用 `@TabularColumnBuilder` 的声明和实现
public protocol DataFrameConvertible {
    // ...其他方法...
    static func makeDataFrame(
            objects: [Self],
            @TabularColumnBuilder<Self> _ columns: () -> [AnyTabularColumn<Self>]) -> DataFrame
}

extension DataFrameConvertible {
    public static func makeDataFrame(
            objects: [Self],
            @TabularColumnBuilder<Self> _ columns: () -> [AnyTabularColumn<Self>]) -> DataFrame
        {
            makeDataFrame(objects: objects, anyColumns: columns())
        }
}

在上述代码中,我们通过 buildExpressionTabularColumnAnyTabularColumn[AnyTabularColumn] 都统一为 [AnyTabularColumn],便于在 buildBlock 中进行整合。

想深入了解这一强大工具,请阅读《ViewBuilder 研究—— 掌握 Result builders》

现在,我们可以用类似 SwiftUI 的声明式语法来定义列表集,既清晰又美观:

Swift
let dataFrame = Student.makeDataFrame(objects: students) {
    TabularColumn(name: "student_age", keyPath: \.age, mapping: { Double($0) })
    TabularColumn(name: "student_name", keyPath: \.name)
    TabularColumn
        .conditional(
            name: "id",
            keyPath: \.id,
            filter: { $0.age > 50 },
            then: { $0 as UUID? },
            else: { _ in nil }
        )
}

Why Swift —— Swift 的魅力

对于任何一种编程语言而言,实现我们的需求都不是难事。但像 Swift 这样能全面兼顾清晰、准确、安全、现代且优雅的语言选择并不多见。

我想,这正是 Swift 深深吸引我的原因,也是作为 Swift 开发者,希望看到它能被更多不同平台、不同场景采用的动力源泉。希望这个小例子能让你看到 Swift 不仅仅是“语法糖太多”的语言,而是一门真正能提升开发体验和代码质量的现代编程语言。

Swift 肯定不是最完美的语言,但它确实是我始终钟爱的选择。

"加入我们的 Discord 社区,与超过 2000 名苹果生态的中文开发者一起交流!"

每周精选 Swift 与 SwiftUI 精华!