2022年 11月 4日

4、Python面向对象编程

类和对象

简单的说,类是对象的蓝图和模板,而对象是类的实例。类是抽象的概念,而对象是具体的东西。

在面向对象编程的世界中,一切皆为对象,对象都有属性和行为,每个对象都是独一无二的,而且对象一定属于某个类(型)。当我们把一大堆拥有共同特征的对象的静态特征(属性)和动态特征(行为)都抽取出来后,就可以定义出一个叫做“类”的东西。

定义类

在Python中可以使用class关键字定义类,然后在类中通过之前学习过的函数来定义方法,这样就可以将对象的动态特征描述出来,代码如下所示。

  1. class Student(object):
  2. # __init__是一个特殊方法用于在创建对象时进行初始化操作
  3. # 通过这个方法我们可以为学生对象绑定name和age两个属性
  4. def __init__(self, name, age):
  5. self.name = name
  6. self.age = age
  7. def study(self, course_name):
  8. print('%s正在学习%s.' % (self.name, course_name))
  9. # PEP 8要求标识符的名字用全小写多个单词用下划线连接
  10. # 但是部分程序员和公司更倾向于使用驼峰命名法(驼峰标识)
  11. def watch_movie(self):
  12. if self.age < 18:
  13. print('%s只能观看《熊出没》.' % self.name)
  14. else:
  15. print('%s正在观看岛国爱情大电影.' % self.name)

说明: 写在类中的函数,我们通常称之为(对象的)方法,这些方法就是对象可以接收的消息。

创建和使用对象 

当我们定义好一个类之后,可以通过下面的方式来创建对象并给对象发消息。

  1. def main():
  2. # 创建学生对象并指定姓名和年龄
  3. stu1 = Student('骆昊', 38)
  4. # 给对象发study消息
  5. stu1.study('Python程序设计')
  6. # 给对象发watch_av消息
  7. stu1.watch_movie()
  8. stu2 = Student('王大锤', 15)
  9. stu2.study('思想品德')
  10. stu2.watch_movie()
  11. if __name__ == '__main__':
  12. main()

访问可见性问题

对于上面的代码,有C++、Java、C#等编程经验的程序员可能会问,我们给Student对象绑定的nameage属性到底具有怎样的访问权限(也称为可见性)。因为在很多面向对象编程语言中,我们通常会将对象的属性设置为私有的(private)或受保护的(protected),简单的说就是不允许外界访问,而对象的方法通常都是公开的(public),因为公开的方法就是对象能够接受的消息。在Python中,属性和方法的访问权限只有两种,也就是公开的和私有的,如果希望属性是私有的,在给属性命名时可以用两个下划线作为开头,下面的代码可以验证这一点。

  1. class Test:
  2. def __init__(self, foo):
  3. self.__foo = foo
  4. def __bar(self):
  5. print(self.__foo)
  6. print('__bar')
  7. def main():
  8. test = Test('hello')
  9. # AttributeError: 'Test' object has no attribute '__bar'
  10. test.__bar()
  11. # AttributeError: 'Test' object has no attribute '__foo'
  12. print(test.__foo)
  13. if __name__ == "__main__":
  14. main()

但是,Python并没有从语法上严格保证私有属性或方法的私密性,它只是给私有的属性和方法换了一个名字来妨碍对它们的访问,事实上如果你知道更换名字的规则仍然可以访问到它们,下面的代码就可以验证这一点。之所以这样设定,可以用这样一句名言加以解释,就是”We are all consenting adults here“。因为绝大多数程序员都认为开放比封闭要好,而且程序员要自己为自己的行为负责。

  1. class Test:
  2. def __init__(self, foo):
  3. self.__foo = foo
  4. def __bar(self):
  5. print(self.__foo)
  6. print('__bar')
  7. def main():
  8. test = Test('hello')
  9. test._Test__bar()
  10. print(test._Test__foo)
  11. if __name__ == "__main__":
  12. main()

在实际开发中,我们并不建议将属性设置为私有的,因为这会导致子类无法访问(后面会讲到)。所以大多数Python程序员会遵循一种命名惯例就是让属性名以单下划线开头来表示属性是受保护的,本类之外的代码在访问这样的属性时应该要保持慎重。这种做法并不是语法上的规则,单下划线开头的属性和方法外界仍然是可以访问的,所以更多的时候它是一种暗示或隐喻,关于这一点可以看看我的《Python – 那些年我们踩过的那些坑》文章中的讲解。

面向对象的支柱

面向对象有三大支柱:封装、继承和多态。后面两个概念在下一个章节中进行详细的说明,这里我们先说一下什么是封装。我自己对封装的理解是”隐藏一切可以隐藏的实现细节,只向外界暴露(提供)简单的编程接口”。我们在类中定义的方法其实就是把数据和对数据的操作封装起来了,在我们创建了对象之后,只需要给对象发送一个消息(调用方法)就可以执行方法中的代码,也就是说我们只需要知道方法的名字和传入的参数(方法的外部视图),而不需要知道方法内部的实现细节(方法的内部视图)。

@property装饰器

之前我们讨论过Python中属性和方法访问权限的问题,虽然我们不建议将属性设置为私有的,但是如果直接将属性暴露给外界也是有问题的,比如我们没有办法检查赋给属性的值是否有效。我们之前的建议是将属性命名以单下划线开头,通过这种方式来暗示属性是受保护的,不建议外界直接访问,那么如果想访问属性可以通过属性的getter(访问器)和setter(修改器)方法进行对应的操作。如果要做到这点,就可以考虑使用@property包装器来包装getter和setter方法,使得对属性的访问既安全又方便,代码如下所示。

  1. class Person(object):
  2. def __init__(self, name, age):
  3. self._name = name
  4. self._age = age
  5. # 访问器 - getter方法
  6. @property
  7. def name(self):
  8. return self._name
  9. # 访问器 - getter方法
  10. @property
  11. def age(self):
  12. return self._age
  13. # 修改器 - setter方法
  14. @age.setter
  15. def age(self, age):
  16. self._age = age
  17. def play(self):
  18. if self._age <= 16:
  19. print('%s正在玩飞行棋.' % self._name)
  20. else:
  21. print('%s正在玩斗地主.' % self._name)
  22. def main():
  23. person = Person('王大锤', 12)
  24. person.play()
  25. person.age = 22
  26. person.play()
  27. # person.name = '白元芳' # AttributeError: can't set attribute
  28. if __name__ == '__main__':
  29. main()

__slots__魔法

我们讲到这里,不知道大家是否已经意识到,Python是一门动态语言。通常,动态语言允许我们在程序运行时给对象绑定新的属性或方法,当然也可以对已经绑定的属性和方法进行解绑定。但是如果我们需要限定自定义类型的对象只能绑定某些属性,可以通过在类中定义__slots__变量来进行限定。需要注意的是__slots__的限定只对当前类的对象生效,对子类并不起任何作用。

  1. class Person(object):
  2. # 限定Person对象只能绑定_name, _age和_gender属性
  3. __slots__ = ('_name', '_age', '_gender')
  4. def __init__(self, name, age):
  5. self._name = name
  6. self._age = age
  7. @property
  8. def name(self):
  9. return self._name
  10. @property
  11. def age(self):
  12. return self._age
  13. @age.setter
  14. def age(self, age):
  15. self._age = age
  16. def play(self):
  17. if self._age <= 16:
  18. print('%s正在玩飞行棋.' % self._name)
  19. else:
  20. print('%s正在玩斗地主.' % self._name)
  21. def main():
  22. person = Person('王大锤', 22)
  23. person.play()
  24. person._gender = '男'
  25. # AttributeError: 'Person' object has no attribute '_is_gay'
  26. # person._is_gay = True

静态方法和类方法

之前,我们在类中定义的方法都是对象方法,也就是说这些方法都是发送给对象的消息。实际上,我们写在类中的方法并不需要都是对象方法,例如我们定义一个“三角形”类,通过传入三条边长来构造三角形,并提供计算周长和面积的方法,但是传入的三条边长未必能构造出三角形对象,因此我们可以先写一个方法来验证三条边长是否可以构成三角形,这个方法很显然就不是对象方法,因为在调用这个方法时三角形对象尚未创建出来(因为都不知道三条边能不能构成三角形),所以这个方法是属于三角形类而并不属于三角形对象的。我们可以使用静态方法来解决这类问题,代码如下所示。

  1. from math import sqrt
  2. class Triangle(object):
  3. def __init__(self, a, b, c):
  4. self._a = a
  5. self._b = b
  6. self._c = c
  7. @staticmethod
  8. def is_valid(a, b, c):
  9. return a + b > c and b + c > a and a + c > b
  10. def perimeter(self):
  11. return self._a + self._b + self._c
  12. def area(self):
  13. half = self.perimeter() / 2
  14. return sqrt(half * (half - self._a) *
  15. (half - self._b) * (half - self._c))
  16. def main():
  17. a, b, c = 3, 4, 5
  18. # 静态方法和类方法都是通过给类发消息来调用的
  19. if Triangle.is_valid(a, b, c):
  20. t = Triangle(a, b, c)
  21. print(t.perimeter())
  22. # 也可以通过给类发消息来调用对象方法但是要传入接收消息的对象作为参数
  23. # print(Triangle.perimeter(t))
  24. print(t.area())
  25. # print(Triangle.area(t))
  26. else:
  27. print('无法构成三角形.')
  28. if __name__ == '__main__':
  29. main()

和静态方法比较类似,Python还可以在类中定义类方法,类方法的第一个参数约定名为cls,它代表的是当前类相关的信息的对象(类本身也是一个对象,有的地方也称之为类的元数据对象),通过这个参数我们可以获取和类相关的信息并且可以创建出类的对象,代码如下所示。

  1. from time import time, localtime, sleep
  2. class Clock(object):
  3. """数字时钟"""
  4. def __init__(self, hour=0, minute=0, second=0):
  5. self._hour = hour
  6. self._minute = minute
  7. self._second = second
  8. @classmethod
  9. def now(cls):
  10. ctime = localtime(time())
  11. return cls(ctime.tm_hour, ctime.tm_min, ctime.tm_sec)
  12. def run(self):
  13. """走字"""
  14. self._second += 1
  15. if self._second == 60:
  16. self._second = 0
  17. self._minute += 1
  18. if self._minute == 60:
  19. self._minute = 0
  20. self._hour += 1
  21. if self._hour == 24:
  22. self._hour = 0
  23. def show(self):
  24. """显示时间"""
  25. return '%02d:%02d:%02d' % \
  26. (self._hour, self._minute, self._second)
  27. def main():
  28. # 通过类方法创建对象并获取系统时间
  29. clock = Clock.now()
  30. while True:
  31. print(clock.show())
  32. sleep(1)
  33. clock.run()
  34. if __name__ == '__main__':
  35. main()

类之间的关系

简单的说,类和类之间的关系有三种:is-a、has-a和use-a关系。

  • is-a关系也叫继承或泛化,比如学生和人的关系、手机和电子产品的关系都属于继承关系。
  • has-a关系通常称之为关联,比如部门和员工的关系,汽车和引擎的关系都属于关联关系;关联关系如果是整体和部分的关联,那么我们称之为聚合关系;如果整体进一步负责了部分的生命周期(整体和部分是不可分割的,同时同在也同时消亡),那么这种就是最强的关联关系,我们称之为合成关系。
  • use-a关系通常称之为依赖,比如司机有一个驾驶的行为(方法),其中(的参数)使用到了汽车,那么司机和汽车的关系就是依赖关系。

我们可以使用一种叫做UML(统一建模语言)的东西来进行面向对象建模,其中一项重要的工作就是把类和类之间的关系用标准化的图形符号描述出来。关于UML我们在这里不做详细的介绍,有兴趣的读者可以自行阅读《UML面向对象设计基础》一书。

利用类之间的这些关系,我们可以在已有类的基础上来完成某些操作,也可以在已有类的基础上创建新的类,这些都是实现代码复用的重要手段。复用现有的代码不仅可以减少开发的工作量,也有利于代码的管理和维护,这是我们在日常工作中都会使用到的技术手段。

继承和多态

刚才我们提到了,可以在已有类的基础上创建新类,这其中的一种做法就是让一个类从另一个类那里将属性和方法直接继承下来,从而减少重复代码的编写。提供继承信息的我们称之为父类,也叫超类或基类;得到继承信息的我们称之为子类,也叫派生类或衍生类。子类除了继承父类提供的属性和方法,还可以定义自己特有的属性和方法,所以子类比父类拥有的更多的能力,在实际开发中,我们经常会用子类对象去替换掉一个父类对象,这是面向对象编程中一个常见的行为,对应的原则称之为里氏替换原则。下面我们先看一个继承的例子。

  1. class Person(object):
  2. """人"""
  3. def __init__(self, name, age):
  4. self._name = name
  5. self._age = age
  6. @property
  7. def name(self):
  8. return self._name
  9. @property
  10. def age(self):
  11. return self._age
  12. @age.setter
  13. def age(self, age):
  14. self._age = age
  15. def play(self):
  16. print('%s正在愉快的玩耍.' % self._name)
  17. def watch_av(self):
  18. if self._age >= 18:
  19. print('%s正在观看爱情动作片.' % self._name)
  20. else:
  21. print('%s只能观看《熊出没》.' % self._name)
  22. class Student(Person):
  23. """学生"""
  24. def __init__(self, name, age, grade):
  25. super().__init__(name, age)
  26. self._grade = grade
  27. @property
  28. def grade(self):
  29. return self._grade
  30. @grade.setter
  31. def grade(self, grade):
  32. self._grade = grade
  33. def study(self, course):
  34. print('%s的%s正在学习%s.' % (self._grade, self._name, course))
  35. class Teacher(Person):
  36. """老师"""
  37. def __init__(self, name, age, title):
  38. super().__init__(name, age)
  39. self._title = title
  40. @property
  41. def title(self):
  42. return self._title
  43. @title.setter
  44. def title(self, title):
  45. self._title = title
  46. def teach(self, course):
  47. print('%s%s正在讲%s.' % (self._name, self._title, course))
  48. def main():
  49. stu = Student('王大锤', 15, '初三')
  50. stu.study('数学')
  51. stu.watch_av()
  52. t = Teacher('骆昊', 38, '砖家')
  53. t.teach('Python程序设计')
  54. t.watch_av()
  55. if __name__ == '__main__':
  56. main()

子类在继承了父类的方法后,可以对父类已有的方法给出新的实现版本,这个动作称之为方法重写(override)。通过方法重写我们可以让父类的同一个行为在子类中拥有不同的实现版本,当我们调用这个经过子类重写的方法时,不同的子类对象会表现出不同的行为,这个就是多态(poly-morphism)。

  1. from abc import ABCMeta, abstractmethod
  2. class Pet(object, metaclass=ABCMeta):
  3. """宠物"""
  4. def __init__(self, nickname):
  5. self._nickname = nickname
  6. @abstractmethod
  7. def make_voice(self):
  8. """发出声音"""
  9. pass
  10. class Dog(Pet):
  11. """狗"""
  12. def make_voice(self):
  13. print('%s: 汪汪汪...' % self._nickname)
  14. class Cat(Pet):
  15. """猫"""
  16. def make_voice(self):
  17. print('%s: 喵...喵...' % self._nickname)
  18. def main():
  19. pets = [Dog('旺财'), Cat('凯蒂'), Dog('大黄')]
  20. for pet in pets:
  21. pet.make_voice()
  22. if __name__ == '__main__':
  23. main()

在上面的代码中,我们将Pet类处理成了一个抽象类,所谓抽象类就是不能够创建对象的类,这种类的存在就是专门为了让其他类去继承它。Python从语法层面并没有像Java或C#那样提供对抽象类的支持,但是我们可以通过abc模块的ABCMeta元类和abstractmethod包装器来达到抽象类的效果,如果一个类中存在抽象方法那么这个类就不能够实例化(创建对象)。上面的代码中,DogCat两个子类分别对Pet类中的make_voice抽象方法进行了重写并给出了不同的实现版本,当我们在main函数中调用该方法时,这个方法就表现出了多态行为(同样的方法做了不同的事情)。