这个Monster Builder是一个很好的建造者/工厂模式来抽象混合了设置器的长构造函数吗?

22
这是一个关于将步骤构建器模式增强向导构建器模式组合成创造性 DSL的人机界面问题。它使用类似流畅接口的方式,尽管它使用方法链接而不是级联。也就是说,这些方法返回不同类型。
我正在处理一个怪兽类,它有两个构造函数,每个函数都需要混合使用int、String和字符串数组。每个构造函数有10个参数。此外,它还有大约40个可选的setter;其中一些在同时使用时会产生冲突。它的构建代码看起来像这样:
Person person = Person("Homer","Jay", "Simpson","Homie", null, "black", "brown", 
  new Date(1), 3, "Homer Thompson", "Pie Man", "Max Power", "El Homo", 
  "Thad Supersperm", "Bald Mommy", "Rock Strongo", "Lance Uppercut", "Mr. Plow");

person.setClothing("Pants!!");     
person.setFavoriteBeer("Duff");
person.setJobTitle("Safety Inspector");

这最终失败了,因为设置喜欢的啤酒和工作职称是不兼容的。 唉。
重新设计怪物类不是一个选项。 它被广泛使用。 它有效。 我只是不想再直接观看它被构建了。 我想写一些干净的东西来喂它。 某些遵循其规则而无需使开发人员记住它们的内容。
与我一直在研究的精美构建器模式相反,这个东西没有口味或类别。 它总是需要一些字段,其他字段仅在需要时才需要,并且某些字段仅取决于之前设置了什么。 构造函数不是可变焦的。 它们提供了两种将类带入相同状态的替代方法。 它们又长又丑。 它们想要喂给它们的东西是独立变化的。
流畅的构建器肯定会使长构造函数更易于查看。 但是,大量的可选setter会混淆所需的setter。 并且有一个级联流畅构建器不能满足的要求:编译时强制执行。
构造函数强制开发者明确添加必需字段,即使将它们置为空。但是当使用级联流畅构建器时,这个要求将被忽略。设置器的情况也是如此。我需要一种方法来防止开发人员在添加每个必需字段之前构建对象。
与许多生成器模式不同,我想要的不是不变性。我希望类保持原样。通过查看构建它的代码,我想知道构建的对象状态良好,而不必参考文档。这意味着它需要按条件引导程序员完成所需的步骤。
Person makeHomer(PersonBuilder personBuilder){ //Injection avoids hardcoding implementation
    return personBuilder

         // -- These have good default values, may be skipped, and don't conflict -- //
        .doOptional()
            .addClothing("Pants!!")   //Could also call addTattoo() and 36 others

         // -- All fields that always must be set.  @NotNull might be handy. -- //
        .doRequired()                 //Forced to call the following in order
            .addFirstName("Homer")
            .addMiddleName("Jay")
            .addLastName("Simpson")
            .addNickName("Homie")
            .addMaidenName(null)      //Forced to explicitly set null, a good thing
            .addEyeColor("black")
            .addHairColor("brown")
            .addDateOfBirth(new Date(1))
            .addAliases(
                "Homer Thompson",
                "Pie Man",
                "Max Power",
                "El Homo",
                "Thad Supersperm",
                "Bald Mommy",
                "Rock Strongo",
                "Lance Uppercut",
                "Mr. Plow")

         // -- Controls alternatives for setters and the choice of constructors -- //
        .doAlternatives()           //Either x or y. a, b, or c. etc.
            .addBeersToday(3)       //Now can't call addHowDrunk("Hammered"); 
            .addFavoriteBeer("Duff")//Now can’t call addJobTitle("Safety Inspector");  

        .doBuild()                  //Not available until now
    ;
}   

在调用addBeersToday()之后,可以构建Person对象,因为此时所有构造函数信息都已知,但是直到调用doBuild()才会返回。

public Person(String firstName, String middleName, String lastName,
               String nickName, String maidenName, String eyeColor, 
               String hairColor, Date dateOfBirth, int beersToday, 
               String[] aliases);

public Person(String firstName, String middleName, String lastName,
               String nickName, String maidenName, String eyeColor, 
               String hairColor, Date dateOfBirth, String howDrunk,
               String[] aliases);

这些参数设置了必须永远不使用默认值的字段。beersToday和howDrunk以不同的方式设置相同的字段。favoriteBeer和jobTitle是不同的字段,但会与类的使用方式产生冲突,因此只能设置一个。它们使用setter而不是构造函数进行处理。
doBuild()方法返回一个Person对象。它是唯一返回Person类型的方法。当它返回时,Person已经完全初始化。
在接口的每个步骤中,返回的类型并非总是相同的。更改类型是开发人员完成步骤的指导方式。它仅提供有效的方法。直到所有必需的步骤都已完成,才可用doBuild()方法。
引入do/add前缀是为了使编写变得更容易,因为不匹配的返回类型与分配不匹配,并使eclipse中的智能提示建议按字母顺序排列。我已确认intellij没有这个问题。感谢NimChimpsky。
本问题涉及接口,因此我将接受不提供实现的答案。但如果您知道一个实现,请分享。
如果您建议使用另一种模式,请展示其接口的使用。请使用示例中的所有输入。
如果您建议使用此处提供的接口或某些轻微变化,请防御像this这样的批评。
我真正想知道的是,大多数人是否更喜欢使用此界面来构建还是其他界面。这是一个人机界面问题。这是否违反了PoLA?不用担心实现的难度。
但是,如果您对实现感到好奇: A failed attempt(状态不足或未理解有效与默认值) A step builder implementation(不够灵活,无法适应多个构造函数或替代方案)

增强的构建器(仍然是线性的但具有灵活的状态)

向导构建器(处理分支但不记住选择构造函数的路径)

要求:

  • 怪物(人物)类已经关闭了修改和扩展;不要触碰

目标:

  • 隐藏长构造函数,因为怪物类有10个必需参数
  • 根据使用的替代方案确定要调用哪个构造函数
  • 禁止冲突的设置器
  • 在编译时执行规则

意图:

  • 明确指示默认值不可接受的情况
5个回答

3
一个静态内部构造器,因Josh Bloch在Effective Java一书中而闻名。
必传参数为构造函数参数,可选参数为方法。
例如,在只需要用户名的情况下进行调用:
RegisterUserDto myDto = RegisterUserDto.Builder(myUsername).password(mypassword).email(myemail).Build();

以下是底层代码(省略显而易见的实例变量):

private RegisterUserDTO(final Builder builder) {
        super();
        this.username = builder.username;
        this.firstName = builder.firstName;
        this.surname = builder.surname;
        this.password = builder.password;
        this.confirmPassword = builder.confirmPassword;
    }


    public static class Builder {
        private final String username;

        private String firstName;

        private String surname;

        private String password;

        private String confirmPassword;

        public Builder(final String username) {
            super();
            this.username = username;
        }

        public Builder firstname(final String firstName) {
            this.firstName = firstName;
            return this;
        }

        public Builder surname(final String surname) {
            this.surname = surname;
            return this;
        }

        public Builder password(final String password) {
            this.password = password;
            return this;
        }

        public Builder confirmPassword(final String confirmPassword) {
            this.confirmPassword = confirmPassword;
            return this;
        }

        public RegisterUserDTO build() {
            return new RegisterUserDTO(this);
        }
    }

谢谢,但这不仅是典型的级联流畅构建器吗?它在构建过程中并不限制可用方法。 - candied_orange
使用工厂模式处理更复杂的逻辑。 - NimChimpsky
1
使用静态内部“步骤构建器”,然后呢?“更改返回类型与赋值不匹配并违反Intelisense建议”,我已经很久没有使用Eclipse了(尝试IntelliJ),那一定是个bug吧?您可以从流畅的API中返回不同的类型。 - NimChimpsky
4
“如果他们需要分叉到不同的构造函数怎么办?”那就有两个不同的构造函数调用?为了澄清,我个人认为最好的解决方案是重构对象,并利用包含这些字段/逻辑的组成对象,从而产生更简单的API,换句话说,重新塑造您的领域。对于那些需要按特定顺序调用的方法,绝对不是好的做法。 - NimChimpsky
1
嗯,我承认我还没有看过原型模式。感谢Intellij的提示。你绝对正确,Intellij将前缀自由方法排序到完成列表的顶部,而Eclipse则不会。 - candied_orange
显示剩余9条评论

1
免责声明:

我知道这个问题被标记为,但是它多次说明这是一个“人机界面问题”,所以我认为在另一种语言(如C++)中提供解决方案的草图可能会对像我这样探索设计模式(并质疑学习它们是否有用或者是否会阻碍创造力)的读者有用,即使只是以一种与语言无关的方式。

类型安全性:

考虑到你必须接受的构造函数,我认为建造者模式不能创造奇迹,因为即使一个带有所有这些参数的构造函数很丑陋(我同意你的看法),将其换成makeHomer中的一长串methodCalls(withArgs)也没有太大的改进空间,在我看来;这是不可避免的。毕竟,你仍然不得不传递相当多的参数,无论如何。

因此,我认为重新思考构造函数的问题,并试图简化你已经设计出的解决方案,可能是有成效的。我们当然必须接受解决方案不会是一个简洁的一行代码,但也许我们可以改进它。

实际上,我提出的解决方案(遗憾的是,是用C++而不是Java)的关键点在于,这个构造函数的问题不仅仅是它需要10个参数,而是你可能会完全混淆它们,最终得到一个(无效的)人。
毕竟,你已经通过“转换”一些编译器无法强制执行任何内容(参数名称)的东西成为了可以在这方面使用的东西(成员函数名称)。
所以你想要的就是一种调用那两个已经存在的构造函数的方法,同时通过使编译器在每次犯错时都产生错误来将输入参数顺序错误的机会降至零(或者没有输入必需的参数,或者输入不兼容的参数等)。
顺便说一句,一个好的IDE会相应地产生。
好的诊断, 在此输入图片描述 以及内联建议 在此输入图片描述 (以上截图显示了我的IDE(Vim)在运行时,当我犯了一个错误并且正在传递参数给makePerson时。)
另一种编码参数顺序/可选性/不兼容性的成员函数名称的替代方法是通过为每个参数关联自定义类型来创建类型安全包装器。一旦这样做,您就可以使用该签名强制执行的类型对象调用构造函数包装器。
在C ++中,用户定义的字面量也有助于在这方面编写清晰的代码。例如,如果您定义了以下内容:
struct FirstName {
  std::string str;
};
FirstName operator"" _first(char const* s, std::size_t) {
  return {s};
}

然后,您可以编写"Homer"_first来创建一个FirstName类的对象。

避免无效状态

这些行

         // -- Controls alternatives for setters and the choice of constructors -- //
        .doAlternatives()           //Either x or y. a, b, or c. etc.
            .addBeersToday(3)       //Now can't call addHowDrunk("Hammered"); 
            .addFavoriteBeer("Duff")//Now can’t call addJobTitle("Safety Inspector");  

也许考虑到你评论中的单词“Now”,建议你将此逻辑视为处理某些时间变化状态并检查某些事情是否已经发生(例如调用addBeersToday)以防止稍后执行其他操作(例如调用addHowDrunk)。可能更好的方法是避免明确处理这些状态。毕竟,你所提到的方法链接正是这样做的:它利用编译时信息(类型)防止你在调用addBeersToday之后甚至尝试调用addHowDrunk

但是,在C++中,使用函数重载可以做到这一点。如果不同的重载有很多共同点,你实际上可以一次编写所有重载,并使用if constexpr在少数使它们不同的条件上进行分支。

所提出的方法

我下面提出的解决方案允许你像这样编写makeHomer

Person makeHomer() {
    return makePerson(
            "Homer"_first,
            "Jay"_middle,
            "Simpson"_last,
            std::nullopt,
            May/12/1956,
            {"Homer Thompson"_alias, "Max Power"_alias},
            3_beers,
            "Duff"_favBeer)
        .setClothing("Pants!!");
}

makePerson的作用类似于构建者或更精确地说,是一个类型安全的Person构造函数。依赖强类型的优势是,例如以上调用中,您甚至不能交换"Homer"_first"Jay"_middle,否则会在编译时出错。

以下是makePerson的另一种可能用法:

Person makeMaude() {
    return makePerson(
            "Maude"_first,
            ""_middle,
            "Flanders"_last,
            "???"_maiden,
            May/12/1956,
            {},
            "teetotal"_drunk /* instead of 3_beers */,
            "Ned's wife"_job /* instead of "Duff"_favBeer */)
        //.setClothing("Pants!!") let's leave her naked
        ;

在这里你可以看到我将替代类型的参数传递给了最后两个参数。

makePerson函数只是简单地从强类型包装器中解开字符串,并根据编译时条件将它们转发给其中一个构造函数:

template<Drunkness Drunkness_, BeerOrJob BeerOrJob_>
Person makePerson(
        FirstName first,
        MiddleName middle,
        LastName last,
        std::optional<MaidenName> maiden,
        Date birth,
        std::vector<Alias> aliases,
        Drunkness_ drunkness,
        BeerOrJob_ /* beerOrJob */) {

    // Not sure how to use beerOrJob... Maybe by creating the Person but not
    // returning it before calling a setter similar to setClothing?
    if constexpr (std::is_same_v<Drunkness_, BeersToday>) {
        return Person(first.str, middle.str, last.str,
                maiden.value_or("null"_maiden).str, birth,  drunkness.num,
                aliases | transform(to_string) | to_vector);
    } else {
        return Person(first.str, middle.str, last.str,
                maiden.value_or("null"_maiden).str, birth,  drunkness.str,
                aliases | transform(to_string) | to_vector);
    }
}

请注意,我使用概念“醉酒程度”来表示只能将两种类型中的一种对象传递给该参数(类似于“啤酒或工作”):通过模板和if constexpr,我基本上将4个重载合并为一个。
这里是一个关于Compiler Explorer的例子(链接),以下是一些关于它的评论:
  • I've removed eye color and hair color only to shorten the exmaple, because they pose the same exact challenge as first name, last name, and others (similarly to how you've shown only one optional parameter, the clothing, commenting that there could be many more).
  • I enforce type safety as much as I can, but as little as it's needed: all those types that wrap std::string are in place exactly to disambiguate between the various meanings we give to std::string (first name, last name, …), but as regards the date of birth, that's the only date in the API, so it needn't be disambiguated, hence std::chrono::year_month_day seemed just a good enough type to me; clearly, if you had more dates in the API, say birth date and death date, you could certainly wrap them in custom types similarly to what I've done for std::string.
  • I'm not sure I've understood your concern about _maiden. In your example you have
    .addMaidenName(null)      //Forced to explicitly set null, a good thing
    
    so I assumed you do want to force the user to set maiden name, even if it is just null; in my solution, std::nullopt is playing the role of null, and its not necessary to make it special to the maiden case: an empty optional is the same thing whether we're dealing with optional<SomeType> or with optional<AnotherType>. If you really want to avoid the uninformative std::nullopt, you could either write std::optional<MaidenName>{} or, far better, define
    constexpr auto noMaidenName/* or whatever you like */ = std::nullopt;
    
    somewhere and use that. Actually, since no type deduction is going on, you can also just pass {} instead of std::nullopt. If, instead, what concerns you is the expression maiden.value_or("null"_maiden).str, that's because the constructors expect a std::string maiden name, and, unlike in Java (from your code I understand that you can assign null to a String), in C++ you can't assign a null-but-non-std::string value to a std::string, so the expression I used is to pull the std::string out of maiden if maiden.has_value() == true, and to pull it out of "null"_maiden otherwise, and in the latter case the string is trivially "null".

我真的很喜欢这种风格看起来多么好。注意到一些例子中存在问题。DateOfBirth没有获得用户定义的文字,而_maiden的使用不一致。我知道它是可选的,但当为空时真的可以省略吗? - candied_orange
@candied_orange,我在结尾处添加了一些注释。我不确定我是否理解了你对“_maiden”的担忧,请让我知道如果我误解了。 - Enlico
std::nullopt缺少_maiden。 - candied_orange
我对明确设置 maiden 的担忧在于它只能被有意地设置为 null,而不是因为忘记设置而被设置为 null。在这种风格中,它似乎给你带来了一些困难。 - candied_orange
1
非常感谢您精心撰写的文章。这确实是一种创造性的用户定义字面量的使用方式。 - candied_orange
_maiden 是创建一个 MaidenName 类型的字面对象,但该函数需要一个参数,该参数是 std::optional<MaidenName> 类型,而空的可选项(可以通过 {}std::nullopt 传递给函数)不是 MaidenName,而是一个空的可选项,因此不需要 _maiden。请注意,当我传递 "whatever"_maiden 时,我只是依赖于 std::optional<MaidenName> 可以从 MaidenName 初始化的事实。我也可以写成 std::make_optional("whatever"_maiden) 更明确,但在我看来会过于冗长。 - Enlico

1

建议您创建一个新的构造函数,引入不同的参数对象来组合不同的参数,代替您的生成器模式。然后,在该新构造函数内部委托给原始构造函数。同时将原始构造函数标记为过时,并指向新的构造函数。

使用 IDE 支持也可以通过重构构造函数带参数对象来完成,因此工作量不大。这样,您还可以重构现有代码。如果需要,仍然可以为参数对象和相关类创建生成器。

您需要关注的问题是不同的参数之间存在依赖关系。这种依赖关系应该反映在它们自己的对象中。

链接生成器的问题是您需要太多的类,并且无法更改要使用它们的顺序,即使该顺序仍然正确。


你如何对独立于彼此或任何上下文的参数进行分组?即使我将不变的参数放入参数对象中,仍然是一个有9个参数的构造函数。--- 还要记住,我不会触碰这个庞然大物。只是喂它。它有太多的调用者,我无法掌控。--- 请展示一下如果使用所有示例输入来构建您的模式会是什么样子,并解释为什么对于许多想要使用它而不想思考的开发人员来说更好。--- 我不介意有很多状态。只要使用它容易就可以了。 - candied_orange

1

所以我已经思考了几年,现在我认为知道了正确的方法。

首先:将每个强制设置实现为单个方法接口,该接口返回下一个单个方法接口。这迫使客户端填写所有必需参数,并具有额外的好处,即它们必须在代码中的所有位置以相同的顺序填写,从而更容易发现错误。

其次:将所有独立可选参数实现为一个接口,该接口是最终参数的返回类型。

第三:对于任何复杂的子组可选参数,创建更多接口,强制选择要走的路线。

interface FirstName {

     public LastName setFirstName(String name)

}
interface LastName {
    public OptionalInterface setLastName(String name)
}

interface OptionalParams {
    public OptionalParams setOptionalParam(String numberOfBananas)
    public OptionalParams setOptionalParam(int numberOfApples)
    public AlcoholLevel setAlcoholLevel() // go down to a new interface
    public MaritalStatus setMaritalStatus()

    public Person build()
}

interface AlcoholLevel {
    //mutually exclusive options hoist you back to options
    public OptionalParams setNumberOfBeers(int bottles)
    public OptionalParams setBottlesOfWine(int wine)
    public OptionalParams setShots(int shots)
    public OptionalParams isHammered()
    public OptionalParams isTeeTotal()
    public OptionalParams isTipsy()
}

interface MaritalStatus {
    public OptionalParams isUnmarried()
    //force you into a required params loop
    public HusbandFirstName hasHusband()
    public WifeFirstName hasWife()
}

通过一系列单一方法接口,您可以在很大程度上强制客户端表现良好。例如,在需要某些身份验证的网络中形成格式良好的HTTP请求时,此模式非常有效。在标准HTML库之上叠加接口,可以引导客户端朝着正确的方向发展。
有些条件逻辑基本上太难了,不值得这样做。例如,坚持参数1和参数2的总和小于参数3最好通过在构建方法上抛出运行时异常来处理。

1
因此,静态内部构建器与工厂函数结合可以实现你想要的某些功能。 (1) 它可以强制执行依赖关系类型,如果设置了 A,则也必须设置 B。 (2) 它可以返回不同的类。 (3) 它可以对条目进行逻辑检查。
但是,如果程序员输入错误的字段,它仍会失败。
可能的优点是“多个构建器”模式。如果客户端预先知道正在构建特定元素的目的,则可以获得不同的构建器。您可以为每种组合制作一个构建器。
根据您的类中逻辑依赖关系的类型,您可以将这些多个构建器与一个通用构建器结合使用。您可以拥有一个通用构建器,并在通用构建器上调用 setOption(A) 时返回一个不同类的构建器,只能链接继续相关的方法。因此,您可以流畅地进行操作,但可以排除一些路径。当您这样做时,必须小心地将已设置但变得无关紧要的字段置为空-您不能使构建器彼此的子类。
这可以迫使客户端在编译时选择如何构造对象,这就是您想要的吗?
更新-尝试回答评论:
首先,工厂函数是 Joshua Blocks《Effective Java》中的第一项,它意味着您将构造函数设为私有,并代之以一个静态工厂函数。这比构造函数更灵活,因为它可以返回不同类型的对象。当您将工厂函数与多个构建器结合使用时,您可以得到非常强大的组合。以下是该模式描述:http://en.wikipedia.org/wiki/Factory_method_pattern http://en.wikipedia.org/wiki/Abstract_factory_pattern 假设您想创建描述人和其职业的对象,但在指定他们的职业时,您想要具有特定子选项列表。
public class outer{
    Map <Map<Class<?>, Object> jobsMap - holds a class object of the new builder class, and a prototypical isntance which can be cloned.
    outer(){
        jobsMap.add(Mechanic.class, new Mechanic());
        //add other classes to the map
    }

    public class GeneralBuilder{
    String name;
    int age;

//constructor enforces mandatory parameters.
    GeneralBuilder(String name, int age, \\other mandatory paramerters){
        //set params
    }

    public T setJob(Class<T extends AbstractJob> job){
        AbstractJob result = super.jobsMap.getValue(job).clone();
        //set General Builder parameters name, age, etc
        return (T) result;
    }
}

public MechanicBuilder extends AbstractJobs{
//specific properties
    MechanicBuilder(GeneralBuilder){
      // set age, name, other General Builder properties
    }
    //setters for specific properties return this
    public Person build(){
        //check mechanic mandatory parameters filled, else throw exception.
        return Person.factory(this);
    }
}

public abstract class AbstractJob{
    String name;
    String age;
    //setters;
}

public class Person {
//a big class with lots of options
//private constructor
    public static Person Factory(Mechanic mechanic){
        //set relevant person options
    }
}

所以现在流畅了。我创建了一个外部类的实例,并用所有特定工作类型填充了映射。然后我可以创建任意多个内部类构建器的实例。我调用.setJobs(Mechanic.class)设置通用构建器的参数,它返回具有一堆特定属性的机械师实例,我现在可以使用 .setOptionA() 等流畅地调用这些属性。最终我调用 build,这将调用 Person 类中的静态工厂方法并传递自身。您会得到一个人类。

虽然必须为代表 Person 类的每种“类型”的对象创建特定的构建器类,但这是很多实现,但它确实创建了一个非常易于客户端使用的 API。实际上,虽然这些类有很多选项,但在实践中,人们可能只打算创建少数几种模式,而其他所有模式都只是偶然出现的。


我喜欢你所说的它可以做什么,但除了静态内部构建器之外,我不认识这些名字。Google也不认识。“多个构建器”模式?工厂函数?来自JavaScript?请展示一下如果使用示例中的所有输入,使用您的模式进行构建会是什么样子。解释为什么对于许多希望无需思考即可使用它的开发人员来说,这更好。 - candied_orange
我已经更新了一个例子。使用了自己的例子,因为你的有太多的“东西”,而且我没有整天的时间... :) - phil_20686
感谢更新。您展示了抽象工厂模式的实现。我需要看一下它的调用代码如何与问题中提出的步骤构建器变体相比较。我担心那个长长的必填参数列表又回到了构造函数里。我想避免使用易忘的设置器,它们将需要默认值。请记住,我不是在重构怪物(人)类,只是给它喂食。我更新了问题以使需求更清晰。 - candied_orange
1
是的,但我在建造者模式上进行了抽象工厂,而不是针对对象本身。如果有必须设置的强制参数列表,则必须设置它们。这里的好处在于,您可以向下移动树形结构,因此无法填写“错误”的参数。技工可能具有不同的设置,会计师也不同于建筑师。因此,应该变得不可能存在冲突的设置。 - phil_20686
你需要为每个构造函数构建一个特定的对象。重点是,你现在的调用代码如何醉了已经设置了setHowDrunk("String", SetHowDrunk.class),它返回了一种不同类型的构建器,你不能再对这个对象调用addBeersToday方法,因为它没有该方法的setter?但你仍然可以设置任何其他相关的方法。 - phil_20686
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接