Java中的函数式构建器方法

在 Java 中,构建器是一种非常经典的模式,用于创建具有大量属性的复杂对象。构建器的一个好处是,它们有助于减少需要创建的构造函数的数量,特别是当并非所有属性都需要设置时(或者如果它们具有默认值)。

newBuilder()然而,我总是发现构建器的/方法组合有点冗长build(),特别是当您使用深度嵌套的对象图时,导致构建器的构建器的代码行......

当我与 Go 开发人员同事Valentin谈论这些构建器时,他向我介绍了 Golang 的函数式构建器方法。对于 Java 构建者来说,这不是一种非常常见的实现实践,但值得重新审视!

一、古典建造者
让我们从一个例子开始。我们想要为具有一些属性的类创建一个构建器。并非所有属性都是强制性的,有些属性可能有一些默认值,并且我们不希望创建尽可能多的属性组合的构造函数。

让我向您介绍一下我的SomeModel班级:

public class SomeModel {
    private String modelName;
    private Float temperature = 0.3f;
    private Integer maxOutputTokens = 100;
    // ... possibly many other attribtues

    private SomeModel(String modelName,
                      Float temperature,
                      Integer maxOutputTokens) {
        this.modelName = modelName;
        this.temperature = temperature;
        this.maxOutputTokens = maxOutputTokens;
    }
}

为各种模型配置创建大量构造函数可能会很痛苦。此外,某些属性可以具有相同的类型,因此从用户的角度来看,很难知道哪个值对应于哪个参数类型。因此,创建一个构建器可以减少这种辛苦。

我们可以在里面编写一个静态构建器类,SomeModel如下所示:

public class SomeModelBuilder {
    private String modelName;
    private Float temperature = 0.3f;
    private Integer maxOutputTokens = 100;

    public SomeModelBuilder modelName(String modelName) {
        this.modelName = modelName;
        return this;
    }

    public SomeModelBuilder temperature(Float temperature) {
        this.temperature = temperature;
        return this;
    }

    public SomeModelBuilder maxOutputTokens(Integer maxOutputTokens) {
        this.maxOutputTokens = maxOutputTokens;
        return this;
    }

    public SomeModel build() {
        return new SomeModel(modelName, temperature, maxOutputTokens);
    }
}

在内部SomeModel您将添加一个方法来实例化构建器:

public static SomeModelBuilder newBuilder() {
    return new SomeModelBuilder();
}

然后,用户将使用构建器创建模型实例,如下所示:

var model = SomeBuilder.newBuilder()
    .modelName("gemini")
    .temperature(0.2f)
    .maxOutputToken(300)
    .build();

还不错。这种方法有一些变体,例如在类的构造函数中传递构建器、使用 return 的 setter 方法this、使用或不使用 Final 字段等。但它们大多是风格上的变体。

然而,我想知道函数式构建器的想法......

Java 中现有的函数式方法
有人建议使用 lambda 表达式和Consumers 的方法,但我发现它比我将在本文中进一步描述的方法更加非常规:

SomeModel model = new SomeModelBuilder()
        .with($ -> {
            $.modelName = "Gemini";
            $.temperature = 0.4f;
        })
        .with($ -> $.maxOutputTokens = 100);

您可以在链式调用中传递一个或多个 lambda。控制模型构建方式的是最终用户,而不是实现者,所以我觉得它不太安全。该$符号的使用有点语法上的技巧,以避免重复与模型对应的变量的名称。最后,毕竟还有一个构建器类,也许我们可以找到一种方法来摆脱它。

让我们看看 Go 能够提供什么,以及我们是否可以从中获得一些灵感!

Go 方法
我的同事Valentin向我指出 Dave Cheney 关于 Go 函数式选项模式的文章。

这个想法是,类的构造函数将函数选项作为可变参数,能够修改正在构建的实例。

让我们用下面的代码片段来说明这一点。

我们创建一个struct代表我们的模型对象,就像我们的 Java 示例一样:

package main

import "fmt"

type SomeModel struct {
    modelName string
    temperature float32
    maxOutputTokens int
}

我们定义一个方法来构建我们的模型,它采用一个可变参数选项:

func NewModel(options ...func(*SomeModel)) (*SomeModel) {
    m := SomeModel{"", 0.3, 100}

    for _, option := range options {
        option(&m)
    }

    return &m
}

这些选项实际上是以模型对象作为参数的函数。

现在我们可以创建struct实用方法来创建此类选项函数,并通过方法参数传递每个字段的值。因此,我们为每个结构字段都有一个方法:模型名称、温度和最大输出标记:

func modelName(name string) func(*SomeModel) {
    return func(m *SomeModel) {
        m.modelName = name
    }
}

func temperature(temp float32) func(*SomeModel) {
    return func(m *SomeModel) {
        m.temperature = temp
    }
}

func maxOutputTokens(max int) func(*SomeModel) {
    return func(m *SomeModel) {
        m.maxOutputTokens = max
    }
}

接下来,我们可以通过以下方式创建模型,通过调用返回能够修改struct.

func main() {
    m := NewModel(
        modelName("gemini"),
        temperature(0.5),
        maxOutputTokens(100))

    fmt.Println(m)
}

请注意,甚至没有NewBuilder()orBuild()方法!

让我们用 Java 实现我们的函数构建器!
我们可以在 Java 中遵循相同的方法。我们将使用 Java 的 lambda 代替 Go 函数。我们的 lambda 将被转换为Consumer的 SomeModel。

因此,让我们重新创建我们的SomeModel类,并使用与之前相同的字段。然而,这一次,构造函数不会是private,并且它将采用一个选项列表(使用 实例的 lambda 表达式SomeModel)。我们将迭代所有这些来执行它们:

import java.util.function.Consumer;

public class SomeModel {
    private String modelName;
    private Float temperature = 0.3f;
    private Integer maxOutputTokens = 100;

    public SomeModel(ModelOption... options) {
        for (Option option : options) {
            option.accept(this);
        }
    }

这个类ModelOption是什么?这只是 一个Consumer<SomeModel>同义词 (因此不是严格需要的,但有助于提高可读性)。

这是一个嵌套接口:

    public interface ModelOption extends Consumer<SomeModel> {}

接下来,我们创建类似的实用方法来更新模型实例:

    public static ModelOption modelName(String modelName) {
        return model -> model.modelName = modelName;
    }

    public static ModelOption temperature(Float temperature) {
        return model -> model.temperature = temperature;
    }

    public static ModelOption maxOutputTokens(Integer maxOutputTokens) {
        return model -> model.maxOutputTokens = maxOutputTokens;
    }
}

现在,如果我们想创建一个模型,我们将能够调用构造函数,如下所示:

import fn.builder.SomeModel;
import static fn.builder.SomeModel.*;
//...

SomeModel model = new SomeModel(
    modelName(
"gemini"),
    temperature(0.5f),
    maxOutputTokens(100)
);

讨论
我认为这种方法有一些优点:

  • 我喜欢我们使用构造函数来构造模型实例!
  • 而且构造函数超级简单短!
  • 这也意味着当有新参数需要处理时,构造函数不会改变(更好的向后兼容性)。另一方面,对于传统的构建器,构造函数也可以将构建器本身作为唯一参数。

我也很高兴我摆脱了冗长newBuilder()/build()组合。感觉我们这里并没有真正的建设者在发挥作用。

起初,我想知道我是否打开了潘多拉魔盒,因为我担心开发人员可以提供他们自己的 lambda 并可能在我的实例构造中造成严重破坏,但由于可见性规则,只有我的方法可以修改模型类的内部

虽然我们使用的是构造函数,但实际上将这些方法调用作为参数传递,但感觉有点 像 Python 或 Groovy 等语言中的 命名参数(它们也可以通过 AST 转换为您创建构建器)。它看起来也更像经典的构建器,具有可读性。

我可以按照我想要的顺序传递参数。

我可以在每个变元方法和调用所有变元后的构造函数中放置验证规则。