问题

怎么从本质上理解面向对象的编程思想?

回答
拨开迷雾:如何从本质上理解面向对象编程的思想

很多人在接触编程的时候,都会被“面向对象编程”(ObjectOriented Programming,OOP)这个概念弄得云里雾里。教科书里充斥着“类”、“对象”、“封装”、“继承”、“多态”这些术语,它们就像一群难以捉摸的幽灵,让你觉得OOP是个高深莫测的哲学体系,而不是一种解决问题的工具。

今天,我们就来试着拨开这层迷雾,从最根本的源头去理解OOP究竟是怎么回事。忘掉那些抽象的定义,让我们从一个更贴近生活、更自然的视角去审视它。

抛开代码,回到现实:万物皆对象

想象一下我们身处的这个世界。你坐在一张桌子旁,手里拿着一杯水。桌子、水杯、水,还有你自己,都是对象。每个对象都有它自己的属性(比如桌子有颜色、有腿的数量,水杯是玻璃做的,水是透明的,你有着自己的姓名、年龄)和自己的行为(桌子可以支撑东西,水杯可以盛水,水可以被喝掉,你可以移动、思考)。

这就是OOP最核心的思想:将现实世界中的事物抽象出来,看作是拥有属性和行为的独立实体,也就是“对象”。

我们之所以要这样做,是因为它非常符合我们人类认知世界的方式。我们天生就会识别和理解各种各样的“东西”,并且知道它们能做什么,有什么特征。当我们要解决一个问题时,我们往往会思考“需要哪些‘东西’参与?”,而不是“需要哪些步骤?”。

编程中的“对象”:更精炼、更可控

现在,把这个思想带回编程世界。我们不再仅仅关注一行行指令,而是思考我们要操作的是什么“东西”。

假设我们要写一个程序来模拟一个图书馆系统。在过去(也就是我们常说的“面向过程编程”),我们可能会先思考需要哪些函数来完成任务:`addBook()`, `removeBook()`, `borrowBook()`, `returnBook()`等等。然后,我们还需要一些变量来存储书的信息,比如书名、作者、ISBN号等等。

面向对象的思维方式则完全不同。我们会先思考,在图书馆里,最重要的“东西”是什么?

第一反应很可能是“书”。

那么,一本“书”应该具备什么?
属性 (Attributes / Properties):书名、作者、出版社、ISBN号、页数、库存量等等。
行为 (Methods / Behaviors):可以被借阅、可以被归还、可以显示书的信息等等。

于是,我们就定义了一个叫做“书”的类 (Class)。你可以把“类”想象成一个蓝图或者模具。它规定了所有“书”都应该有哪些属性和行为,但它本身不是一本具体的书。

然后,我们用这个“书”的蓝图,去制造出具体的“书”。比如,我们根据蓝图造出一本《围城》,一本《三体》,它们都是“书”这个类的“实例 (Instance)”,或者更通俗地说,就是对象。

《围城》 这个对象,它的书名属性是“围城”,作者属性是“钱钟书”,ISBN号是xxx。
《三体》 这个对象,它的书名属性是“三体”,作者属性是“刘慈欣”,ISBN号是yyy。

你看,通过这种方式,我们把与“书”相关的数据(属性)和操作(行为)打包到了一起,形成了一个独立、有意义的单元——“书”对象。

封装:让对象更安全、更易用

有了对象,我们就需要考虑如何让它们更好地工作。

想想我们的水杯。你倒水,并不需要关心水杯内部的分子是如何排列的,也不需要知道它的材料是如何合成的。你只需要知道,拿起水杯,往里面倒水,它就能盛住水。同样,当你需要喝水时,你只需要拿起水杯,对准嘴巴,然后倾斜它。

这就是封装 (Encapsulation) 的精髓。它意味着:

1. 信息隐藏 (Information Hiding):将一个对象内部的实现细节(比如水杯的材质、内部结构)隐藏起来,对外只暴露有限的、必要的操作接口(比如倒水、喝水)。这样一来,使用者不需要了解复杂的内部机制,也能方便地使用这个对象。
2. 数据与行为的绑定:将数据(属性)和操作这些数据的方法(行为)紧密地捆绑在一起,放在同一个“容器”里。

在编程中,封装的好处非常明显:

提高安全性:你可以控制哪些属性是可以被外部直接修改的,哪些属性只能通过特定的方法来修改。比如,你可能不希望任何人随意修改书的ISBN号,那么你可以将其设为“私有”属性,只允许通过特定的方法来获取或在特定条件下进行更新。
降低耦合度:当一个对象的内部实现发生变化时(比如我们改进了水杯的设计,让它更保温),只要对外暴露的接口不变,其他使用这个水杯的对象(比如你的手)就不需要做任何修改。这极大地提高了代码的可维护性和可重用性。
提高可读性与易用性:使用者只需要关心对象“能做什么”,而不需要关心“是怎么做的”。就像你使用一个遥控器,你只需要知道按哪个按钮能换台,而不需要了解遥控器内部的电路是如何工作的。

继承:让对象“传宗接代”,共享与扩展

现在,我们不仅有“书”,还有“杂志”、“报纸”等等。它们都属于“出版物”。

仔细想想,“书”、“杂志”、“报纸”之间有什么共同点?它们都有标题、有出版日期,也都有可能被“阅读”或者“归还”(虽然杂志报纸的“归还”可能不太常见,但我们可以抽象化)。

在这种情况下,如果我们将这些共同的属性和行为分别写在“书”类、“杂志”类、“报纸”类里,就会造成大量的代码重复。

继承 (Inheritance) 就是为了解决这个问题而诞生的。

想象一下生物的“遗传”。父母的基因会传递给子女,子女在继承了父母的特征的同时,又会拥有自己独特的特征。

在OOP中,“继承”就像是建立一种“isa”的关系。

“书” 是 “出版物”。
“杂志” 是 “出版物”。
“报纸” 是 “出版物”。

我们可以创建一个名为“出版物”的父类 (Parent Class) 或基类 (Base Class),把所有出版物共有的属性(比如标题、出版日期)和行为(比如显示基本信息)定义在里面。

然后,我们可以让“书”、“杂志”、“报纸”这些子类 (Child Class) 或派生类 (Derived Class) 去继承“出版物”类。这样一来,它们就自动拥有了“出版物”类里的所有属性和行为,而不需要我们重新编写。

更妙的是,每个子类还可以拥有自己独特的属性和行为。比如:

“书”类可以继承“出版物”的所有属性,并增加“作者”、“ISBN号”、“页数”等属性,以及“写序言”、“借阅”等行为。
“杂志”类可以继承“出版物”的所有属性,并增加“期号”、“编辑”等属性,以及“订阅”等行为。

继承的好处显而易见:

代码复用:避免了大量的重复代码,让代码更简洁。
提高可维护性:如果需要修改所有出版物的共同属性(比如统一增加一个“出版商”属性),只需要在父类中修改一次,所有子类都会自动生效。
建立层次结构:使得我们的程序结构更加清晰,更容易理解对象之间的关系。

多态:让对象更灵活、更智能

到目前为止,我们有了能够独立工作的对象,它们可以隐藏内部细节,也可以从父类那里继承能力。但有时,我们希望让不同的对象能够以统一的方式响应同一个指令。

比如,我们有一个“出版物列表”。我们想遍历这个列表,然后让每一项“显示自己的详细信息”。

对于一本“书”对象,它应该显示书名、作者、页数等。
对于一个“杂志”对象,它应该显示期号、编辑等。

我们不能简单地对所有对象都调用同一个“显示信息”的方法,因为它们具体要显示的内容是不同的。

多态 (Polymorphism) 就像是给对象赋予了“变色龙”的能力——在不同的场景下,表现出不同的形态。

在OOP中,多态通常通过“方法重写 (Method Overriding)”来实现。当一个子类继承了父类的方法,并且重新定义了那个方法,我们就说子类“重写”了父类的方法。

回到之前的例子:

1. 我们在“出版物”父类中定义一个叫做 `displayDetails()` 的方法。这个方法可以是一个抽象方法(意味着它只定义了“应该做什么”,但没有具体实现),或者在父类中有一个默认的实现。
2. 在“书”类中,我们重写 `displayDetails()` 方法,让它显示书的详细信息。
3. 在“杂志”类中,我们也重写 `displayDetails()` 方法,让它显示杂志的详细信息。

现在,当我们有一个“出版物列表”时,我们可以用一个统一的循环来遍历它:

```
for each publication in publicationList:
publication.displayDetails()
```

当程序执行到 `publication.displayDetails()` 这行代码时,JVM(或者其他语言的运行时环境)会根据 `publication` 实际指向的对象类型来决定调用哪个 `displayDetails()` 方法:

如果 `publication` 指的是一本“书”,那么就会调用“书”类重写的 `displayDetails()` 方法。
如果 `publication` 指的是一本“杂志”,那么就会调用“杂志”类重写的 `displayDetails()` 方法。

这就好像我们在告诉一个朋友:“你来表演一个节目吧!” 你的朋友是演员,他知道如何根据自己的专长(戏剧、唱歌、跳舞)来表演。多态就是允许我们用统一的指令,让不同的对象执行自己最擅长的操作。

多态的威力在于:

灵活性与可扩展性:我们可以随时添加新的“出版物”子类(比如“报纸”),只要它们实现了 `displayDetails()` 方法,就可以无缝地接入到现有的系统中,而不需要修改原有的遍历代码。
代码的通用性:我们可以编写更通用的代码,处理不同类型的对象,而无需针对每种类型编写独立的逻辑。

面向对象思想的本质:协同合作的“智能体”

如果我们把上述的封装、继承、多态放在一起看,你会发现OOP的本质是一种组织和管理复杂软件系统的方法论。

它不再是将程序看作是一系列指令的堆砌,而是将其看作是一个由许多相互协作的、拥有独立能力(属性和行为)的“智能体”(对象)组成的生态系统。

封装 让每个“智能体”都能够独立地运作,保护自己的核心秘密,并提供清晰的沟通方式。
继承 让“智能体”之间能够共享经验和能力,减少重复劳动,并能够形成清晰的层级关系。
多态 让这些“智能体”能够以统一的语言进行沟通和协作,即便它们的内部实现不同。

这种思想带来了软件开发上的巨大优势:

模块化:软件被分解成许多独立的、可管理的单元,便于开发、测试和维护。
重用性:通过继承和良好的设计,可以复用现有的代码和组件,加速开发进程。
可维护性:当需求变化时,可以通过修改或替换少数对象来适应变化,而不会对整个系统产生颠覆性的影响。
可读性:代码结构更清晰,更符合人类的思维模式,更容易理解和沟通。

总结一下:告别“流程”,拥抱“实体”

从本质上讲,面向对象编程的思想,就是一种将我们如何思考和组织复杂问题的方式,映射到编程世界里的一种设计哲学和实现方式。

它鼓励我们:

从“做什么”(过程)转向“是什么”(实体):关注构成问题的核心“事物”,而不是它们的操作步骤。
将数据和操作绑定在一起:创建一个个独立、封装好的“盒子”,让它们能够自我管理。
利用层级关系实现共享和扩展:让能力能够像基因一样传递和发展。
实现灵活的沟通与协作:让不同的实体能够以统一的方式进行交互。

当我们真正理解了这些,面向对象编程就不再是那些令人望而生畏的术语堆砌,而是一种自然而然的思考方式,一种能够帮助我们构建更强大、更灵活、更易于维护的软件的强大工具。它是一种关于如何“化繁为简”,如何“协同合作”的智慧。

网友意见

user avatar

面向对象编程(OOP),是一种设计思想或者架构风格。OO语言之父Alan Kay,Smalltalk的发明人,在谈到OOP时是这样说的:

I thought of objects being like biological cells and/or individual computers on a network, only able to communicate with messages (so messaging came at the very beginning -- it took a while to see how to do messaging in a programming language efficiently enough to be useful).
...
OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things. It can be done in Smalltalk and in LISP.

简单解释一下上面的这几句话的大概意思:OOP应该体现一种网状结构,这个结构上的每个节点“Object”只能通过“消息”和其他节点通讯。每个节点会有内部隐藏的状态,状态不可以被直接修改,而应该通过消息传递的方式来间接的修改。

这个编程思想被设计能够编写庞大复杂的系统。

那么为什么OOP能够支撑庞大复杂的系统呢?用开公司举个例子。如果公司就只有几个人,那么大家总是一起干活,工作可以通过“上帝视角“完全搞清楚每一个细节,于是可以制定非常清晰的、明确的流程来完成这个任务。这个思想接近于传统的面向过程编程。而如果公司人数变多,达到几百上千,这种“上帝视角”是完全不可行的。在这样复杂的公司里,没有一个人能搞清楚一个工作的所有细节。为此,公司要分很多个部门,每个部门相对的独立,有自己的章程,办事方法和规则等。独立性就意味着“隐藏内部状态”。比如你只能说申请让某部门按照章程办一件事,却不能说命令部门里的谁谁谁,在什么时候之前一定要办成。这些内部的细节你管不着。类似的,更高一层,公司之间也存在大量的协作关系。一个汽车供应链可能包括几千个企业,组成了一个商业网络。通过这种松散的协作关系维系的系统可以无限扩展下去,形成庞大的,复杂的系统。这就是OOP想表达的思想。

第一门OOP语言是Ole-Johan Dahland和Kristen Nygaard发明的Simula(比smalltalk还要早)。从名字就可以看出来,是用来支撑“模拟系统”的。模拟这个场景非常适合体现OOP的这个思想。这个语言引入了object、class、subclass、inheritance、动态绑定虚拟进程等概念,甚至还有GC。Java很大程度上受了Simula的影响。我们在现在教书上讲解OOP类、实例和继承关系时,总会给出比如动物-猫-狗,或者形状-圆-矩形的例子,都源自于此。

还有一些带有OO特征的语言或者研究成果在Simula之前就出现,这里就不往前追溯了。

但随后在施乐Palo Alto研究中心(Xerox PARC),Alan Kay、Dan Ingalls、Adele Goldberg在1970年开发了smalltalk,主要用于当时最前沿计算模型研究。在Simula的基础之上,smalltak特别强调messaging的重要性,成为了当时最有影响力的OOP语言。与smalltalk同期进行的还有比如GUI、超文本等项目。smalltalk也最早的实现了在GUI使用MVC模型来编程。

但是,并不是说OOP程序一定要用OOP语言来写。再强调一下,OOP首先是一种设计思想,非仅仅是编码方式。从这个角度推演,其实OOP最成功的例子其实是互联网。(Alan Kay也是互联网前身ARPNET的设计者之一)。另外一个OOP典型的例子是Linux内核,它充分体现了多个相对独立的组件(进程调度器、内存管理器、文件系统……)之间相互协作的思想。尽管Linux内核是用C写的,但是他比很多用所谓OOP语言写的程序更加OOP。

现在很多初学者会把使用C++,Java等语言的“OOP”语法特性后的程序称为OOP。比如封装、继承、多态等特性以及class、interface、private等管家你在会被大量提及和讨论。OOP语言不能代替人类做软件设计。既然做不了设计,就只能把一些轮子和语法糖造出来,供想编写OOP程序的人使用。但是,特别强调,是OOP设计思想在前,OOP编码在后。简单用OOP语言写代码,程序也不会自动变成OOP,也不一定能得到OOP的各种好处。

我们在以为我们在OOP时,其实很多时候都是在处理编码的细节工作,而非OOP提倡的“独立”,“通讯”。以“class”为例,实际上我们对它的用法有:

  • 表达一个类型(和父子类关系),以对应真实世界的概念,一个类型可以起到一个“模版”的作用。这个类型形成的对象会严格维护内部的状态(或者叫不变量)
  • 表达一个Object(即单例),比如XXXService这种“Bean”
  • 表达一个名字空间,这样就可以把一组相关的代码写到一起而不是散播的到处都是,其实这是一个“module”
  • 表达一个数据结构,比如DTO这种
  • 因为代码复用,硬造出来的,无法与现实概念对应,但又不得不存在的类
  • 提供便利,让foo(a)这种代码可以写成a.foo()形式

其中前两种和OOP的设计思想有关,而其他都是编写具体代码的工具,有的是为了代码得到更好的组织,有的就是为了方便。

很多地方提及OOP=封装+继承+多态。我非常反对这个提法,因为这几个术语把原本很容易理解的,直观的做事方法变的图腾化。初学者往往会觉得他们听上去很牛逼,但是使用起来又经常和现实相冲突以至于落不了地。

“封装”,是想把一段逻辑/概念抽象出来做到“相对独立”。这并不是OOP发明的,而是长久以来一直被广泛采用的方法。比如电视机就是个“封装”的好例子,几个简单的操作按钮(接口)暴露出来供使用者操作,复杂的内部电路和元器件在机器里面隐藏。再比如,Linux的文件系统接口也是非常好的“封装”的例子,它提供了open,close,read,write和seek这几个简单的接口,却封装了大量的磁盘驱动,文件系统,buffer和cache,进程的阻塞和唤醒等复杂的细节。然而它是用函数做的“封装”。好的封装设计意味着简洁的接口和复杂的被隐藏的内部细节。这并非是一个private关键字就可以表达的。一个典型的反面的例子是从数据库里读取出来的数据,几乎所有的字段都是要被处理和使用的,还有新的字段可能在处理过程中被添加进来。这时用ORM搞出一个个实体class,弄一堆private成员再加一堆getter和setter是非常愚蠢的做法。这里的数据并非是具有相对独立性的,可以进行通讯的“Object“,而仅仅是“Data Structure”。因此我非常喜欢有些语言提供“data object”的支持。

当然,好的ORM会体现“Active Record”这种设计模式,非常有趣,本文不展开

再说说“继承”,是希望通过类型的 is-a 关系来实现代码的复用。绝大部分OOP语言会把is-a和代码复用这两件事情合作一件事。但是我们经常会发现这二者之间并不一定总能对上。有时我们觉得A is a B,但是A并不想要B的任何代码,仅仅想表达is-a关系而已;而有时,仅仅是想把A的一段代码给B用,但是A和B之间并没有什么语义关系。这个分歧会导致严重的设计问题。比如,做类的设计时往往会希望每个类能与现实当中的实体/概念对应上;但如果从代码复用角度出发设计类,就可能会得到很多现实并不存在,但不得不存在的类。一般这种类都会有奇怪的名字和非常玄幻的意思。如果开发者换了个人,可能很难把握原来设计的微妙的思路,但又不得不改,再稳妥保守一点就绕开重新设计,造成玄幻的类越来越多…… 继承造成的问题相当多。现在人们谈论“继承”,一般都会说“Composite Over Inheritance“。

多态和OOP也不是必然的关系。所谓多态,是指让一组Object表达同一概念,并展现不同的行为。入门级的OOP的书一般会这么举例子,比如有一个基类Animal,定义了run方法。然后其子类Cat,Dog,Cow等都可以override掉run,实现自己的逻辑,因为Cat,Dog,Cow等都是Animal。例子说得挺有道理。但现实的复杂性往往会要求实现一个不是Animal的子类也能“run”,比如汽车可以run,一个程序也可以“run”等。总之只要是run就可以,并不太在意其类型表达出的包含关系。这里想表达的意思是,如果想进行极致的“多态”,is-a与否就不那么重要了。在动态语言里,一般采用duck typing来实现这种“多态”——不关是什么东西,只要觉得他可以run,就给他写个叫“run”的函数即可;而对于静态语言,一般会设计一个“IRun”的接口,然后mixin到期望得到run能力的类上。简单来说,要实现多态可以不用继承、甚至不用class。

OOP一定好吗?显然是否定的。回到OOP的本心是要处理大型复杂系统的设计和实现。OOP的优势一定要到了根本就不可能有一个“上帝视角”的存在,不得不把系统拆成很多Object时才会体现出来。

举个例子,smalltalk中,1 + 2 的理解方式是:向“1”这个Object发送一给消息“+”,消息的参数是“2”。的确是非常存粹的OOP思想。但是放在工程上,1 + 2理解为一般人常见的表达式可能更容易理解。对于1 + 2这样简单的逻辑,人很容易从上帝视角出发得到最直接的理解,也就有了最简单直接的代码而无用考虑“Object”。

如果是那种“第一步”、“第二步“……的程序,面向数据的程序,极致为性能做优化的程序,是不应该用OOP去实现的。但很无奈如果某些“纯OOP语言”,就不得不造一些本来就不需要的class,再绕回到这个领域适合的编码模式上。比如普通的Web系统就是典型的“面向”数据库这个中心进行数据处理(处理完了展示给用户,或者响应用户的操作)。这个用FP的思路去理解更加简单,直观。也有MVC,MVVM这样的模式被广泛应用。

还有一些领域尽管用OOP最为基础很适合,但是根据场景,已经诞生出了“领域化的OOP”,比如GUI是一个典型的例子。GUI里用OOP也是比较适合的,但是GUI里有很多细节OOP不管或者处理不好,因此好的GUI库会在OOP基础之上扩展很多。早期的MFC,.Net GUI Framework, React等都是这样。另外一个领域是游戏,用OOP也很合适,但也是有些性能和领域细节需要特殊处理,因此ECS会得到广泛的采用。

总结一下,OOP是众多设计思想中的一种。很多OOP语言把这种思想的不重要的细节工具化,但直接无脑应用这些工具不会直接得到OOP的设计。即便是OOP思想本身也有其适合的场景和不适合的场景。即便是适合的场景,也可能针对这个场景在OOP之上做更针对这个场景需求的定制的架构/框架。如果简单把OOP作为某种教条就大大的违反了这个思想的初衷,也只能得到拧巴的代码。

类似的话题

本站所有内容均为互联网搜索引擎提供的公开搜索信息,本站不存储任何数据与内容,任何内容与数据均与本站无关,如有需要请联系相关搜索引擎包括但不限于百度google,bing,sogou

© 2025 tinynews.org All Rights Reserved. 百科问答小站 版权所有