用汽车比喻理解OOP - Jonathan Kuhl


站在任何街角,观看交通一段时间,来来往往都是汽车。它们具有相同的基本结构:四个轮子,一个发动机,一个方向盘,用汽油或柴油运行。然而,它们在颜色,马力,形状,特征甚至可能使用的汽油类型方面差异很大。每条繁忙的街道都是不同车型的杂音,但我们看到的大多数车辆,每个人都会同意,是一辆汽车。是的,根据你的定义,你的“道奇”可以被认为是“一辆车”。
这是面向对象编程(OOP)的意义所在。

对象是基于某些基本模板的单个属性的捆绑。

每辆汽车都是基于一些基本模板基础上的一系列独立功能;当然,一些模板是模板的模板。没有“基本车”这个概念,因为它是一个抽象的概念,每个衍生车是必须实现其属性和方法的集合,但每个衍生车可以在抽象的范围内自由地实现。
我们对汽车的概念有一个概括,然后扩展到不同类型的汽车。斯巴鲁是一辆旅行车;GMC Yukon是一款SUV;福特Fusion是一款轿车。从这些类型,旅行车,SUV,轿车等,你可以找到更具体的想法,就像我列举的车辆一样。最终,你会得到足够的抽象来购买你的个人汽车。

Car
    旅行车Sports Utility Vehicle
        GMC Yukon
    SUVStation Wagon
        斯巴鲁Outback
    轿车Sedan
        福特Fusion

这些被称为类Class。

什么是类
如果你走进一个斯巴鲁工厂,你找不到一群疯狂的工程师不管三七二十一鼓捣一辆汽车,如果存在这种现象会是很荒谬的、低效的,并导致不均匀的汽车制造。(banq注:汽车修理厂和汽车制造厂的区别,修理厂是围绕着某个具体汽车修理,而制造厂是根据磨具类或图纸蓝图进行制造每个具体汽车)。
从艺术上讲,非均匀性通常是有价值的,但在编程世界中,它会引起问题。对象不符合他们所需的角色。测试几乎是不可能的,因此质量保证就被扔到窗外,批量生产会放缓。
相反,你发现的是一个长的程序流水线工人(大多数现在是机器人)遵循一套精确的指令,根据之前绘制的原理图来制造汽车。
这个原理图正是一个类,类本身不是它们创建的对象,而是用于创建对象的模板。如果我要了解2017款斯巴鲁的原理图,它会详细告诉我每个螺母和螺栓的位置以及每个功能的工作原理和实施方式。

构造函数
当然,个别汽车的创建方式也各不相同。虽然,由于某种原因,橙色斯巴鲁Crosstreks似乎在高速公路上占主导地位,显然,人们有很多颜色选择余地和许多其他小功能的选择。
构造函数设置属性并初始化它们。它允许我们为对象的单个实例设置单独的详细信息,然后,它创建对象。
构造函数和初始化程序有许多不同的版本。在我继续之前,构造函数和初始化程序之间存在明显的区别。构造函数在内存中创建对象(Subaru工厂抛出一个新的Outback)并且初始化程序设置其属性(新的Outback被绘制了插入的可选功能。)一些语言同时执行此操作,其他语言单独执行。

// Java
class MyObject {
    String param;
    MyObject(String param) {
        this.param = param;
    }
}

// JavaScript
class MyObject {
    constructor(param) {
        this.param = param;
    }
}

# Python
class MyObject:
    self.__init__(self, param):
        self.param = param

想想斯巴鲁工厂的装配线等构造商。你通过告诉机器人你想要什么颜色和功能来启动,然后,基于他们给出的类模板,用您想要的参数制造汽车。

接口和抽象类
但是,并非每个对象都需要构建。正如我之前所说,基本的汽车理念并不代表具体的对象,而是一个抽象的想法;基本车的盖世只是每辆车必须实现的功能列表。如果您不实施这些功能,那么您的制造出来的成品就不是汽车;也许它有轮子和发动机,但它不是汽车。

接口是合同,它保证另一边出来的都是汽车。

暂时放下汽车类比,假设你有一个函数将一个对象作为其参数之一,并且在该函数中,它调用该对象的一个​​方法。

class Person {
    String name;
    Person(String name) {
        this.name = name;
    }
    public void greet() {
        System.out.printf("%s says 'Hello'", this.name);
    }
}

// assume we're in class main
public static void personGreet(Person person) {
    person.greet();
}
public static void main(String ...args) {
    personGreet(new Person(
"Jim"));
}

在上面的例子中,我们有一个方法,它接受Person并调用它的greet方法。这当然很好,但是有一个问题。如果我有多个“人”类型,继承是一个模型对我不利,怎么办?
Employee并且Dog所有可能都有问候,但可能不作为Person的子类,该怎么办?Employee可能会是Person的子类,但Dog不会!Employee可以更好地通过组合而不是继承来实现。通过personGreet()接口确保这两个类在方法中工作:

interface Greetable {
    public void greet();
}

class Employee implements Greetable {
    // ...
    public void greet() {
        System.out.printf(
           
"%s says 'Hello', I work for %s as %s"
            this.name, this.job, this.jobTitle
        );
    }
}

class Dog implements Greetable {
   
// ...
    public void greet() {
        System.out.printf(
"%s barks 'Arf Arf!', this.name);
    }
}

// and back to the Main class
public static void personGreet(Greetable greeter) {
    greeter.greet();
}

现在我们的greet类可以接受任何实现的对象,Greetable因为我们可以确信它实现了所需的方法。

它对任何现代IDE都非常有帮助。如果IDE知道您的变量实现Greetable,它将有助于在Greetable接口中建议方法,因此您不必记住拼写方法或方法参数的顺序。

像JavaScript这样的语言没有静态类型,对于那些语言接口没有意义。如果我想将一个对象传递给该personGreet()方法,那么没有什么可以确保personGreet()有一个greet方法,除了我实际编程这样的检查。界面是静态类型语言的亮点。如果您是JavaScript的粉丝,但也想要静态类型,请尝试使用Typescript。

在考驾驶执照场景中,你没有被教导如何驾驶特定的汽车。你被教导如何以非常通用的方式驾驶。我们都知道汽车实现了特定的接口:方向盘位于左侧(除非您的英国人),加速器位于右侧并且可以使用它;将转向开关向上轻拂一下右转,然后向下按以指示左转。汽车都实现了相同的接口,如果你可以开其中一辆车,你几乎可以开任何一辆车。
而任何需要特殊许可的车辆,他们可能会实现轿车接口,但他们也会实现用户需要注意的其他一些接口。一半可能会实现一些与汽车相同的细节,这一半就是以相同的基本方式操作,但也会实现一些其他接口,因为它的尺寸,重量,轮子数量都将影响其处理并使其成为不同的对象。事实上,将一些想法从汽车中取出(如基本驾驶)并将其移至车辆接口中可能会更好。
也许如果汽车是一个抽象类而不是一个实现车辆接口的实现类会更好,它也有自己的属性:有四只轮子以及汽车独有的其他东西。

属性和方法
我们驾驶过所有那些车,包括那个可怕的橙色斯巴鲁Crosstrek。(为什么它们总是橙色?)虽然它们都继承了车辆和汽车,但它们看起来都非常独特。人们在形状,颜色,性能,收音机,驾驶室控制等方面有广泛的选择。构造对象时,它是存储在内存中的唯一数据结构。你可以拥有任意数量的它们(直到内存耗尽)并且它们可能具有相同的基本形状,但它们都是独一无二的。
在我继续之前,属性在不同语言中定义为不同的东西。在JavaScript中,它们是属性。在Python和Ruby中,它们是属性。在Java中,它们是字段。为了清楚起见,当我说“propperty”时,它适用于所有这些事情。
属性只是应用于单个对象的某种状态。例如,汽车的颜色或马力。
方法是单个对象可能执行的某些行为。当你在汽车中加油门时,你可以调用accelerate()方法。即使每个对象都采用相同的accelerate()方法,但并不是每辆车都会立刻加速!只有自己的汽车accelerate()才会开始加速并增加其速度属性值,如果两辆车同时加速,他们只会修改自己的内部状态。我踩下汽车里的油门,只能让我自己的车开走。

静态与成员
当人们谈论静态方法和属性时,他们经常说一些模糊的东西,比如“静态方法属于类,而成员方法属于一个对象”。一旦你掌握了OOP,那么理解这个概念并不是非常困难。但对于一些全新的编程人员来说,可能会有点令人困惑。毕竟,不是所有的方法和属性都属于这个类吗?一个新的程序员可能还没有意识到一个类和一个对象是不一样的。正如我之前所说,一个类是原理图,一个对象是原理图设计的具体内容。
任何标记为“静态”的东西都是适用于整个类的东西,适用于单个对象是没有意义的,也不适合与类分离。
为了解释它,我将继续用汽车比喻。
想象一下,你开车去公路旅行。因为你非常富裕,所以你可以把车从美国运到大西洋到欧洲。当你到达那里时,你意识到欧洲人,他们喜欢简单易用,使用公制系统。
幸运的是,您的汽车具有将每加仑英里显示器换成每升公里数的功能。这样的配方对任何一辆车都很有用,但是如果你驾驶的是斯巴鲁WRX或福特Fusion或笨重的Yugo用胶带和绝望装在一起,它就不会改变。无论你驾驶什么,MPG到KPL都是同样的功能。即使您驾驶高油耗车,单位转换也不会改变。那么为什么要使这种方法针对个别车?让它静态,以便所有汽车都可以共享它。
MPG到KPL的转换不属于汽车。当然,个人可能会建立一种方法来自行显示这种转换,但数学的实际实现并不属于汽车。相反,它可以作为常识存储在所有汽车制造商和司机那里。

class Car {
    final static double MPG_TO_KPL = 0.425144;
    public static double mpgToKpl(double mpg) {
        return mpg * MPG_TO_KPL;
    }
}

class Outback extends Car {
    public void displayMetricFuelConsumption() {
        double fuelConsumption = Car.mpgToKpl(this.mpg);
        System.out.println("Current fuel consumption in kpl: " + fuelConsumption);
    }
}

如您所见,单个对象本身可以调用自己的方法来显示转换后的单元,但单位转换本身是静态的并保持在类级别。此外,Outback不必Car为静态方法扩展可用,任何对象都可以从Car类中调用它。

继承
继承是子类型继承其父类的属性和方法。
旅行车是一辆汽车,因此它有四个轮子,意在供一般人使用。汽车又是车辆,因此它有方向盘,加速器和制动踏板,某种活塞发动机等等。
斯巴鲁傲虎是一辆旅行车,因此它是一辆带掀背车的车,后备箱中有额外的空间。因为它是一辆旅行车,所以它是一辆汽车,因为它是一辆汽车,所以它是一辆汽车。

多态性
但并非每个子类型都会以与父级相同的方式实现方法!斯巴鲁WRX和斯巴鲁傲虎肯定有不同的加速方法!在任何OOP语言中,子项都可以覆盖父项的方法和属性。这是通过覆盖和重载完成的,但不是每种语言都支持重载。例如,JavaScript没有。
覆盖是指我们更改实现时,但保持方法签名相同。在斯巴鲁WRX中,也许在我们的加速方法中有一种涡轮增压方法,这是你通常在Outback找不到的东西。
重载是指方法签名相同但参数及其类型不同。只要使用不同的参数和不同的参数类型重载方法,就可以多次覆盖方法。但同样,并非每种语言都支持这一点。JavaScript没有。
您可以使用typeof或instanceof关键字模拟JavaScript中的重载,并以编程方式确定参数是什么以及实现的功能取决于给出的参数,但是您不能拥有具有相同名称和不同参数的多个函数。
你可以在Java中:

public static String add(String a, String b) {
  return a.concat(b);
}

public static int add(int a, int b) {
  return a + b;
}

两种方法都具有相同的名称,但签名不同。重载只是根据输入的内容改变实现。

应用程序接口
在继续之前,我确实想花一点时间来解释API(应用程序编程接口)是什么。API是用户可以使用的面向公众的属性和方法的集合。
你的车有API:它有一个加油口。有一个踏板可以让它走,另一个让它停下来。有一个轮子可以让我转向。这些东西允许您与汽车接口交互,并以受控和可预测的方式改变其状态和行为。
任何对象的API可以概括为其公共属性和用户要使用的方法。

封装
当我开车行驶时,当我的脚在制动器或汽油上时,它会影响我车的内部状态。我正在放慢速度或加快速度。它不会影响我周围的其他车辆。我汽车的内部状态是封装的。作为用户,我不需要知道引擎在做什么,我周围也没有人知道。我只需要知道公共API是什么。当我与该API接口时,我不需要知道内部发生了什么。如果我加速,我不需要特别知道哪些活塞必须上升,哪些活塞必须在任何给定时刻降低; 引擎为我处理。如果我被允许在发动机运转时进入并修改它,我会破坏一些东西。
这是封装。类的内部状态仅通过明确设计用于外部用户的特定方法暴露给外部世界。例如,单例使用封装来确保其自身只有一个实例。构造函数只能通过特定的公共方法访问,以确保只能调用一次。通常,它会检查实例是否已创建并创建实例,或返回对该实例的引用,但无法从外部调用构造函数本身(设置为private)。

抽象化
现在让我们再次回到司机考驾照场景:请记住,你不是在学习如何驾驶一辆特定的汽车,而是学会通用的汽车驾驶方法。我们知道你驾驶的任何汽车都将继承汽车和汽车,所以我们知道它会有加速器,刹车踏板,方向盘,转向灯等所有其他东西或多或少相同的地方。
这些方法是如何实现的并不重要,只是它们是以可预测的方式实现的。抽象与封装有关,因为你不了解或不关心内部。你知道的是,如果你调用一个方法,就应该发生可预测的行为。如果我踩油门,汽车应该向前移动。如果我踩了油门,转向闪光灯却亮了。。。那会很奇怪。
你知道如何驾驶任何一辆汽车,因为你对汽车是什么以及它的方法有了一个抽象的概念。您知道每辆车都可以在您可以使用的类似API中使用这些方法。
油门踏板或转向灯的实际实施或任何黑盒装。如果你想加速,你只需踩踏板;没有必要了解踏板是如何按压导致气体流入发动机的,这使得活塞上升和下降并允许它们循环通过进气,压缩,燃烧和排气系统。当然,这对于维护来说可能很方便,但你可以轻松驾驶汽车而不知道引擎盖下那个大笨重的东西究竟在做什么。
抽象隐藏了不必要的实现细节。它允许您采用该实现并将其捆绑为更有意义和可重用的内容。这降低了用户端的复杂性。以JavaScript的Array方法库为例。

const squares = [1,2,3].map(x => x**2);
//[1, 4, 9]

虽然JavaScript是一种OOP语言,但它基于原型继承方案而不是经典方案。这是要记住的事情。话虽如此,我在这里所说的大部分内容也适用于JavaScript,但也许并不完全正确。

我是否关心map()引擎盖下的内容?不,我所关心的是它对每个元素都执行某些功能的知识。我不在乎怎么样。每次我想要更改数组的元素时,我都不想实现映射函数。为什么不把它抽象成一个方法,以便我可以根据需要重用它?
这是抽象。

结论
面向对象编程可能有点令人困惑,但我发现如果将对象视为日常对象,就能更好地理解它。汽车是大规模生产的物体,具有类似的基本形状,即使有各种各样的类型,从笨重的SUV到旅行车,到卡车,轿车和跑车。如果您考虑汽车在现实世界中如何相互关联,您可以更好地了解对象在编程世界中的工作方式。
我希望这有助于更好地理解OOP是什么以及OOP中涉及的基本概念和术语是什么。我并不打算真正进入OOP的实际语言实现,但希望当你读到不同语言如何实现OOP时,进行某种类比将有助于使概念更清晰。