1. 什么是面向对象编程?

在写代码的日常里,我们主要会遇到两种编程思维:

  • 面向过程编程
    就像把大象放进冰箱:第一步打开冰箱门,第二步把大象塞进去,第三步关上冰箱门。它关注的是“做事的步骤”,程序像一张流程图,关心的是流程或者是步骤。

  • 面向对象编程
    同样一件事情换种思路思考:世界上有“大象”这个对象,也有“冰箱”这个对象。冰箱有“开门”和“关门”的方法,大象有“被放进冰箱”的方法。我们只需要指挥各个对象调用自己的方法,就能完成任务。
    这种思维将现实世界的事物抽象成 对象,每个对象既包含数据(称为 属性),也包含操作数据的方法(称为 行为)。程序就是由一群互相协作的对象组成。

面向对象编程这种编程范式让代码更贴近人类的认知,更容易理解、维护和复用。而其中最重要的基础概念,就是封装、继承和多态。今天我们重点学习的是封装。


2. 类和对象

理解了面向对象的思想,我们就会碰到一对最基础的术语:对象


  • 是一类事物的抽象描述,相当于一个模具或蓝图。它规定了这一类事物应该具备哪些共同的属性和行为。
    例如:“人” 这个类,有名字、年龄等属性,有吃饭、走路等行为。“手机” 这个类,有品牌、型号等属性,有打电话、拍照等行为。

  • 对象
    是类的具体实例,是真实存在的、看得见摸得着的个体。
    例如:“人” 这个类的一个对象是 “张三” ,“手机” 类的一个对象是 “你手上的华为P40”。

注意:必须先定义类,才能根据类来创建对象。就像你要先有模具,才能生产出产品一样。


3. 定义类和创建对象

3.1 定义类

在Python中,定义类非常简单,使用 class 关键字:

class Dog:
    pass  # 一个最简单的类,什么也没做

Python与其他语言在定义类上的对比:

语言 定义方式 特别之处
Python class Dog: 运行到定义时即时创建类对象;可以动态修改
JavaScript class Dog {}function Dog() {} class 只是语法糖,背后仍是原型机制;也可以完全不用 class
Java public class Dog {} 通常一个文件一个公共类,类名必须与文件名一致,强类型
C++ class Dog {}; 结尾必须加分号

3.2 创建对象

定义好类之后,我们可以像调用函数一样来生成一个该类的实例(对象):

my_dog = Dog()  # 创建了一个Dog类的对象,并赋给变量 my_dog

在后台,Python 实际上做了两件事:

  1. 调用 __new__ 方法来创建一个新的实例对象(分配内存)。
  2. 调用 __init__ 方法来初始化这个对象,给它加上一些初始属性。

对比其他语言,创建对象的语法和内存管理有很大不同:

语言 创建语法 背后发生了什么
Python d = Dog() __new__ 创建,再调 __init__ 初始化;内存由解释器管理
JavaScript new Dog()Object.create(proto) new 创建对象、绑定 this、挂载原型链;Object.create 直接指定原型
Java Dog d = new Dog(); 在堆上分配内存,调用构造器;由 JVM 垃圾回收
C++ Dog d; (栈) 或 Dog* d = new Dog(); (堆) 堆内存需要用 delete 手动释放,否则内存泄漏

3.3 构造方法 init

我们平时创建对象时,通常希望每个对象一出生就拥有属于自己的属性,这个初始化工作就交给了 __init__ 方法。__init__ 就是我们为对象注入“个性化”初始值的入口,通过它我们能保证每个对象一创建就拥有必须的属性,这和 Java/C++ 里的构造函数作用是完全一致的。

  • __init__ 是一个魔法方法(前后带双下划线),我们不需要手动调用它,Python 会在创建对象时自动调用它。
  • 它的第一个参数永远是 selfself 代表当前正在创建的这个实例对象,用来给这个对象绑定属性。self 就像其他语言中的 this,只不过 Python 要求我们必须显式地写在方法定义的第一个位置。

正确示例:

class Dog:
    def __init__(self, name, age):
        self.name = name  # 将传入的name赋给这个实例的name属性
        self.age = age    # 将传入的age赋给这个实例的age属性

# 创建对象时,传入参数,self会自动传,我们不用管
my_dog = Dog("旺财", 3)
print(my_dog.name)  # 输出:旺财
print(my_dog.age)   # 输出:3

常见错误示例:

# 错误1:定义时忘了写 self
class Dog:
    def __init__(name, age):   # 缺少 self
        self.name = name       # 会报错,因为不知道 self 是谁

# 错误2:创建对象时没有传入足够的参数
class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

d = Dog()  # 报错!缺少2个必需的位置参数 name 和 age

# 错误3:在方法内部忘记用 self.xxx 绑定属性
class Dog:
    def __init__(self, name, age):
        name = name   # 只是创建了局部变量,函数执行完就没了
        age = age     # 对象根本没有被赋予任何属性

4. 属性

属性根据所属范围的不同,分为 实例属性类属性

4.1 实例属性

实例属性是属于各个对象自己的,每个实例都拥有独立的一份。我们可以在 __init__ 方法里通过 self.xxx 定义;我们也可以通过对象.属性来创建实例属性。

class Dog:
    def __init__(self, name):
        self.name = name   # 实例属性 方法1

dog1 = Dog("旺财")
dog2 = Dog("来福")
print(dog1.name)  # 旺财
print(dog2.name)  # 来福
# 修改 dog1 的 name 不会影响 dog2
dog1.age=1 # 实例属性 方法2

4.2 类属性

类属性是直接定义在类体内部(方法外面)的变量,它被这个类的所有实例所共享。你可以理解为这是这个“模具”上自带的、共同的印记。可以类比Java和C++中的static。

class Dog:
    species = "犬科"   # 类属性

    def __init__(self, name):
        self.name = name

dog1 = Dog("旺财")
dog2 = Dog("来福")

print(dog1.species)  # 犬科
print(dog2.species)  # 犬科
print(Dog.species)   # 犬科,也可以直接通过类名访问

4.3 为什么Python的属性可以这么灵活?(对比Java/C++)

学习了 Java 或 C++ 会困惑:为什么 Python 不用像 Java 那样必须在类里先声明所有属性,而是可以在 __init__ 甚至创建对象后再动态添加属性?

  • Java/C++ 中,类就是一份严格的“合同”,一个对象长什么样(有哪些成员变量)在编译期就必须完全确定,不能变。
  • Python 中,对象内部核心是一个叫做 __dict__ 的字典,所有实例属性都存储在这个字典里。这意味着你可以随时给一个对象新增或删除属性,非常灵活。

动态添加属性的例子:

class Dog:
    def __init__(self, name):
        self.name = name

d = Dog("旺财")
d.age = 3  # 动态添加一个 age 属性,这是完全合法的
print(d.name, d.age)  # 旺财 3

这样会不会导致一个对象添加了属性,其他同类的对象也跟着变?
不会。上面这样动态添加的属性只会属于 d 这一个对象,Dog 类的其他实例是完全不受影响的,其他对象甚至没有 age 这个属性。这就像你给其中一只小狗戴了个铃铛,其他小狗并不会平白无故也长出铃铛来。

既然这么灵活,那我们为什么通常还是把属性写在 __init__ 里?
这正是为了保证代码的可读性和可维护性。如果属性满世界飞,程序一大,你根本不知道一个对象有哪些属性可用。最佳实践是:所有实例属性都应尽量在 __init__ 方法内完成初始化,这样别人(包括以后的你)一眼就能看清这个类有哪些数据。动态添加属性更多是作为高阶技巧或在特殊场景下使用。

4.4 访问和修改属性的“大坑”

Python 在通过实例访问属性时,会遵循一个查找顺序

  1. 先在实例自己的 __dict__ 里找(实例属性)。
  2. 如果找不到,再去类的 __dict__ 里找(类属性)。

这就引出了一个经典的陷阱:通过实例给类属性赋值,并不会修改类属性,反而会在实例上创建一个同名的实例属性,把类属性“屏蔽”掉。

class Dog:
    species = "犬科"

d1 = Dog()
d2 = Dog()

# 错误尝试:想通过实例修改类属性
d1.species = "猫科"  # 这实际上是在 d1 上创建了一个实例属性 species

print(d1.species)   # 猫科 (访问的是 d1 自己的实例属性)
print(d2.species)   # 犬科 (d2 没有实例属性,仍访问类属性)
print(Dog.species)  # 犬科 (类属性纹丝未动)

正确的修改类属性的方式是直接通过类名操作:Dog.species = "狼科"。这时,所有之前没有自己单独定义 species 实例属性的对象,再访问时都会得到新值。


5. 方法

5.1 实例方法

最常见的方法,第一个参数必须是 self,它指向调用该方法的实例对象,这样方法内部就能操作该对象的属性了。

class Dog:
    def __init__(self, name):
        self.name = name

    def bark(self):  # 实例方法
        print(f"{self.name} 汪汪叫!")

d = Dog("旺财")
d.bark()  # 输出:旺财 汪汪叫!
# 本质上等价于 Dog.bark(d)

5.2 类方法

使用 @classmethod 装饰,第一个参数是 cls,代表类本身。它通常用来定义那些不需要实例数据,但又与类紧密相关的操作,比如工厂方法。

class Dog:
    species = "犬科"

    @classmethod
    def get_species(cls):
        return cls.species

print(Dog.get_species())  # 犬科

5.3 静态方法

使用 @staticmethod 装饰,没有默认的第一个参数(selfcls)。它可以看作是一个定义在类的命名空间里的普通函数,与类和实例都没有直接的数据交互,只是逻辑上归属于这个类。

class Dog:
    @staticmethod
    def bark_sound():
        return "汪汪"

print(Dog.bark_sound())  # 汪汪

对比不同语言中的形态:

语言 实例方法 类/静态方法 特殊方法
Python def method(self): @classmethod(首参 cls
@staticmethod(无自动传参)
__str____add__ 等魔术方法
JavaScript method() {}this.method = function() {} static method() {} Symbol.toPrimitive 等,运算符重载较弱
Java public void method() {} public static void method() {} 无可重载的运算符
C++ void method(); static void method(); 可重载 operator+ 等运算符

5.4 魔法方法

魔法方法(Magic Methods)是一些以双下划线开头和结尾的特殊方法。它们的核心特点就是:我们不需要主动调用它们,Python 会在特定时机自动触发。

5.4.1 initstr

__init__ 我们已经很熟悉了,它在创建对象时自动调用,负责初始化。

__str__ 则是在你使用 print(对象)str(对象) 时自动调用的,它的返回值就是打印出来的字符串。

class Dog:
    def __init__(self, name):
        self.name = name

    def __str__(self):
        return f"一只名叫{self.name}的小狗"

d = Dog("旺财")
print(d)  # 输出:一只名叫旺财的小狗  (自动调用了 __str__)

问: 没写 __str__ 会怎样?

我们也可能会产生这样的疑惑:为什么 print(对象名) 能打出内容?为什么列表打印出来不是地址?

Python 中一切皆为对象,变量里存的都是对象的引用(内存地址)。

print 在打印一个对象时,底层会去调用这个对象的 __str__ 方法,把返回的字符串显示给你看。如果这个类没有定义 __str__,它会再尝试调用 __repr__。如果 __repr__ 也没有,Python 就只好使用一个默认的表示,即我们看到的那种 <__main__.Dog object at 0x10f43a280>,告诉你这是一个什么类的对象,以及它所在的内存地址。

  • 内置类型(列表、字典、字符串等)之所以打印得那么好看,不是因为它们有什么特权,而是因为 Python 的开发者们已经为这些内置类写好了 __str____repr__ 方法
  • 我们自己定义的类,如果没写这些魔法方法,print 时就会显示那个“原始”的内存地址格式。

所以,想让你的对象打印得友好需要自定义一个 __str__ 方法。

5.4.2 其他实用的魔法方法

  • __add__(self, other):重载 + 运算符。例如,c = a + b 会调用 a.__add__(b)
  • __len__(self):让你可以用 len(obj)
  • __getitem__(self, key):让你可以用 obj[key] 来访问,像列表一样。
  • __eq__(self, other):让你可以用 == 来比较两个对象是否相等。

6. Python的封装

面向对象的封装包含两层含义:

  1. 将属性和行为打包在一个整体(类)中。
  2. 控制对内部数据的访问权限,隐藏细节,只暴露必要的接口。

对于第一点,所有面向对象语言都是这么做。而对于第二点,Python 和 Java/C++ 走了两条完全不同的路。

语言 私有/保护机制 实际约束力
Python 约定 _ 表示“保护”,__ 触发名称重整(_ClassName__attr 全靠自觉,没有真正的、硬件上的访问限制
JavaScript 旧版用闭包、WeakMap 模拟;ES2020 引入 # 私有字段 # 开头的字段是真正无法外部访问的
Java private、默认、protectedpublic 硬性约束,编译器严格检查
C++ privateprotectedpublic 硬性约束,编译时报错

在 Python 中,你看到以一个下划线 _ 开头的属性和方法(如 _internal),就是开发者之间的一个默契约定:“嘿,这是内部用的,别在外边直接碰,后果我不负责。” 虽然你硬要访问也能,但通常不这么做。

而以双下划线 __ 开头、结尾最多一个下划线的属性(如 __secret),会触发“名称重整”,Python 会自动把它改成 _ClassName__secret,这能有效避免子类不小心覆盖同名属性,但依然无法做到绝对隐藏。

Python 的设计哲学就是相信开发者会遵守约定,换取最大的灵活性和简洁性。


以上为个人学习总结,旨在梳理个人理解。如有疏漏或不当之处,欢迎指正与交流。如果文章对你有帮助,别忘了点个赞、留个言,让更多的小伙伴看到~ 我们下篇再见!

更多推荐