为什么在构造函数中使用setter并没有成为常见的模式?


23

访问器和修饰符(又名setter和getter)之所以有用,主要有以下三个原因:

  1. 它们限制了对变量的访问。
    • 例如,可以访问但不能修改变量。
  2. 他们验证参数。
  3. 它们可能会引起一些副作用。

大学,在线课程,教程,博客文章和Web上的代码示例都在强调访问器和修饰符的重要性,如今,它们几乎像是代码的“必备”。因此,即使它们不提供任何附加值,也可以找到它们,例如下面的代码。

public class Cat {
    private int age;

    public int getAge() {
        return this.age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

话虽如此,找到更有用的修饰符是很常见的,这些修饰符实际上会验证参数并抛出异常,或者如果提供了无效输入,则返回布尔值,如下所示:

/**
 * Sets the age for the current cat
 * @param age an integer with the valid values between 0 and 25
 * @return true if value has been assigned and false if the parameter is invalid
 */
public boolean setAge(int age) {
    //Validate your parameters, valid age for a cat is between 0 and 25 years
    if(age > 0 && age < 25) {
        this.age = age;
        return true;
    }
    return false;
}

但是即使那样,我几乎也从未见过从构造函数中调用过修饰符,因此,我遇到的一个简单类的最常见示例是:

public class Cat {
    private int age;

    public Cat(int age) {
        this.age = age;
    }

    public int getAge() {
        return this.age;
    }

    /**
     * Sets the age for the current cat
     * @param age an integer with the valid values between 0 and 25
     * @return true if value has been assigned and false if the parameter is invalid
     */
    public boolean setAge(int age) {
        //Validate your parameters, valid age for a cat is between 0 and 25 years
        if(age > 0 && age < 25) {
            this.age = age;
            return true;
        }
        return false;
    }

}

但是有人会认为第二种方法更安全:

public class Cat {
    private int age;

    public Cat(int age) {
        //Use the modifier instead of assigning the value directly.
        setAge(age);
    }

    public int getAge() {
        return this.age;
    }

    /**
     * Sets the age for the current cat
     * @param age an integer with the valid values between 0 and 25
     * @return true if value has been assigned and false if the parameter is invalid
     */
    public boolean setAge(int age) {
        //Validate your parameters, valid age for a cat is between 0 and 25 years
        if(age > 0 && age < 25) {
            this.age = age;
            return true;
        }
        return false;
    }

}

您是否在自己的经历中看到过类似的情况?还是我很不幸?如果您这样做了,那么您认为是什么原因导致的呢?使用构造函数中的修饰符有明显的缺点吗?还是只是认为它们更安全?还有吗


1
@stg,谢谢,有趣的一点!您可以扩展它,并用一个可能发生的坏事的例子来回答它吗?
弗拉德·斯派瑞斯


8
@stg实际上,在构造函数中使用可重写的方法是一种反模式,许多代码质量工具会将其指出为错误。您不知道被覆盖的版本会做什么,它可能会做出令人讨厌的事情,例如在构造函数完成之前让“ this”转义,这会导致各种奇怪的问题。
米哈尔Kosmulski

1
@gnat:我不相信这是重复的,因为类的方法应该调用自己的getter和setter吗?,因为构造函数调用的性质是在未完全形成的对象上调用的。
格雷格·伯格哈特

3
另请注意,您的“验证”仅使年龄未初始化(或为零,或...,取决于语言)。如果要使构造函数失败,则必须检查返回值并在失败时引发异常。就目前而言,您的验证只是引入了另一个错误。
没用

Answers:


37

非常一般的哲学推理

通常,我们要求构造函数提供(作为后置条件)有关构造对象状态的某些保证。

通常,我们还希望实例方法可以假定(作为前提)这些保证在被调用时已经成立,并且只需要确保不破坏它们即可。

从构造函数内部调用实例方法意味着可能尚未建立某些或全部保证,这使得很难推断是否满足实例方法的前提条件。即使您做对了,例如,它也可能非常脆弱。重新排序实例方法调用或其他操作。

在构造函数仍在运行时,语言在解析对实例方法的调用方面也有所不同,这些实例方法是从基类继承/被子类重写的。这增加了另一层复杂性。

具体例子

  1. 您自己如何看待外观的示例本身就是错误的:

    public Cat(int age) {
        //Use the modifier instead of assigning the value directly.
        setAge(age);
    }

    这不会检查的返回值setAge。显然,呼叫设置员并不能保证正确性。

  2. 很简单的错误,例如取决于初始化顺序,例如:

    class Cat {
      private Logger m_log;
      private int m_age;
    
      public void setAge(int age) {
        // FIXME temporary debug logging
        m_log.write("=== DEBUG: setting age ===");
        m_age = age;
      }
    
      public Cat(int age, Logger log) {
        setAge(age);
        m_log = log;
      }
    };

    我的临时伐木工作破坏了一切。哎呀!

还有像C ++这样的语言,其中从构造函数中调用setter意味着浪费的默认初始化(至少对于某些成员变量而言,这是值得避免的)

一个简单的建议

的确,大多数代码不是这样写的,但是如果您希望保持构造函数整洁且可预测,并且仍然重用前提条件和条件前提逻辑,那么更好的解决方案是:

class Cat {
  private int m_age;
  private static void checkAge(int age) {
    if (age > 25) throw CatTooOldError(age);
  }

  public void setAge(int age) {
    checkAge(age);
    m_age = age;
  }

  public Cat(int age) {
    checkAge(age);
    m_age = age;
  }
};

甚至可能更好:将约束编码为属性类型,并在赋值时验证其自身的值:

class Cat {
  private Constrained<int, 25> m_age;

  public void setAge(int age) {
    m_age = age;
  }

  public Cat(int age) {
    m_age = age;
  }
};

最后,为了完整起见,请使用C ++中的自验证包装器。请注意,尽管它仍在进行繁琐的验证,但由于此类不执行其他操作,因此检查起来相对容易

template <typename T, T MAX, T MIN=T{}>
class Constrained {
    T val_;
    static T validate(T v) {
        if (MIN <= v && v <= MAX) return v;
        throw std::runtime_error("oops");
    }
public:
    Constrained() : val_(MIN) {}
    explicit Constrained(T v) : val_(validate(v)) {}

    Constrained& operator= (T v) { val_ = validate(v); return *this; }
    operator T() { return val_; }
};

好吧,它还不是很完整,我省略了各种复制和移动构造函数和赋值。


4
大量签名的答案!让我补充一下,仅仅是因为OP在教科书中已经看到了所描述的情况,但这并不是一个好的解决方案。如果类中有两种方法来设置属性(通过构造函数和设置器),并且一种方法想对该属性强制执行约束,则所有这两种方法都必须检查约束,否则就是设计错误
布朗

因此,如果 1)设置器中没有逻辑,或者2)设置器中的逻辑与状态无关,那么您会考虑在构造函数中使用设置器方法吗?我不经常这样做,但我个人正是出于后一个原因在构造函数中使用了setter方法(当setter中存在某种类型的条件时,必须始终将其应用于成员变量
Chris Maggiulli,

如果存在必须始终适用的某种类型的条件,那就是设置器中的逻辑。当然,我认为这是更好,如果能够以这样的逻辑进行编码的成员,因此它被强制为自包含的,并不能对班上其他同学的获取依赖。
没用

13

构造对象时,根据定义,它没有完全形成。考虑一下您提供的二传手:

public boolean setAge(int age) {
    //Validate your parameters, valid age for a cat is between 0 and 25 years
    if(age > 0 && age < 25) {
        this.age = age;
        return true;
    }
    return false;
}

如果验证的一部分setAge()包括对age属性的检查以确保对象只能老化,该怎么办?这种情况可能看起来像:

if (age > this.age && age < 25) {
    //...

这似乎是一个无辜的改变,因为猫只能沿一个方向移动。唯一的问题是您不能使用它来设置的初始值,this.age因为它假定this.age已经具有有效值。

避免在构造函数中使用setter可以清楚地表明,构造函数要做的唯一事情就是设置实例变量,并跳过setter中发生的任何其他行为。


好的...但是,如果您实际上没有测试对象的构造并暴露出潜在的错误,则两种方式都有潜在的错误。现在你有两个2问题:您有一个隐藏的潜在问题,必须强制执行的年龄范围检查在两个地方。
svidgen '16

没问题,因为在Java / C#中,所有字段在构造函数调用之前都清零了。
Deduplicator

2
是的,很难从构造函数调用实例方法,因此通常不建议这样做。现在,如果要将验证逻辑移到私有的最终类/静态方法中,并从构造函数和设置方法中调用它,那就很好了。
没用的2016年

1
不,非实例方法避免了实例方法的棘手问题,因为它们不涉及部分构造的实例。当然,您仍然需要检查验证结果,以确定构造是否成功。
无用

1
恕我直言,这个答案是关键所在。“在构造对象时,按照定义它是不完全形成的” -当然,在构造过程的中间,但是在构造函数完成后,必须保留对属性的任何预期约束,否则可以规避该约束。我认为这是一个严重的设计缺陷。
布朗

6

这里已经有一些不错的答案,但是到目前为止,似乎没有人注意到您在这里问两件事,这引起了一些混乱。恕我直言,最好将您的帖子视为两个独立的问题:

  1. 如果在setter中有一个约束的验证,那么它是否也不应该在类的构造函数中以确保不能被绕过?

  2. 为什么不为此直接从构造函数中调用setter?

第一个问题的答案显然是“是”。构造函数在不引发异常的情况下应将对象置于有效状态,并且如果某些属性禁止使用某些值,则让ctor绕开它是绝对没有道理的。

但是,第二个问题的答案通常是“否”,只要您不采取措施避免在派生类中覆盖设置器即可。因此,更好的选择是在私有方法中实施约束验证,该方法可以从setter以及构造函数中重新使用。我不会在这里重复@Useless示例,该示例完全显示了此设计。


我仍然不明白为什么最好从setter中提取逻辑,以便构造函数可以使用它。... 与以合理的顺序简单地致电二传手相比,这真的有何不同?特别要考虑的是,如果您的设置方法简单到“应该”的程度,那么此时构造函数唯一调用的部分就是基础字段的实际设置...
svidgen

2
@svidgen:请参见JimmyJames示例:简而言之,在ctor中使用可重写的方法不是一个好主意,否则会引起问题。您可以在ctor中使用setter(如果它是最终的,并且不调用任何其他可重写方法)。此外,将验证与验证失败时的处理方式分开可以为您提供在ctor和setter中以不同方式处理验证的机会。
布朗

好吧,我现在更没有说服力了!但是,感谢您为我指出了其他答案...
svidgen '16

2
通过在一个合理的顺序,你的意思是用易碎的,看不见的排序依赖容易被无辜外观的变化打破,对吧?
没用的2016年

@无用的好吧,不...我的意思是,很有趣的是,您建议代码的执行顺序无关紧要。但这并不是重点。除了人为的例子外,我只是想不起来一个实例,在我的经验中,我认为在setter中进行跨属性验证是一个好主意-这种事情必然导致“看不见的”调用顺序需求。不管这些调用是否在构造函数中发生
。.– svidgen

2

这是一段愚蠢的Java代码,演示了在构造函数中使用非最终方法会遇到的各种问题:

import java.util.regex.Pattern;

public class Problem
{
  public static final void main(String... args)
  {
    Problem problem = new Problem("John Smith");
    Problem next = new NextProblem("John Smith", " ");

    System.out.println(problem.getName());
    System.out.println(next.getName());
  }

  private String name;

  Problem(String name)
  {
    setName(name);
  }

  void setName(String name)
  {
    this.name = name;
  }

  String getName()
  {
    return name;
  }
}

class NextProblem extends Problem
{
  private String firstName;
  private String lastName;
  private final String delimiter;

  NextProblem(String name, String delimiter)
  {
    super(name);

    this.delimiter = delimiter;
  }

  void setName(String name)
  {
    String[] parts = name.split(Pattern.quote(delimiter));

    this.firstName = parts[0];
    this.lastName = parts[1];
  }

  String getName()
  {
    return firstName + " " + lastName;
  }
}

当我运行它时,我在NextProblem构造函数中得到一个NPE。这当然是一个简单的例子,但是如果您具有多个继承级别,事情可能会很快变得复杂。

我认为这没有变得普遍的更大原因是它使代码更难理解。就我个人而言,我几乎从未拥有过setter方法,并且我的成员变量几乎总是(最终目标是双关语)。因此,必须在构造函数中设置值。如果您使用不可变的对象(这样做有很多充分的理由),那么这个问题就没有意义了。

无论如何,重用验证或其他逻辑是一个好目标,您可以将其放入静态方法中,然后从构造函数和设置方法中调用它。


这完全是在构造函数中使用setter的问题,而不是继承实现不佳的问题吗???换句话说,如果您实际上使基本构造函数无效,那为什么还要调用它!
svidgen

1
我认为关键是,重写设置器不应使构造函数无效。
克里斯·沃勒特

@ChrisWohlert好的...但是,如果您更改了setter逻辑以使其与构造函数逻辑冲突,则无论您的构造函数是否使用setter都是一个问题。它要么在构造时失败,要么以后失败,或者它不可见地失败(b / c您绕过了业务规则)。
svidgen '16

您通常不知道基类的构造函数是如何实现的(它可能在第3方库中的某个位置)。但是,重写可重写方法不应破坏基本实现的约定(并且可以说,此示例通过不设置name字段来这样做)。
绿巨人

@svidgen这里的问题是,从构造函数中调用可覆盖方法会降低覆盖它们的有用性-您无法在覆盖版本中使用子类的任何字段,因为它们可能尚未初始化。更糟糕的是,您不能将this-reference 传递给其他方法,因为您尚不能确定其是否已完全初始化。
绿巨人

1

这取决于!

如果您要在设置器中执行简单的验证或发出顽皮的副作用,而每次设置该属性时都需要点击该副作用:请使用设置器。通常,替代方法是从设置器中提取设置器逻辑,并调用新方法,然后再从设置器构造函数中调用实际的设置,这实际上与使用设置器相同!(除了现在,您要在两个地方维护公认的小型两行装订器。)

但是,如果您需要执行复杂的操作以一个特定的设置器(或两个!)可以成功执行,请不要使用该设置器。

无论哪种情况,都有测试用例。无论走哪条路,如果有单元测试,您都有很大的机会揭露与这两个选项相关的陷阱。


我还要补充一点,如果我的记忆远未到那时,那也是我在大学时所教的那种推理。而且,这与我一直无法完成的成功项目完全不矛盾。

如果不得不猜测,我在某个时候错过了“干净的代码”备忘录,该备忘录指出了在构造函数中使用setter可能导致的一些典型错误。但是就我而言,我并没有成为任何此类错误的受害者...

因此,我认为这个特定的决定不必太笼统和教条。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.