深入理解 Dart Freezed:从 OO SOLID 的视角

在 Dart 和 Flutter 的开发中,freezed 是一个不可或缺的库。它能为我们自动生成不可变(Immutable)数据类的模板代码,将我们从繁琐的 copyWith== 操作符重载、hashCode 以及 toString 的编写中解放出来。

然而,当我们第一次接触 freezed 生成的代码时,常常会感到困惑。一个简单的类,为什么会生成如此复杂的层级结构?

// 我们写的简单代码

class User with _$User {
  const factory User({
    required String name,
    required int age,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

仅仅是这段代码,就会牵扯出 _$User(一个 mixin)、_User(一个私有类)、_$UserFromJson(一个函数)以及最终的 _$UserImpl(另一个我们看不见的实现类)。这背后究竟是怎样的设计哲学?最起码来说,直接让目标类型 User 继承一个完整的实现的子类 _$UserImpl,岂不是更简单?

一、剖析生成代码:揭开 Freezed 的神秘面纱

要理解 freezed 的设计哲学,最好的方法就是直面它生成的代码。许多困惑都源于我们不清楚 part 文件背后到底发生了什么。

让我们以一个表示网络请求结果的 Result 联合类型(Union Type)为例,这是 freezed 最强大的功能之一。

第一步:我们手写的文件 (result.dart)

import 'package:freezed_annotation/freezed_annotation.dart';

part 'result.freezed.dart';
part 'result.g.dart';


class Result<T> with _$Result<T> {
  const factory Result.success({required T data}) = _Success<T>;
  const factory Result.failure({required String message}) = _Failure<T>;
  factory Result.fromJson(Map<String, dynamic> json) => _$ResultFromJson(json);
}

我们在这里定义了意图:一个名为 Result 的类型,它有两个状态 successfailure。我们还希望它能从 JSON 转换而来。

第二步:Freezed 生成了什么?

当我们运行构建命令后,freezedjson_serializable 会生成 result.freezed.dartresult.g.dart。让我们来看看其中的关键部分(已简化):

  • result.freezed.dart - mixin _$Result<T>

    mixin _$Result<T> {
      // 提供了 when/map 等方法的"签名"
      // 它们的实现只是简单地抛出异常
      TResult when<TResult extends Object?>({
        required TResult Function(T data) success,
        required TResult Function(String message) failure,
      }) => throw _privateConstructorUsedError;
      
      Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
    }
    

    这是一个行为契约。你通过 with _$Result<T> 将它应用到你的类上。它并不包含真正的逻辑,但它向 Dart 编译器保证了 whentoJson 等方法的存在,让你可以在代码中安全地调用它们。$ 前缀提醒我们:这是我需要消费的、由工具生成的契约。

  • result.freezed.dart - 子类的"抽象"与"实现" 对于 success 分支,freezed 生成了两个类:

    // 1. 开发者定义的"抽象标签"
    abstract class _Success<T> implements Result<T> {
      const factory _Success({required final T data}) = _$SuccessImpl<T>;
    
      factory _Success.fromJson(Map<String, dynamic> json) = _$SuccessImpl<T>.fromJson;
      // ...
    }
    
    // 2. 工具生成的"具体实现"
    ()
    class _$SuccessImpl<T> implements _Success<T> {
      const _$SuccessImpl({required this.data});
    
      
      final T data;
      
      // ... 此处包含 hashCode, ==, copyWith, toString 的真正实现 ...
    
      
      Map<String, dynamic> toJson() {
        return _$$SuccessImplToJson(this); // 调用 .g.dart 中的函数
      }
    }
    

    这里揭示了设计的核心:

    • 你在 factory 中指定的 _Success,被 freezed 生成为一个 abstract 类。它的作用是作为一座桥梁,明确声明 _SuccessResult 的一个子类型,并将构造任务转发给真正的实现类 _$SuccessImpl
    • _$SuccessImpl 才是真正的"幕后黑手",它包含了所有属性和方法的具体实现。
  • result.freezed.dartresult.g.dart - fromJson 的实现

    // in result.freezed.dart
    Result<T> _$ResultFromJson<T>(Map<String, dynamic> json) {
      // 这是一个"分派中心"
      switch (json['runtimeType']) {
        case 'success':
          return _Success<T>.fromJson(json);
        case 'failure':
          return _Failure<T>.fromJson(json);
        default:
          throw CheckedFromJsonException(...);
      }
    }
    
    // in result.g.dart
    // 这是真正干活的序列化/反序列化函数
    Map<String, dynamic> _$$SuccessImplToJson<T>(...) { ... }
    _$SuccessImpl<T> _$$SuccessImplFromJson<T>(...) { ... }
    

    这里我们看到了 fromJson 的全貌:

    1. 你调用的 _$ResultFromJson 是一个顶层函数,它通过检查 JSON 中的 runtimeType 字段来决定具体调用哪个子类的 fromJson
    2. 而每个子类的 fromJson(如 _Success.fromJson)最终会调用 .g.dart 文件中由 json_serializable 生成的 _$$SuccessImplFromJson 函数来完成真正的转换工作。

第三步:用"四层模型"重新理解

现在,我们可以用一个清晰的模型来总结我们所看到的一切:

  1. API 定义层 (Result<T>): 你手写的类,是公开的 API。
  2. 生成行为契约层 (_$Result<T>): mixin,提供方法签名。
  3. 开发者抽象定义层 (_Success<T>): 你在 factory 中定义的稳定钩子,作为连接你和工具的桥梁。
  4. 工具实现层 (_$SuccessImpl<T>): 包含所有模板代码的具体实现类。

这个模型解释了我们最初的问题:

  • 我们需要 _Success 这个由我们命名的中间层,是为了实践依赖倒置,让我们的高层 API 不直接依赖于工具生成的低层实现细节 _$SuccessImpl
  • 我们需要 _$ResultFromJson 这个分派中心,是为了在运行时能够动态地、类型安全地根据 JSON 内容创建出正确的子类型实例。

二、freezed 与 SOLID 原则的完美合奏

现在,让我们从 SOLID 的视角,来审视这个四层模型为何如此设计。

D - 依赖倒置原则 (Dependency Inversion Principle)

这是理解 freezed 设计核心的关键。该原则指出:高层模块不应依赖于低层模块,二者都应依赖于抽象。

  • 错误的方式:如果我们的 factory 直接指向实现类,会怎样? const factory Result.success({required T data}) = _$SuccessImpl<T>; 在这种情况下,你的 Result 类(高层模块)直接依赖了 _$SuccessImpl(一个由工具生成的、不稳定的低层实现细节)。如果 freezed 未来改变其内部命名规则,你的代码就会崩溃。

  • freezed 的正确方式const factory Result.success({required T data}) = _Success<T>; 在这里,Result 类(高层模块)依赖于你定义的 _Success(一个稳定的抽象)。同时,freezed 生成的 _$SuccessImpl(低层模块)也去实现这个 _Success 抽象。

    如此一来,高层和低层都依赖于你亲手定义的、稳定的抽象 _Success。这便是依赖倒置的精髓!你的 factory 声明不再是"消费一个实现细节",而是"定义一个抽象契约",并将实现任务委托给代码生成工具。

O - 开闭原则 (Open/Closed Principle)

freezed 的联合类型是开闭原则的典范。

  • 对扩展开放:你可以随时为 Result 添加新的状态,比如 loading 状态: const factory Result.loading() = _Loading<T>;
  • 对修改关闭:增加这个新状态,你完全不需要修改 Result 基类或任何已有的 _Success_Failure 的实现。你只需在 fromJson 的分派中心和 when 方法中处理新的情况即可,而这些都是由工具自动完成的。

L - 里氏替换原则 (Liskov Substitution Principle)

任何一个 Result 的子类型实例(如 _Success_Failure)都可以被其父类型 Result 的引用所替代,而程序的行为不会出错。这正是 freezed 强大的 when 方法能够类型安全地工作的基石。

void processResult(Result<int> result) {
  // 无论传入的是 Success还是Failure,程序都能正确处理
  String message = result.when(
    success: (data) => 'Success! Data is $data',
    failure: (message) => 'Failed with error: $message',
    loading: () => 'Loading...', // 如果我们增加了 loading 状态
  );
  print(message);
}

S - 单一职责原则 (Single Responsibility Principle)

freezed 的结构清晰地划分了职责:

  • 你的手写文件:负责定义业务意图和 API 结构。
  • 生成的 Impl:负责实现与业务无关的模板代码(==, hashCode, copyWith 等)。
  • 生成的 .g.dart 文件:负责具体的 JSON 序列化/反序列化逻辑。
  • 生成的 .freezed.dart 文件:负责连接以上所有部分,并提供 when/map 等联合类型特有的功能。

每个部分各司其职,互不干扰。

三、结论

初看 freezed,我们可能会被其生成的代码层级所迷惑。但通过 SOLID 的棱镜去审视,便会发现这并非过度设计,而是一个将软件工程原则内化于心的、极其精巧的架构。

它通过依赖倒置,将你的手写代码(API 定义)与工具代码(实现细节)完美解耦;通过引入 $_命名约定,清晰地划分了开发者和工具的职责边界。

下一次,当你写下 const factory ... = _MyType; 时,请记住,你不仅是在定义一个构造函数,更是在实践一种优雅的软件设计思想:定义抽象,而非依赖实现。这正是 freezed 教给我们的、超越代码生成本身的宝贵一课。

四、后记:殊途同归——从Freezed看FP与OO的深层一致性

函数式编程(FP)与面向对象编程(OO)的核心思想,在表面上似乎截然相反。FP 强调"数据"与"行为"的分离,而 OO 则强调将它们"封装"在一起。但当我们深入探索,会发现这两种范式在追求高质量软件的道路上,最终会走向同一个目的地。

OO 的发展并非止步于封装。为了驾驭大型软件的复杂性,SOLID 原则应运而生,其中的"依赖倒置原则"恰恰引导开发者走向一种更高级的分离:行为契约与具体实现的分离。我们依赖的"接口"(Interface),本质上就是一个不包含具体实现的"行为契约";而实现接口的类,则是承担具体工作的"执行者"。

此时,OO 的思想已经从"将数据和行为捆绑",升华为"将稳定的契约与易变的实现解耦"。这与 FP 将不变的数据结构与可组合的函数行为分离开来的思想,在哲学层面形成了深刻的共鸣。

freezed 的精妙之处,就在于它将这种原本深藏于高级 OO 设计理论中的思想,以一种极其直观甚至带有强制性的方式,呈现在了我们面前:

  • 我们手写的代码 (@freezed class...):这不再是一个传统的类,而是一个数据契约的声明。它只定义"需要什么"(what),描述了数据的结构与意图,这与在 FP 中定义一个不可变的数据类型如出一辙。

  • 工具生成的代码 (.freezed.dart, .g.dart):这是纯粹的行为实现。它负责所有繁杂、机械的"如何做"(how),比如 copyWithtoJson 等。

通过 freezed,我们被迫将"契约"与"实现"分置于两个世界——手写代码与生成代码。这让我们清晰地看到,无论是 FP 还是成熟的 OO 设计,其管理复杂性的终极武器都是一样的:守护住稳定的抽象核心,将易变的实现细节隔离出去。这或许就是 freezed 这个小工具,带给我们的最大启示。