15  面向对象编程

15.1 认识类与对象

函数可以将完成特定功能的代码封装,实现代码的复用与模块化。 但函数封装的是行为,与行为相关的数据仍然是散落的。

例如,用函数管理银行账户:

# owner 所有人
# account 账户
# balance 余额
def create_account(owner, account_number, balance=0):
    return {'owner': owner, 'account_number': account_number, 'balance': balance}

# deposit 存款
# withdraw 取款
def deposit(account, amount):
    account['balance'] += amount

def withdraw(account, amount):
    if amount <= account['balance']:
        account['balance'] -= amount
    else:
        print('余额不足')

# display 显示
def display(account):
    print(f"户主:{account['owner']},账号:{account['account_number']},余额:{account['balance']}")

账户数据是字典,操作数据的函数与数据本身是分离的,这会带来一些麻烦或不便,如:

  • 函数里的校验逻辑可以被绕过。withdraw()会再取钱前检查余额是否充足,避免余额为负,但没有机制阻止你写 account['balance'] = -9999,把余额直接改成负数。
  • 操作函数时还要手动传入 account 参数,函数本身并不知道它应该操作哪个账户。

面向对象编程(Object-Oriented Programming, OOP)可以解决上述问题。它将数据与操作数据的行为封装在一起,形成(class)。用类可创建具体的实例(instance)。 类与实例的关系,类似于一般概念与具体个体的关系:

  • “行星”是一般概念,“地球”是一个这个概念下的具体个体;
  • “猫”是一般概念,学校草坪上的那只小黑猫是这个概念下的具体个体。

在编程语言的语境中:

  • 类是模板,定义了对象应该有哪些属性、能执行哪些方法,但类本身不存储具体数据。
  • 实例是按类的模板创建出来的实体,每个实例独立存储各自的属性值。
  • 实例具有数据,称为属性(attribute)。
  • 实例也可以进行一些行为,通过函数实现,实例专用的函数通常称为方法(method)。

如可以创建一个“银行账户”类,用以描述账户的共同特征:有户主、账号、余额,能存款、取款、查询余额。

“张三的账户”是一个具体的实例(instance),它有具体的户主姓名、账号和余额等属性;此账户可以进行的存款,取款,查询余额操作,是其方法

此前学过,type(obj)返回对象obj的类型。

obj的类型,就是obj所属的类。而对象obj,就是相关类的实例。

liststrdict 就是类,[1, 2, 3]"hello"{'a': 1} 就是它们的实例。 调用方法时写的 s.upper()L.append(4),就是在调用实例的方法。

# [1,2,3]是list类的实例
print(type([1, 2, 3]))
print(type("hello"))
print(type({'a': 1}))
<class 'list'>
<class 'str'>
<class 'dict'>

15.2 定义类与创建实例

Python提供内置类,也允许用户定义类。

15.2.1 __init__()self

定义类使用 class 关键字,语法如下:

class 类名:
    类体

类名习惯使用首字母大写的驼峰命名法(如 BankAccount),以区别于函数和变量。

class BankAccount:
    def __init__(self, owner, account_number):
        self.owner = owner
        self.account_number = account_number
        self.balance = 0

__init__() 是一个特殊方法,用于初始化实例(initialization)。 此方法在创建实例时自动调用,不需手动调用。

self 指代当前实例本身。 在 __init__() 中,通过 self.属性名 = 值 为实例绑定属性。

创建实例的语法为 类名(实参),实参传递给 __init__() 的形参。(不包括 self):

调用类方法时,Python 自动将实例本身作为第一个参数传入 self,因此不需要手动传递self参数。

acc1 = BankAccount('张三', '62220001')
acc2 = BankAccount('李四', '62220002')

上述代码创建了两个 BankAccount 实例。 acc1acc2 各自独立存储属性值:

print(acc1.owner, acc1.account_number, acc1.balance)
print(acc2.owner, acc2.account_number, acc2.balance)
张三 62220001 0
李四 62220002 0

修改一个实例的属性,不影响另一个实例:

acc1.balance = 1000
print(acc1.balance)
print(acc2.balance)
1000
0

15.2.2 属性与方法

类体中定义的函数称为方法。 与普通函数的区别是:方法的第一个参数是 self,调用时不需手动传入。

# 为 `BankAccount` 添加方法
class BankAccount:
    def __init__(self, owner, account_number):
        self.owner = owner
        self.account_number = account_number
        self.balance = 0

    def deposit(self, amount):
        '''存款'''
        self.balance += amount

    def withdraw(self, amount):
        '''取款'''
        if amount <= self.balance:
            self.balance -= amount
        else:
            print('余额不足')

    def display(self):
        '''显示账户信息'''
        print(f'户主:{self.owner},账号:{self.account_number},余额:{self.balance}')

实例.方法名() 的形式调用方法。

acc = BankAccount('张三', '62220001')
acc.deposit(1000)
acc.display()
acc.withdraw(300)
acc.display()
户主:张三,账号:62220001,余额:1000
户主:张三,账号:62220001,余额:700
# 取款超过余额时,方法会给出提示
acc.withdraw(1000)
acc.display()
余额不足
户主:张三,账号:62220001,余额:700

方法的调用本质上是将实例作为第一个参数传入函数。

# 以下两种写法等价
acc.display()
BankAccount.display(acc)
户主:张三,账号:62220001,余额:700
户主:张三,账号:62220001,余额:700

通常使用前一种写法,后一种写法有助于理解 self 的含义。

可在实例创建后动态添加属性。 但动态添加的属性不在类定义中,其他实例不会有此属性,一般不推荐使用。

acc.phone = '13800138000'
print(acc.phone)
13800138000

15.3 继承

15.3.1 子类

继承允许在已有类的基础上定义新类。 已有类称为父类或基类(base),新类称为子类或派生类。 子类自动获得父类的属性和方法,还可以定义自己特有的属性和方法。

定义子类的语法:

class 子类名(父类名):
    子类体

下面定义 SavingsAccount(储蓄账户)继承自 BankAccount

class SavingsAccount(BankAccount):
    def __init__(self, owner, account_number, interest_rate):
        super().__init__(owner, account_number)
        self.interest_rate = interest_rate

    def add_interest(self):
        '''按利率计算并增加利息'''
        interest = self.balance * self.interest_rate
        self.balance += interest
        print(f'利息:{interest:.2f}')

SavingsAccount 自动拥有 BankAccountdeposit()withdraw()display() 方法, 同时新增了 interest_rate 属性和 add_interest() 方法:

sacc = SavingsAccount('王五', '62220003', 0.02)
sacc.deposit(10000)
sacc.add_interest()
sacc.display()
利息:200.00
户主:王五,账号:62220003,余额:10200.0

15.3.2 super() 与方法重写

super() 的作用是在子类中引用父类。 在 __init__() 中,super().__init__() 调用父类的初始化方法, 避免重复编写父类的初始化代码。

子类中可以重写父类的方法,即定义与父类同名的方法,覆盖父类的实现。 重写时,可以用 super() 调用父类的方法,在此基础上增加子类特有的逻辑。

下面定义 CreditAccount(信用卡账户),它允许在透支额度内取款:

class CreditAccount(BankAccount):
    def __init__(self, owner, account_number, credit_limit):
        super().__init__(owner, account_number)
        self.credit_limit = credit_limit

    def withdraw(self, amount):
        '''重写取款方法:允许在透支额度内取款'''
        if amount <= self.balance + self.credit_limit:
            self.balance -= amount
        else:
            print('超出透支额度')

    def display(self):
        '''重写显示方法:增加透支额度信息'''
        super().display()
        print(f'透支额度:{self.credit_limit}')

CreditAccount 重写了 withdraw()display()withdraw() 在父类逻辑基础上增加了透支判断; display()super().display() 调用父类的显示逻辑,再追加透支额度信息。

cacc = CreditAccount('赵六', '62220004', 5000)
cacc.deposit(1000)
cacc.display()
cacc.withdraw(3000)
cacc.display()
cacc.withdraw(5000)
户主:赵六,账号:62220004,余额:1000
透支额度:5000
户主:赵六,账号:62220004,余额:-2000
透支额度:5000
超出透支额度

15.4 练习

  1. 定义一个 Question 类表示选择题。属性包括:题干 text、选项 options(列表)、正确答案 answer(如 'A')。方法 check(self, choice) 接受学生的选择,判断是否正确,返回 TrueFalse。创建几道选择题并测试。

  2. 定义一个 Character 类表示RPG游戏角色。属性包括:名称 name、血量 hp、攻击力 attack_power。方法包括:attack(self, target) 对目标角色造成伤害(目标血量减少)、is_alive() 判断角色是否存活、show_status() 显示角色状态。

  3. 在第2题的基础上,创建 Character 的子类 Tank(坦克)和 Mage(法师)。坦克有额外的 shield(护盾)属性,受到攻击时先消耗护盾,再消耗血量;法师有额外的 mp(魔法值)属性和 fire_ball(self, target) 方法(消耗魔法值,造成高额伤害)。测试各子类的特殊能力。

  4. 在2,3题的基础上创建两个角色,模拟对战。