面向对象编程入门 - Janos Pasztor


你已经编程了一段时间,你仍然难以接受面向对象编程的实际情况?那么这可能是你的指南。我们将偏离传统的解释,并寻找一种解释OOP的新方法。
我们马上就会从一些代码开始。请记住,本文中的示例是用Java-esque表示法编写的,但是所有内容都可以轻松应用于任何OOP编程语言,无论是PHP,Python还是Go。

那么,让我们来看一个类:

class Student {
  string name;
}

一个类就像一个蓝图(banq注:blueprint是蓝图 原图 计划大纲,做事之前的计划和主观观念)。如果您想获取此蓝图并创建实际实例,请按以下步骤操作:

Student myStudent = new Student();
myStudent.name = "Janos";

myStudent变量现在包含一个Student类的副本,其name变量设置为我的名字。您也可以轻松创建第二个副本:

Student myStudent = new Student();
myStudent.name = "Janos";

Student yourStudent = new Student();
yourStudent.name =
"Your name";

如果运行此代码,这两个实例将存在于不同的内存空间中; 你可以独立修改它们。
到现在为止还挺好?好吧,那就让我们做一些更先进的事情吧。到目前为止我们所做的是创建一个数据结构。您可以根据需要添加任意数量的变量,但这不仅仅是一种组织数据的方法。让我们通过向我们的类添加方法来改变它。

class Student {
  string name;
  void setName(string name) {
    this.name = name;
  }
}

如您所见,该setName方法设置名称变量。你问为什么要这么做?让我们假设您想要检查名称是否为空的情况:

class Student {
  string name;
  
  void setName(string name) {
    if (name == "") {
      throw new InvalidArgumentException(
"The name must not be empty!");
    }
    this.name = name;
  }
}

这很不错,但正如你所看到的,这个name领域仍然可以解开。我们需要一些方法来强制执行name字段的初始化。这就是构造函数发挥作用的地方。

构造函数是一个特殊函数,在实例化类时执行。通常,它与类共享名称,但在某些语言(如PHP)中,它具有不同的名称。创建构造函数就像创建方法一样简单:

class Student {
  string name;
  
  Student(string name) {
    this.setName(name);
  }
  
  void setName(string name) {
    if (name == "") {
      throw new InvalidArgumentException(
"The name must not be empty!");
    }
    this.name = name;
  }
}

Student myStudent = new Student(
"Janos");

换句话说,您在实例创建时输入的参数自动最终作为构造函数的参数。

保卫数据!(封装)
如您所见,我们将数据绑定到功能。一个好的类可以让你使用它,而不必知道它是如何实现的或如何在内部存储数据。但是,我们有一个小问题。通过当前设置,我们可以通过name直接设置变量来轻松绕过验证逻辑:

Student myStudent = new Student("Janos");
myStudent.name    =
"";

幸运的是,大多数OOP语言为我们提供了禁用对成员变量和方法的外部访问的工具,称为 可见性关键字。通常,我们会区分这些级别的可见性:

  • public:每个人都可以访问使用此关键字标记的方法。在大多数OOP语言中,这是默认语言。
  • protected:只有子类可以访问方法或变量(我们稍后会讨论这个)。
  • private:只有当前类可以访问方法或变量。(注意,同一类的其他实例通常也可以访问它!)

使用这些关键字,我们可以使代码更安全:

class Student {
  private string name;
  
  public void setName(string name) {
    if (name == "") {
      throw new InvalidArgumentException(
"The name must not be empty!");
    }
    this.name = name;
  }
}

如果我们现在尝试name直接访问成员变量,我们将从编译器中得到错误。除此之外,具有明确的可见性标记使我们的代码更具描述性并且更易于阅读。
作为一般规则,您不应该将类用作函数容器; 它应该用于保存与类相关的数据。

类协作
要使OOP有用,您将需要多个类。如果您正在编写显示文章的网站,您可能希望拥有一个可以从数据库中获取原始数据的类。您还需要一个将原始数据(例如markdown或LaTeX)转换为输出格式的类,例如HTML或PDF。
作为一种自然的方法,我们可以做这样的事情:

class HTMLOutputConverter {
  private MySQLArticleDatabaseConnector db;
  
  public HTMLOutputConverter() {
    this.db = new MySQLArticleDatabaseConnector();
  }
}

如您所见,HTMLOutputConverter依赖于MySQLArticleDatabaseConnector我们在输出转换器的构造函数中创建数据库连接器的实例。为什么这么糟糕?

  • 你不能用不同的类替换MySQLArticleDatabaseConnector。
  • 由于MySQLArticleDatabaseConnector创建了实例HTMLOutputConverter,该类需要知道需要传递给的所有参数MySQLArticleDatabaseConnector。
  • 在查看类定义时,依赖性不会立即显现出来。你必须查看代码才能发现存在依赖关系。

让我们看看我们是否可以做得更好。我们不是创建实例MySQLArticleDatabaseConnector,而是将其作为参数请求。像这样的东西:

class HTMLOutputConverter {
  private MySQLArticleDatabaseConnector db;
  
  public HTMLOutputConverter(MySQLArticleDatabaseConnector db) {
    this.db = db;
  }
}

此构造称为依赖注入。您依赖的类是注入的,而不是直接创建的。必须手动创建依赖项似乎很麻烦,但有一些工具可以帮助您; 它们被称为依赖注入容器。值得注意的例子包括Google Guice, AurynPython DIC
依赖注入解决了第二和第三个问题,但没有解决第一个问题。所以让我们创建一个新的语言结构并将其称为接口。接口将描述类需要实现的方法,而不实际指定它们的代码。像这样的东西:

interface ArticleDatabaseConnector {
  public Article getArticleBySlug(string slug);
}

因此,接口将描述类可以实现的功能。在这种情况下,我们将描述一个接受slug参数并返回Article一个响应的方法。您可以像这样编写实现类:

class MySQLArticleDatabaseConnector implements ArticleDatabaseConnector {
  public Article getArticleBySlug(string slug) {
    //Query data from the MySQL database and return the article object.
  }
}

如您所见,MySQLArticleDatabaseConnector实现ArticleDatabaseConnector,需要实现其行为。这使我们能够修改HTMLOutputConverter依赖于接口,而不是实际的实现:

class HTMLOutputConverter {
  private ArticleDatabaseConnector db;
  
  public HTMLOutputConverter(ArticleDatabaseConnector db) {
    this.db = db;
  }
}

由于HTMLOutputConverter依赖于接口,我们可以自由地为我们喜欢的接口创建任何实现,无论是MySQL,Cassandra还是Google Cloud SQL。当一个抽象可以有许多形式,许多实际实现时,我们称之为多态。
当我们以这样的方式使用多态时,你的类依赖于抽象而不是具体,我们也将它称为依赖性反转,以突出我们已经颠倒了依赖性的事实。
但这只是花哨的极客 - 说的是你不应该把你的类焊接在一起。您可以将接口想象为实现它的类与使用它的类之间的契约。此契约描述了实现类必须提供的功能,并且使用类可以依赖于它。

让我们概括接口!(抽象)
您可能已经猜到,接口并不是创建抽象的唯一工具。事实上,接口被发明为Java中的一种解决方法,用于称为多重继承。这带来了一个显而易见的问题:什么是继承?

让我们想象一下,当强制具体指定某个特定行为是不够的时候,你真的想要将“一些代码”传递给你现有的抽象。实际上,您的抽象必须为您提供实现“某些方法”的可能性,这些方法接口显然无法实现。

这就是继承发挥作用的地方。从本质上讲,继承意味着如果一个类Foo扩展了类Bar,它将继承它的所有方法和变量。换句话说,您可以编写如下代码:

class Bar {
  protected string baz;
}

class Foo extends Bar {
  public void setBaz(string baz) {
    this.baz = baz;
  }
}

如您所见,子类声明了一个方法,该方法设置从父类继承的变量,这是可能的,因为可见性rules(protected)允许它。如果我们将变量设置baz为private,则此代码将不起作用。
有趣的是,在上面的例子中,你可以实例化Bar和Foo。如果你想限制它,你必须声明Bar该类abstract。除此之外,您还可以添加没有正文的抽象方法,并且必须由子类实现:

abstract class Bar {
  protected string baz;
  
  abstract void setBaz(string baz);
}

class Foo extends Bar {
  public void setBaz(string baz) {
    this.baz = baz;
  }
}

那么一个类可以有多少父类?一?二?五?答案是:这取决于。有些语言,比如C ++,已经解决了多重继承的问题。因此,接口语言构造甚至不存在于C ++中。其他语言,如Java或PHP,决定不处理这个问题,而是发明接口。换句话说,接口只是抽象类,只有抽象方法,没有变量来规避必须解决多重继承。
谨防错误的抽象!许多OOP教程都带有从矩形继承的正方形的例子。这仅在数学意义上是正确的。在编程中,您希望子类的行为与它们的父类相同,因为矩形具有两个独立的边,而正方形则没有。

避免全局状态
某些语言(如Java)引入了一个名为的特殊关键字static。它颠倒了这样一个事实,即每个实例都有自己的内存空间,并在所有实例中创建共享内存空间。有很多种方法可以使用它。

一个值得注意的例子是单例模式:

class DatabaseConnection {
  private static DatabaseConnection instance;

  public static DatabaseConnection getInstance() {
    if (!self::instance) {
      self::instance = new DatabaseConnection();
    }
    return self::instance;
  }
}

DatabaseConnection db = DatabaseConnection::getInstance();

第一次调用时getInstance,将创建一个实例。任何进一步的调用都将返回该初始实例。

使用static的问题在于它创建了一个有时隐藏的全局状态。您无法创建一个真正独立的类实例,这使得测试和其他操作变得棘手。
通常,您应该尽可能避免全局状态。虽然静态不是创建全局状态的唯一方法,但它是最相关的方式之一。如果可能的话,最好避免使用静态,并且如上所述进行依赖注入。
提示: static确实有一些合法的用途,但一般来说,应始终考虑替代方案。

类责任
拥有状态的对象与经典的基于函数的编程不同,您只需传递数据。学习OOP时,尽量避免使对象成为纯函数容器,并将数据与功能集成。
但是,在创建类时,请始终考虑其责任。虽然很容易将与一项任务相关的所有内容都放入类中,但这样做可能并不明智。如果你有学生管理软件,你可能会想做这样的事情:

class Student {
  private string id;
  private string name;
  
  public void setId(string id) { ... }
  public void setName(string name) { ... }
  
  public void save() { ... }
}

正如您在此场景中所看到的那样,处理学生数据并将其保存到某种类型的数据库将属于同一个类。实际上,这些是完全独立的两个任务,并且没有任何业务存在。虽然我们不会在本文中详细介绍,但建议您将课程简洁明了并专注于单个任务。

未来的步骤
这些只是最基本的OOP概念。实际上,人们可以遵循许多想法和设计模式,但很少有程序员可以用心命名。不要害怕尝试,更重要的是,不要害怕失败。OOP和编写可维护代码一般都很难,所以在对结果感到满意之前,您可能需要尝试几次。在以后的文章中,我们将详细介绍可以帮助您编写更好,更易维护的代码的概念和想法,因此请务必保持关注。