【问题标题】:What defines a valid object state?什么定义了有效的对象状态?
【发布时间】:2017-04-05 16:29:10
【问题描述】:

我正在阅读 an article 关于构造函数做太多工作的信息。 一段写着

在面向对象的风格中,依赖关系往往是倒置的,构造函数具有不同的且更加斯巴达式的角色。它唯一的工作是确保对象初始化到满足其基本不变量的状态(换句话说,它确保对象实例以有效状态开始,仅此而已)。

这是一个类的基本示例。在创建类时,我传入需要解析的 HTML,然后设置类属性。

OrderHtmlParser
{
    protected $html;

    protected $orderNumber;

    public function __construct($html)
    {
        $this->html = $html;
    }

    public function parse()
    {
        $complexLogicResult = $this->doComplexLogic($this->html);

        $this->orderNumber = $complexLogicResult;
    }

    public function getOrderNumber()
    {
        return $this->orderNumber;
    }

    protected function doComplexLogic($html)
    {
        // ...

        return $complexLogicResult;
    }
}

我正在使用它来调用它

$orderparser = new OrderHtmlParser($html);
$orderparser->parse()
$orderparser->getOrderNumber();

我使用parse 函数是因为我不希望构造函数执行任何逻辑,因为上述文章和this article 都表示这是一种糟糕的做法。

public function __construct($html)
{
    $this->html = $html;
    $this->parse(); // bad
}

但是,如果我不使用 parse 方法,那么我的所有属性(在此示例中仅为一个)将返回 null

这是否称为处于“无效状态”的对象?

另外,有点感觉我的 parse 方法是变相的initialise 函数,另一篇文章也认为这很糟糕(尽管我不确定这是否仅在构造函数调用时该方法,当它被手动调用或两者兼而有之时)。无论如何,initialise 方法仍然在设置属性之前执行一些复杂的逻辑 - 这需要在 getter 被可靠地调用之前发生。

所以要么我误解了这些文章,要么这些文章让我认为我对这个简单类的整体实现可能是不正确的。

【问题讨论】:

  • 考虑不将$html 传递给构造函数,而是传递给parse 方法。然后其他方法的行为(返回 null)更有意义。您可以 仍然允许将参数传递给构造函数(可选),但是构造函数应该调用对象的 parse 方法。显然,可选行为不符合文章中表达的愿景,但我认为它可以接受。
  • 谢谢你帮了我很多。
  • @myol - 我更新了我的回复,增加了对一般构造函数和构造函数异常的考虑。

标签: php oop design-patterns


【解决方案1】:

通常,在构造函数中执行工作是一种代码异味,但这种做法背后的原因更多地与编程语言有关,而不是对最佳实践的看法。确实存在会引入错误的极端情况。

在某些语言中,派生类的构造函数是自下而上执行的,而在其他语言中是自上而下执行的。在 PHP 中,它们是从上到下调用​​的,您甚至可以通过不调用 parent::__construct() 来停止链。

这会在基类中创建未知的状态预期,更糟糕的是,PHP 允许您在构造函数中首先或最后调用 parent。

例如;

class A extends B {
     public __construct() {
           $this->foo = "I am changing the state here";
           parent::__construct(); // call parent last
     }
}

class A extends B {
     public __construct() {
           parent::__construct(); // call parent first
           $this->foo = "I am changing the state here";
     }
}

在上面的示例中,B 类的构造函数以不同的顺序调用,如果 B 在构造函数中做了很多工作,那么它可能不是程序员所期望的状态。

那么你如何解决你的问题呢?

你需要两个类。一个包含解析器逻辑,另一个包含解析器结果。

class OrderHtmlResult {
      private $number;
      public __construct($number) {
            $this->number = $number;
      }
      public getOrderNumber() {
            return $this->number;
      }
}

class OrderHtmlParser {
      public parse($html) {
          $complexLogicResult = $this->doComplexLogic($this->html);
          return new OrderHtmlResult($complexLogicResult);
      }
}

$orderparser = new OrderHtmlParser($html);
$order = $orderparser->parse($html)
echo $order->getOrderNumber();

在上面的示例中,如果 parse() 方法无法提取订单号,您可以返回 null,或者抛出一个示例。但是这两个类都不会进入无效状态。

这种模式有一个名称,其中一个方法产生另一个对象作为结果以封装状态信息,但我记得它叫什么。

【讨论】:

  • 我认为这种模式称为过程式编程。将逻辑与数据分离是 OOP 的对立面,OOP 的主要目标是将逻辑与数据结合
  • 谢谢,我最终采用了这种方法,以便我的格式化程序和验证程序可以预期使用接口解析的对象
【解决方案2】:

这是否称为处于“无效状态”的对象?

是的。你说得对,parse 方法是伪装的initialise 函数。

为了避免初始化解析,懒惰。最懒惰的方法是消除$orderNumber 字段并从getOrderNumber() 函数内部的$html 解析它。如果您希望该函数被重复调用和/或您希望解析很昂贵,则保留 $orderNumber 字段但将其视为缓存。在getOrderNumber() 中检查null 并仅在第一次调用时将其解析出来。


关于链接的文章,我原则上同意构造函数应该限于字段初始化;但是,如果这些字段是从文本块中解析出来的,并且期望客户端将使用大部分或全部解析值,那么延迟初始化就没有什么价值了。 此外,当文本解析不涉及 IO 或newing 域对象时,它不应该妨碍黑盒测试,因为急切和延迟初始化是不可见的。

【讨论】:

    【解决方案3】:

    当构造函数做“太多”时发生的一个常见问题是,有些紧密链接的两个对象需要相互引用(是的,紧密链接是一种不好的气味,但它确实发生了)。

    如果对象 A 和对象 B 必须相互引用才能“有效”,那么如何创建呢?

    答案通常是您的构造函数使对象不完全“有效”,您添加对另一个无效对象的引用,然后调用某种 finalize/initialize/start 方法来完成并使您的对象有效。

    如果您仍想“安全”,您可以通过在对象“有效”之前调用未初始化异常来保护您的业务方法。

    依赖注入有这个问题的通用版本,如果你有一个注入类的循环循环呢?遵循构造/初始化模式也解决了一般情况,因此 DI 总是使用该模式。

    【讨论】:

      【解决方案4】:

      解决标题中的问题我一直认为一个对象处于有效状态时,它可以在没有任何问题的情况下执行其工作;也就是说,它按预期工作。

      在查看链接文章时,我突然想到的是构造函数逻辑正在创建很多对象:我数了 7。所有这些对象都与有问题的类 (ActiveProduct) 紧密耦合,因为它们被直接引用并且构造函数将 this 指针传递给其他对象的构造函数:

          VirtualCalculator = new ProgramCalculator(this, true);
          DFS = new DFSCalibration(this);
      

      在这种情况下,ActiveProduct 还没有完成初始化,ProgramCalculator 和 DFSCalibration 可以通过方法和属性回调到 ActiveProduct 中并导致各种恶作剧,因此该代码是高度可疑的。 通常,在 OOP 中,您希望将对象传递给构造函数,而不是在构造函数中实例化它们。您还想使用Dependency Inversion Principle 并在将对象传递给允许dependency injection 的构造函数时使用接口或抽象/纯虚拟类。

      对于您的 OrderHtmlParser 类,这似乎不是问题,因为所讨论的复杂逻辑似乎并未超出 OrderHtmlParser 类。我很好奇为什么 doComplexLogic 函数被定义为受保护的,暗示继承类可以调用它。

      也就是说如何处理初始化可能就像将 Parse 方法设为静态并使用它来构造 OrderHtmlParser 类的实例并将构造函数设为私有一样简单,这样调用者就必须调用 Parse 方法来获取实例:

      OrderHtmlParser
      {
          protected $html;
      
          protected $orderNumber;
      
          private function __construct()
          {
      
          }
      
          public static function parse($html)
          {
              $instance = new OrderHtmlParser();
      
              $instance->html = $html;
      
              $complexLogicResult = $instance->doComplexLogic($this->html);
      
              $instance->orderNumber = $complexLogicResult;
      
              return $instance;
          }
      
          public function getOrderNumber()
          {
              return $this->orderNumber;
          }
      
          protected function doComplexLogic($html)
          {
              // ...
      
              return $complexLogicResult;
          }
      }
      

      【讨论】:

        【解决方案5】:

        我完全同意@trincot 的评论:

        当您使用构造函数创建解析器时,无需传递 html。

        也许您想再次使用 Parser 对象和另一个 Input。

        所以为了有一个干净的构造函数,我使用了一个 reset() 函数,它也在开始时被调用,它重置了对象的初始状态。

        例子:

        class OrderHtmlParser
        {
          protected $html;
          protected $orderNumber;
        
          public function __construct()
          {
            $this->reset();
          }
        
          public function reset()
          {
            $this->html = null;
            $this->orderNumber = null;
          }
          /**
           * Parse the given Context and return the result
           */ 
          public function parse($html)
          {
            // Store the Input for whatever
            $this->html = $html;
            // Parse
            $complexLogicResult = $this->doComplexLogic($this->html);
            // Store the Result for whatever
            $this->orderNumber = $complexLogicResult;
            // return the Result
            return $this->orderNumber;
          }
        
          public function getOrderNumber(){}
          protected function doComplexLogic($html){}
        }
        

        这样,Parsing Object可以做什么,它应该做什么:

        尽可能频繁地解析:

        $parser = new OrderHtmlParser();
        $result1 = $parser->parse($html1);
        $parser->reset();
        $result2 = $parser->parse($html2);
        

        【讨论】:

        • 谢谢,重置功能的想法现在似乎很明显,但我完全忽略了这一点。今后我一定会考虑这一点
        【解决方案6】:

        感谢您提出的精彩问题!

        这是一个容易出错的设计,将大量数据传递给构造函数,构造函数只会将其存储在对象中以便以后处理这些大数据。

        让我再引用一次你的美言(粗体标记是我的):

        在面向对象的风格中,依赖关系往往是倒置的,构造函数具有不同的、更斯巴达式的角色。 它的唯一工作是确保对象初始化到满足其基本不变量的状态(换句话说,它确保对象实例以有效状态开始,仅此而已)。

        您的示例中解析器类的设计很麻烦,因为构造函数接受输入数据,这是要处理的真实数据,而不仅仅是下面引用中提到的“初始化数据”,而实际上并不处理数据。

        根据加州大学圣克鲁斯分校的 Ira Pohl 教授的说法,构造函数的主要作用是:(1) 初始化对象,(2) 当类具有不同的构造函数时转换值,每个构造函数都有不同的参数列表 -这个概念被称为构造函数重载,(3) 检查正确性 - 当构造函数参数被检查是否属于合法范围时。

        无论如何,即使构造函数是用来初始化、转换和检查的,它也很快发生,没有明显的延迟。

        在非常古老的编程课程中,在 1980 年代,我们被告知程序有输入和输出。

        将 $html 视为程序输入。

        构造函数不应该接受程序输入。它们只应该接受配置、初始化数据(如字符集名称)或其他以后可能不会提供的配置参数。如果它们接受大数据,有时可能需要它们抛出异常,而构造函数中的异常是一种非常糟糕的风格。最好避免构造函数中的异常,以使代码更容易理解。例如,您可以将文件名传递给构造函数,但不应在构造函数中打开文件,等等。

        让我稍微修改一下你的类。

        enum ParserState (undefined, ready, result_available, error);
        
        
        OrderHtmlParser
        {
        
            protected $orderNumber;
            protected $defaultEncoding;
            protected ParserState $state;
        
            public function __construct($orderNumber, $defaultEncoding default "utf-8")
            {
                $this->orderNumber = $orderNumber;
                $this->defaultEncoding = $defaultEncoding;
                $this->state = ParserState::ready;
            }
        
            public function feed_data($data)
            {
                if ($this->state != ParserState::ready) raise Exception("You can only feed the data to the parser when it is ready");
        
                // accumulate the data and parse it until we get enough information to make the result available
        
                if we have enough result, let $state = ParserState::resultAvailable;
            }
        
            public function ParserState getState()
            {
                return $this->state
            }
        
            public function getOrderNumber()
            {
                return $this->orderNumber;
            }
        
            protected function getResult($html)
            {
                if ($this->state != ParserState::resultAvailable) raise Exception("You should wait until the result is available");
        
                // accumulate the data and parse it until we get enough information to make the result available
            }
        }
        

        如果你认为类有一个明显的设计,使用你的类的程序员不会忘记调用任何方法。您最初问题中的设计存在缺陷,因为与逻辑相反,构造函数确实获取了数据但没有对它做任何事情,并且需要一个不明显的特定功能。如果你让设计简单明了,你甚至不需要状态。只有那些长期积累数据直到结果就绪的类才需要状态,例如状态机,例如,从 TCP/IP 套接字异步读取 HTML 以将此数据提供给解析器。

        $orderparser = new OrderHtmlParser($orderNumber, "Windows-1251");
        repeat
          $data = getMoreDataFromSocket();
          $orderparser->feed_data($data);
        until $orderparser->getState()==ParserState::resultAvailable;
        $orderparser->getResult();
        

        至于你最初关于对象状态的问题:如果你设计一个类,构造函数只获取初始化数据,而有方法接收和处理数据,那么就没有单独的函数来存储数据和解析可能忘记调用的数据——不需要状态。如果您仍然需要按顺序收集或提供数据的长期对象的状态,您可以使用上面示例中的枚举类型。我的示例是抽象语言,而不是特定的编程语言。

        【讨论】:

        • 谢谢,您的回答通过解释输入和输出确实解释了您的方法的原因。你给了我一个完全(虽然微妙)不同的方式来思考未来的对象交互。周末我很忙,所以自动分配了赏金,所以由于这个答案,我添加了另一个给你。
        • "应不惜一切代价避免构造函数中的异常。" 为什么?
        • @jaco0646 我提倡简单的构造函数。我更喜欢将所有复杂的任务移出构造函数。这使得程序设计更精简,程序变得更容易理解和管理,用于持续和正在开发数十年的长期产品。
        • @jaco0646 由于复杂的构造函数(通常会抛出异常),您必须为构造函数中抛出的异常阻止析构函数调用的情况做好准备,因此您应该编写两次释放从构造函数分配的东西的代码:首先在构造函数的“try/catch”块中(将在异常情况下运行),然后在析构函数中。当然,您可以创建一个函数并从构造函数的“catch”和析构函数中调用它,但这会导致设计不那么精简。
        • @jaco0646 释放我的意思主要是资源,如文件、线程、信号量和其他不能定期释放的东西,如内存。
        猜你喜欢
        • 1970-01-01
        • 2011-01-22
        • 2012-08-19
        • 2018-07-12
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多