面向对象编程
最初发表于sasablagojevic.com
本文的目的是以更易于理解的形式为新手开发人员分解 OOP 的主要概念,我希望他们阅读后能够更好地理解面向对象范式。
我们都知道,大多数新手开发者都喜欢亲自动手,而不是阅读,所以我会尽量少说废话。当我还是个编程新手,刚开始钻研面向对象编程(OOP)的世界时,学习什么是类、什么是对象是比较容易的部分。但把它们应用到我试图解决的实际问题中,才是棘手的部分。
- 我该如何构建我的代码?
- 我的班级应该关注什么?
- 我是否应该将这个类拆分成更多类?
- 我的类的公共 API 应该是什么样的?
- 方法应如何与类名一起命名?
- 这个方法应该是静态的吗?
这些都是我问自己的问题。
由于我是自学成才的开发人员,缺乏一些理论知识,所以我决定阅读更多关于面向对象编程 (OOP) 和设计模式的书籍。以前,我做事比较凭直觉,而且效果很好。我走在正确的道路上,我的职业生涯也取得了进步,而且没有人抱怨我的代码,相反,没有人抱怨。但总有一天,你需要更深入地理解事物的“底层工作原理”,这样才能将你的知识和职业生涯提升到一个新的高度。
本文旨在为新手开发者解析面向对象编程 (OOP) 的主要概念,希望他们读完后能够更好地理解面向对象范式。它旨在作为一份速查表,方便他们(包括我自己)随时复习知识 ;)
条条大路通罗马
为了更好地理解面向对象编程 (OOP) 以及人们试图用它解决的问题,我们将简要介绍一些不同的、最常见的编程范式。虽然大多数现代语言在其发展历程中的某个阶段都采用了多范式方法,但它们可能在发展初期并没有采用过。从汇编语言到 PHP,我们经历了一个反复迭代的过程,才走到了今天。
编程范式
- 至关重要的
- 程序
- ...
- 结构化
- 面向对象
- ...
- 声明式
- 功能
- ...
在 命令式编程中,我们向计算机发出指令,指示它做什么以及按什么顺序执行。每当我们通过将某些内容存储在变量中来让计算机记住它时,我们就是在使用 语句来更改程序的全局状态。您已经可以看到,在有大量开发人员的大型程序中,这会带来多么混乱的情况,因为所有开发人员都在直接更改全局状态,因此出现错误和覆盖数据的可能性很高。
过程式编程虽然经常被用作命令式编程的同义词,但实际上是命令式范式的扩展。在过程式编程中,我们将上述指令分组为过程,也称为子例程或函数。过程只是一组指令,一个模块化单元,我们只需调用它即可在程序中重用它,而不必重新编写具体步骤。但此时,您已经知道这一点;)过程式编程为我们带来了 块和 作用域的概念,这给了我们一种新的状态类型,即本地状态。本地状态意味着它仅在特定过程的上下文中有效。
我们可以看到,过程编程的重点是将计算机指令分解为变量和子例程/函数,而面向对象编程的重点是将其分解为对象。
对象通过方法(类/对象函数)暴露其行为,并以成员/属性 的形式拥有自己的内部状态。方法和属性是否对外界可访问取决于它们的可见性。
所以你看,过程式和面向对象范式都在试图解决同样的问题:以各自的方式改变全局状态并将复杂任务分解为更小的模块单元(子程序/函数与对象)。
面向对象编程的核心就是发送消息并响应消息。
在过程式编程中,一个过程在一个地方表达,其中一系列指令按顺序编码。而在面向对象编程中,一个过程则被表达为跨对象的一系列消息。(David Chelimsky,《单一职责应用于方法》)
在我看来, “告诉而不是询问”原则最能体现这一点。
不要询问完成工作所需的信息;而要询问拥有信息的对象,以便为你完成工作。(艾伦·霍卢布 - 霍卢布论模式)
让我们用通俗易懂的语言来解释一下。与过程式和函数式范式相反,在面向对象编程 (OOP) 中,我们会将数据从一个函数传递到另一个函数,对其进行操作并返回。而我们希望封装该数据(保持其内部状态)的对象能够为我们进行操作。我们通过向其发送消息来实现这一点。 收到消息后,该对象将根据其内部知识和方法来确定返回什么。
更简单地说,消息就是我们的方法调用,而响应则是这些方法返回的数据。记住,方法可以是 void,这意味着它们不返回任何数据,它们只是改变对象的内部状态,但你明白它的意思了。
因此,从实际角度来说,它看起来应该是这样的。*免责声明* 这是一个高度简化的示例,仅用于说明目的。
function mark_as_read(array $email)
{
$email['is_read'] = 1;
return $email;
}
$email = [
"from" => "Marry Doe",
"to" => "John Doe",
"subject" => "Dear John",
"body" => "I\'m leaving you"
];
mark_as_read($email);
// vs.
class Email {
protected $from;
protected $to;
protected $subject;
protected $body;
protected $isRead;
public function __constructor(string $from, string $to, string $subject, string $body)
{
$this->from = $from;
$this->to = $to;
$this->subject = $subject;
$this->body = $body;
}
public function markAsRead(): Email
{
$this->isRead = 1;
return $this;
}
public function isRead(): bool
{
return $this->isRead === 1;
}
}
$email = new Email("Marry Doe", "John Doe", "Dear, John", "I'm leaving you.");
$email->markAsRead();
面向对象编程之父之一艾伦·凯(Allan Kay)有一个很好的比喻,他说 对象就像我们身体里的细胞,是构成我们人类的小而独立的单元。
好吧,我们现在知道如何把函数和数据塞进一个类里,并不意味着我们懂面向对象编程 (OOP)。现在就把所有东西都变成类,这是新手常犯的一个错误。还有一些事情需要我们牢记!:D 但在进一步讨论之前,我们先简单回顾一下最后两种范式。
与命令式范式相反,声明式范式告诉计算机我们想要什么,而不是如何获取它的步骤。SQL 就是声明式编程语言的一个完美示例。
函数式范式是声明式范式的一个子集,它使用与命令式范式不同的声明/表达式,但与命令式范式类似,它也试图解决操纵全局状态的问题。在函数式编程中,程序被认为是纯函数的集合。纯函数是接受输入并始终返回新值的函数。它们永远不会产生副作用,永远不会改变状态,并且始终期望在给定相同输入的情况下给出相同的结果。因此,在函数式范式中,状态是 不可变的。
@AnjanaVakil在 WebcampZG 2017 的跨范式编程主题演讲中对此进行了更为雄辩的阐述,我建议您观看。
基本 OOP 原则
单一职责
一个类应该只关注并处理我们试图解决的复杂问题的一个方面。这并不意味着一个类实际上应该只承担一项职责,它不是一个函数,它可以有多个职责,但它们都必须属于一个更广泛的任务。当我们说单一职责时,我们是从业务/领域逻辑的角度来讨论的。例如,让我们看看 PHP 的内置SplFileObject,该类负责:
- 读取文件,
- 写入文件,
- 检查文件是否存在,
- 检查给定的路径是否是文件或目录等。
但所有这些操作都属于一个更广泛的任务,即与文件交互。
抽象
这里所说的抽象,指的是接口和抽象类的总称。在 PHP 中,它们是两种主要的抽象机制。
接口是行为的契约。我们使用它们来定义类的公共 API,并确保当消息发送到实现该接口的类实例(对象)时,该对象始终会根据定义进行响应,否则程序将失败。换句话说,接口定义了类需要实现的方法,以确保程序正常运行,否则程序将崩溃。
- 接口无法实例化
- 接口只能有方法签名
- 接口不能声明属性(尽管 PHP 允许接口声明常量)
- 接口只能有公共方法
抽象类 也是行为契约,但它的作用远不止于此。与接口,抽象类也可以像其他类一样,定义具体的方法和成员/属性(状态)。
- 抽象类不能被实例化
- 抽象类必须至少有一个抽象方法
- 抽象类可以有具体的方法
- 抽象类可以有成员/属性(属性)
- 抽象类可以具有所有可见性级别(公共、受保护和私有)
针对接口进行编程,而不是针对实现。
这是什么意思?简而言之,这意味着:将变量/属性的类型提示为抽象(接口/抽象类), 而不是具体类。
通过面向接口编程,你可以将代码的设计与实现解耦。这样,以后需要时,你就能更轻松地替换部分代码。没错,你的代码可维护性将大幅提升。这还能让你的代码更易于测试,因为你只需实现接口,将“生产”行为替换为“测试”行为,就能模拟系统的某些部分。
抽象将我们的注意力从方法的底层实现转移到它们的签名上,因为从某种意义上说,它们与我们无关,只要 遵循抽象的方法签名及其定义(参数和返回值),一切就都能正常工作。这使我们能够构建更健壮、更灵活的代码库。让我们看以下示例。
很好的例子- 由于我们针对接口而不是实现进行编程,因此更改存储方法只是通过App 的构造函数更改我们提供的类的问题 ,我们只需更改一行代码。
// Storable Interface
interface Storable {
public function store(array $data): bool;
}
// Database Storage
class Mysql implements Storable {
protected $conn;
public function __construct(PDO $conn)
{
$this->conn = $conn;
}
protected function insert(string $table, array $data): bool
{
$columns = implode(',', array_keys($data));
$placeholders = "?".str_repeat(",?", count($columns) - 1);
$values = array_values($data);
$sql = "INSERT INTO $table($columns) VALUES($placeholders)";
$stmt = $this->pdo->prepare($sql);
return $stmt->execute($values);
}
public function store(array $data): bool
{
return $this->insert('emails', $data);
}
}
// File Storage
class File implements Storable {
protected function write(string $file, array $data): bool
{
$file = new \SplFileObject($file.'.txt', 'a+');
if (!file_exists($file.'.txt')) {
$columns = implode(', ', array_keys($data));
$file->fwrite($values, strlen($values));
} else {
$file = new \SplFileObject($file, 'a+');
}
$values = implode(', ', array_values($data));
return (bool) $file->fwrite($values, strlen($values));
}
public function store(array $data): bool
{
return $this->write('emails', $data);
}
}
// Client
class App {
public function __construct(Storable $storable)
{
$data = [
'from' => 'foo@mail.com',
'to' => 'bar@mail.com',
'subject' => 'Hello',
'body' => 'World'
];
$storable->store($data);
}
}
// Databse App
$dbApp = new App(new Mysql(new Pdo(...$config)));
// File Storage App
$fileApp = new App(new File());
坏的例子 ——想象一下,我们想要在这个例子中改变存储方法,现在这将比前一个付出更多的努力。
// Database Storage
class Mysql implements Storable {
protected $conn;
public function __construct(PDO $conn)
{
$this->conn = $conn;
}
public function insert(string $table, array $data): bool
{
$columns = implode(',', array_keys($data));
$placeholders = "?".str_repeat(",?", count($columns) - 1);
$values = array_values($data);
$sql = "INSERT INTO $table($columns) VALUES($placeholders)";
$stmt = $this->pdo->prepare($sql);
return $stmt->execute($values);
}
}
// File Storage
class File implements Storable {
public function write(string $file, array $data): bool
{
$file = new \SplFileObject($file.'.txt', 'a+');
if (!file_exists($file.'.txt')) {
$columns = implode(', ', array_keys($data));
$file->fwrite($values, strlen($values));
} else {
$file = new \SplFileObject($file, 'a+');
}
$values = implode(', ', array_values($data));
return (bool) $file->fwrite($values, strlen($values));
}
}
// Client
class App {
public function __construct(Mysql $mysql)
{
$data = [
'from' => 'foo@mail.com',
'to' => 'bar@mail.com',
'subject' => 'Hello',
'body' => 'World'
];
$mysql->insert('emails', $data);
}
}
当然,这两个都是简单的例子,但想象一下,如果这些类有更多的方法在代码深处被调用,它可能会给你带来很大的麻烦。
封装
封装和 “告知而非询问”原则相辅相成。正如我们已经说过的,对象应该操纵自身的状态。应该避免允许其他对象直接更改我们对象的状态。再次强调:
不要询问完成工作所需的信息;而要询问拥有信息的对象,以便为你完成工作。(艾伦·霍卢布 - 霍卢布论模式)
通过保留封装,我们可以编写更少的错误和更易于调试的代码,因为我们是明确的,并且只有在向他发送消息时才会改变对象的状态。
我们还使用封装来隐藏抽象及其实现的复杂性和错综复杂之处 ,这样我们只需让外界能够访问“简单的东西”,而所有那些复杂的细节都被隐藏起来。
可见性/访问器是我们确保对象保持封装性的方法。与许多其他语言一样,PHP 中也有三个级别的可见性:
- 公共- 方法和属性可从外部访问。 公共方法构成了我们类的公共 API,我们希望其他开发人员和/或对象能够与这些方法进行交互。
- protected - 方法和属性只能从类及其子类(扩展它的其他类)内部访问
- private - 方法和属性只能在定义它们的类中访问
Getter/Setter 也称为 访问器 和 修改器,它使我们能够保持对象的封装,同时能够访问和改变它们的状态,只是我们不是直接访问它,而是通过 getter/setter 方法来做到这一点。
// Without getters/setters
class Article {
public $slug = 'foo';
}
$article = new Article();
// Imagine we wanted to check for equality
// but forgot to add the second '='.
// See, a simple typo could introduce a bug.
if ($article->slug = 'bar') {
// Do something awesome here
}
// With getters/setters
class Article {
proteceted $slug = 'foo';
public function getSlug(): string
{
return $this->slug;
}
public function setSlug(string $slug)
{
$this->slug = $slug;
}
}
$article = new Article();
if ($article->getSlug() == 'bar') {
// Do something awesome here
}
遗产
继承是不言而喻的,一个对象将继承所有超类(父类)的方法和属性,除非该方法的可见性是 私有的 (我们通过扩展类来实现这一点)。你可能听说过“组合优于继承” 这句话,或者有人甚至更进一步说继承是邪恶的。在我看来, 继承本身并不坏,但坏的继承才是坏的。那么,我们如何区分好的继承和坏的继承呢?
好的继承是“某种(特殊情况)”。关键字 extends 还支持“分类法”,即对象比类更适合分类;此外,它还支持“代码复用”,即组合更适合代码复用。
- Pim Elshoff (@Pelshoff) 2018 年 11 月 26 日
正如@Pelshof在他的推文中完美指出的那样,你应该将继承视为一种特殊情况。我们应该将子类视为其 超类的一种特殊情况,它拥有其父类的所有行为,甚至更多。好的继承是浅继承,如果实在不行,就不应该再深入一层。你应该从 抽象类继承,除非在扩展具体类有意义的情况下,例如,一个框架的具体BaseController,你想从框架中抽象出一些内容或添加一些通用行为。
当你扩展课程时,你应该始终牢记 单一责任原则,将其用作试金石,如果你通过扩展它来破坏它,那么就该进行 组合了。
简单来说,组合 就是将一个复杂的任务分解成多个可重用的类,而不是多层级的继承。组合与继承的比较,某种程度上就像水平扩展与垂直扩展。组合是水平分解任务,而继承是垂直分解任务。
这张图更清晰一些,左侧是父类File ,以及它的子类Reader和孙类 Writer 。这样我们就把示例文件交互任务垂直拆分了。
在右侧,我们有 File 类及其依赖项*,两个独立的可重用类 Writer 和 Reader,我们将通过其构造函数将它们注入到 File 类中。在这种情况下,我们水平扩展了 File 类的职责。
多态性
用简单的英语来说,多态性意味着实现相同接口的对象可以在“底层”做不同的事情,只要它们遵守接口的定义,即多态 部分。
我们在讨论 抽象原则时已经稍微讨论过这一点,方法实现的复杂性和复杂性隐藏在接口之下——我们唯一的入口和出口。只要我们接受规定数量的参数并返回预期结果,我们就可以用无数种方式实现一个方法。
如果你回到 抽象部分中的 那个好例子,你会清楚地看到它也体现了多态性。我们有一个Storable接口和两个实现该接口的类,它们分别执行两个不同的操作。Mysql 存储 到数据库中,File写入到 CSV 文件。
罗马
我主要从 PHP 开发者的角度来阐述,但所有这些概念都可以应用于任何支持面向对象范式的编程语言。为了证明我的观点,让我们比较一下如何 在 PHP 和 Swift 中应用抽象原则。
在 PHP 中我们有接口 ,在 Swift 中我们有协议,它们是同一件事,只是一个不同的关键字,这里没有问题,但是当涉及到抽象类时,Swift 不支持它们。
尽管 Swift 没有抽象类的概念,但它们具有不同的语言结构,允许我们具有相同的行为,即 协议扩展。
协议扩展允许我们扩展协议并向其添加其他方法和/或属性,以便我们可以通过以下方式实现与抽象类相同的行为:
protocol Animal() {
func sound();
}
extension Animal {
func numberOfLegs() -> Int {
return 4;
}
}
class Cat: Animal {
func sound() {
print("woof");
}
}
class Dog: Animal {
func sound() {
print("woof");
}
}
let dog = Dog();
dog.sound(); // woof
dog.numberOfLegs(); // 4
瞧,这些概念是“无语言的”;)
我们都应该努力遵守这些原则,但作为一名开发人员,这 2.5 年以上的专业工作经历教会了我,我们永远不应该太教条,每条规则都有例外。
* 依赖关系 是一个广义的软件工程术语,指一个软件依赖于另一个软件的情况。耦合(计算机编程)在软件工程中,耦合或依赖关系是指每个程序模块对其他模块的依赖程度。程序 X 使用库 Y。
文章来源:https://dev.to/blackcat_dev/object-oriented-programming--1oie