博客

  • 面向对象编程应用

    面向对象编程应用

    面向对象编程对初学者来说不难理解但很难应用,虽然我们为大家总结过面向对象的三步走方法(定义类、创建对象、给对象发消息),但是说起来容易做起来难。大量的编程练习阅读优质的代码可能是这个阶段最能够帮助到大家的两件事情。接下来我们还是通过经典的案例来剖析面向对象编程的知识,同时也通过这些案例把我们之前学过的 Python 知识都串联起来。

    例子1:扑克游戏。

    > 说明:简单起见,我们的扑克只有52张牌(没有大小王),游戏需要将 52 张牌发到 4 个玩家的手上,每个玩家手上有 13 张牌,按照黑桃、红心、草花、方块的顺序和点数从小到大排列,暂时不实现其他的功能。

    使用面向对象编程方法,首先需要从问题的需求中找到对象并抽象出对应的类,此外还要找到对象的属性和行为。当然,这件事情并不是特别困难,我们可以从需求的描述中找出名词和动词,名词通常就是对象或者是对象的属性,而动词通常是对象的行为。扑克游戏中至少应该有三类对象,分别是牌、扑克和玩家,牌、扑克、玩家三个类也并不是孤立的。类和类之间的关系可以粗略的分为 is-a关系(继承)has-a关系(关联)use-a关系(依赖)。很显然扑克和牌是 has-a 关系,因为一副扑克有(has-a)52 张牌;玩家和牌之间不仅有关联关系还有依赖关系,因为玩家手上有(has-a)牌而且玩家使用了(use-a)牌。

    牌的属性显而易见,有花色和点数。我们可以用 0 到 3 的四个数字来代表四种不同的花色,但是这样的代码可读性会非常糟糕,因为我们并不知道黑桃、红心、草花、方块跟 0 到 3 的数字的对应关系。如果一个变量的取值只有有限多个选项,我们可以使用枚举。与 C、Java 等语言不同的是,Python 中没有声明枚举类型的关键字,但是可以通过继承enum模块的Enum类来创建枚举类型,代码如下所示。

    from enum import Enum

    class Suite(Enum): """花色(枚举)""" SPADE, HEART, CLUB, DIAMOND = range(4)

    通过上面的代码可以看出,定义枚举类型其实就是定义符号常量,如SPADEHEART等。每个符号常量都有与之对应的值,这样表示黑桃就可以不用数字 0,而是用Suite.SPADE;同理,表示方块可以不用数字 3, 而是用Suite.DIAMOND。注意,使用符号常量肯定是优于使用字面常量的,因为能够读懂英文就能理解符号常量的含义,代码的可读性会提升很多。Python 中的枚举类型是可迭代类型,简单的说就是可以将枚举类型放到for-in循环中,依次取出每一个符号常量及其对应的值,如下所示。

    for suite in Suite:
        print(f'{suite}: {suite.value}')
    

    接下来我们可以定义牌类。

    class Card:
        """牌"""

    def __init__(self, suite, face): self.suite = suite self.face = face

    def __repr__(self): suites = '♠♥♣♦' faces = ['', 'A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'] return f'{suites[self.suite.value]}{faces[self.face]}' # 返回牌的花色和点数

    可以通过下面的代码来测试下Card类。

    card1 = Card(Suite.SPADE, 5)
    card2 = Card(Suite.HEART, 13)
    print(card1)  # ♠5 
    print(card2)  # ♥K
    

    接下来我们定义扑克类。

    import random

    class Poker: """扑克"""

    def __init__(self): self.cards = [Card(suite, face) for suite in Suite for face in range(1, 14)] # 52张牌构成的列表 self.current = 0 # 记录发牌位置的属性

    def shuffle(self): """洗牌""" self.current = 0 random.shuffle(self.cards) # 通过random模块的shuffle函数实现随机乱序

    def deal(self): """发牌""" card = self.cards[self.current] self.current += 1 return card

    @property def has_next(self): """还有没有牌可以发""" return self.current < len(self.cards)

    可以通过下面的代码来测试下Poker类。

    poker = Poker()
    print(poker.cards)  # 洗牌前的牌
    poker.shuffle()
    print(poker.cards)  # 洗牌后的牌
    

    定义玩家类。

    class Player:
        """玩家"""

    def __init__(self, name): self.name = name self.cards = [] # 玩家手上的牌

    def get_one(self, card): """摸牌""" self.cards.append(card)

    def arrange(self): """整理手上的牌""" self.cards.sort()

    创建四个玩家并将牌发到玩家的手上。

    poker = Poker()
    poker.shuffle()
    players = [Player('东邪'), Player('西毒'), Player('南帝'), Player('北丐')]
    

    将牌轮流发到每个玩家手上每人13张牌

    for _ in range(13): for player in players: player.get_one(poker.deal())

    玩家整理手上的牌输出名字和手牌

    for player in players: player.arrange() print(f'{player.name}: ', end='') print(player.cards)

    执行上面的代码会在player.arrange()那里出现异常,因为Playerarrange方法使用了列表的sort对玩家手上的牌进行排序,排序需要比较两个Card对象的大小,而<运算符又不能直接作用于Card类型,所以就出现了TypeError异常,异常消息为:'<' not supported between instances of 'Card' and 'Card'

    为了解决这个问题,我们可以对Card类的代码稍作修改,使得两个Card对象可以直接用<进行大小的比较。这里用到技术叫运算符重载,Python 中要实现对<运算符的重载,需要在类中添加一个名为__lt__的魔术方法。很显然,魔术方法__lt__中的lt是英文单词“less than”的缩写,以此类推,魔术方法__gt__对应>运算符,魔术方法__le__对应<=运算符,__ge__对应>=运算符,__eq__对应==运算符,__ne__对应!=运算符。

    修改后的Card类代码如下所示。

    class Card:
        """牌"""

    def __init__(self, suite, face): self.suite = suite self.face = face

    def __repr__(self): suites = '♠♥♣♦' faces = ['', 'A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'] return f'{suites[self.suite.value]}{faces[self.face]}' def __lt__(self, other): if self.suite == other.suite: return self.face < other.face # 花色相同比较点数的大小 return self.suite.value < other.suite.value # 花色不同比较花色对应的值

    >说明: 大家可以尝试在上面代码的基础上写一个简单的扑克游戏,如 21 点游戏(Black Jack),游戏的规则可以自己在网上找一找。

    例子2:工资结算系统。

    > 要求:某公司有三种类型的员工,分别是部门经理、程序员和销售员。需要设计一个工资结算系统,根据提供的员工信息来计算员工的月薪。其中,部门经理的月薪是固定 15000 元;程序员按工作时间(以小时为单位)支付月薪,每小时 200 元;销售员的月薪由 1800 元底薪加上销售额 5% 的提成两部分构成。

    通过对上述需求的分析,可以看出部门经理、程序员、销售员都是员工,有相同的属性和行为,那么我们可以先设计一个名为Employee的父类,再通过继承的方式从这个父类派生出部门经理、程序员和销售员三个子类。很显然,后续的代码不会创建Employee 类的对象,因为我们需要的是具体的员工对象,所以这个类可以设计成专门用于继承的抽象类。Python 语言中没有定义抽象类的关键字,但是可以通过abc模块中名为ABCMeta 的元类来定义抽象类。关于元类的概念此处不展开讲解,当然大家不用纠结,照做即可。

    from abc import ABCMeta, abstractmethod

    class Employee(metaclass=ABCMeta): """员工"""

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

    @abstractmethod def get_salary(self): """结算月薪""" pass

    在上面的员工类中,有一个名为get_salary的方法用于结算月薪,但是由于还没有确定是哪一类员工,所以结算月薪虽然是员工的公共行为但这里却没有办法实现。对于暂时无法实现的方法,我们可以使用abstractmethod装饰器将其声明为抽象方法,所谓抽象方法就是只有声明没有实现的方法声明这个方法是为了让子类去重写这个方法。接下来的代码展示了如何从员工类派生出部门经理、程序员、销售员这三个子类以及子类如何重写父类的抽象方法。

    class Manager(Employee):
        """部门经理"""

    def get_salary(self): return 15000.0

    class Programmer(Employee): """程序员"""

    def __init__(self, name, working_hour=0): super().__init__(name) self.working_hour = working_hour

    def get_salary(self): return 200 * self.working_hour

    class Salesman(Employee): """销售员"""

    def __init__(self, name, sales=0): super().__init__(name) self.sales = sales

    def get_salary(self): return 1800 + self.sales * 0.05

    上面的ManagerProgrammerSalesman三个类都继承自Employee,三个类都分别重写了get_salary方法。重写就是子类对父类已有的方法重新做出实现。相信大家已经注意到了,三个子类中的get_salary各不相同,所以这个方法在程序运行时会产生多态行为,多态简单的说就是调用相同的方法不同的子类对象做不同的事情

    我们通过下面的代码来完成这个工资结算系统,由于程序员和销售员需要分别录入本月的工作时间和销售额,所以在下面的代码中我们使用了 Python 内置的isinstance函数来判断员工对象的类型。我们之前讲过的type函数也能识别对象的类型,但是isinstance函数更加强大,因为它可以判断出一个对象是不是某个继承结构下的子类型,你可以简单的理解为type函数是对对象类型的精准匹配,而isinstance函数是对对象类型的模糊匹配。

    emps = [Manager('刘备'), Programmer('诸葛亮'), Manager('曹操'), Programmer('荀彧'), Salesman('张辽')]
    for emp in emps:
        if isinstance(emp, Programmer):
            emp.working_hour = int(input(f'请输入{emp.name}本月工作时间: '))
        elif isinstance(emp, Salesman):
            emp.sales = float(input(f'请输入{emp.name}本月销售额: '))
        print(f'{emp.name}本月工资为: ¥{emp.get_salary():.2f}元')
    

    总结

    面向对象编程思想非常的好,也符合人类的正常思维习惯,但是要想灵活运用面向对象编程中的抽象、封装、继承、多态需要长时间的积累和沉淀,这件事情无法一蹴而就,因为知识的积累本就是涓滴成河的过程。

  • 面向对象编程进阶

    面向对象编程进阶

    前面我们讲解了 Python 面向对象编程的一些基础知识,本节我们继续讨论面向对象编程相关的内容。

    可见性和属性装饰器

    在很多面向对象编程语言中,对象的属性通常会被设置为私有(private)或受保护(protected)的成员,简单的说就是不允许直接访问这些属性;对象的方法通常都是公开的(public),因为公开的方法是对象能够接受的消息,也是对象暴露给外界的调用接口,这就是所谓的访问可见性。在 Python 中,可以通过给对象属性名添加前缀下划线的方式来说明属性的访问可见性,例如,可以用__name表示一个私有属性,_name表示一个受保护属性,代码如下所示。

    class Student:

    def __init__(self, name, age): self.__name = name self.__age = age

    def study(self, course_name): print(f'{self.__name}正在学习{course_name}.')

    stu = Student('王大锤', 20) stu.study('Python程序设计') print(stu.__name) # AttributeError: 'Student' object has no attribute '__name'

    上面代码的最后一行会引发AttributeError(属性错误)异常,异常消息为:'Student' object has no attribute '__name'。由此可见,以__开头的属性__name相当于是私有的,在类的外面无法直接访问,但是类里面的study方法中可以通过self.__name访问该属性。需要说明的是,大多数使用 Python 语言的人在定义类时,通常不会选择让对象的属性私有或受保护,正如有一句名言说的:“We are all consenting adults here”(大家都是成年人),成年人可以为自己的行为负责,而不需要通过 Python 语言本身来限制访问可见性。事实上,大多数的程序员都认为开放比封闭要好,把对象的属性私有化并非必不可少的东西,所以 Python 语言并没有从语义上做出最严格的限定,也就是说上面的代码如果你愿意,用stu._Student__name的方式仍然可以访问到私有属性__name,有兴趣的读者可以自己试一试。

    动态属性

    Python 语言属于动态语言,维基百科对动态语言的解释是:“在运行时可以改变其结构的语言,例如新的函数、对象、甚至代码可以被引进,已有的函数可以被删除或是其他结构上的变化”。动态语言非常灵活,目前流行的 Python 和 JavaScript 都是动态语言,除此之外,诸如 PHP、Ruby 等也都属于动态语言,而 C、C++ 等语言则不属于动态语言。

    在 Python 中,我们可以动态为对象添加属性,这是 Python 作为动态类型语言的一项特权,代码如下所示。需要提醒大家的是,对象的方法其实本质上也是对象的属性,如果给对象发送一个无法接收的消息,引发的异常仍然是AttributeError

    class Student:

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

    stu = Student('王大锤', 20) stu.sex = '男' # 给学生对象动态添加sex属性

    如果不希望在使用对象时动态的为对象添加属性,可以使用 Python 语言中的__slots__魔法。对于Student类来说,可以在类中指定__slots__ = ('name', 'age'),这样Student类的对象只能有nameage属性,如果想动态添加其他属性将会引发异常,代码如下所示。

    class Student:
        __slots__ = ('name', 'age')

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

    stu = Student('王大锤', 20)

    AttributeError: 'Student' object has no attribute 'sex'

    stu.sex = '男'

    静态方法和类方法

    之前我们在类中定义的方法都是对象方法,换句话说这些方法都是对象可以接收的消息。除了对象方法之外,类中还可以有静态方法和类方法,这两类方法是发给类的消息,二者并没有实质性的区别。在面向对象的世界里,一切皆为对象,我们定义的每一个类其实也是一个对象,而静态方法和类方法就是发送给类对象的消息。那么,什么样的消息会直接发送给类对象呢?

    举一个例子,定义一个三角形类,通过传入三条边的长度来构造三角形,并提供计算周长和面积的方法。计算周长和面积肯定是三角形对象的方法,这一点毫无疑问。但是在创建三角形对象时,传入的三条边长未必能构造出三角形,为此我们可以先写一个方法来验证给定的三条边长是否可以构成三角形,这种方法很显然就不是对象方法,因为在调用这个方法时三角形对象还没有创建出来。我们可以把这类方法设计为静态方法或类方法,也就是说这类方法不是发送给三角形对象的消息,而是发送给三角形类的消息,代码如下所示。

    class Triangle(object):
        """三角形"""

    def __init__(self, a, b, c): """初始化方法""" self.a = a self.b = b self.c = c

    @staticmethod def is_valid(a, b, c): """判断三条边长能否构成三角形(静态方法)""" return a + b > c and b + c > a and a + c > b

    # @classmethod # def is_valid(cls, a, b, c): # """判断三条边长能否构成三角形(类方法)""" # return a + b > c and b + c > a and a + c > b

    def perimeter(self): """计算周长""" return self.a + self.b + self.c

    def area(self): """计算面积""" p = self.perimeter() / 2 return (p (p - self.a) (p - self.b) (p - self.c)) * 0.5

    if Triangle.is_valid(3, 4, 5): t = Triangle(3, 4, 5) print(f'周长: {t.perimeter()}') print(f'面积: {t.area()}') else: print('无效的边长!!!')

    上面的代码使用staticmethod装饰器声明了is_valid方法是Triangle类的静态方法,如果要声明类方法,可以使用classmethod装饰器(如上面的代码15~18行所示)。可以直接使用类名.方法名的方式来调用静态方法和类方法,二者的区别在于,类方法的第一个参数是类对象本身,而静态方法则没有这个参数。简单的总结一下,对象方法、类方法、静态方法都可以通过“类名.方法名”的方式来调用,区别在于方法的第一个参数到底是普通对象还是类对象,还是没有接受消息的对象。静态方法通常也可以直接写成一个独立的函数,因为它并没有跟特定的对象绑定。

    这里做一个补充说明,我们可以给上面计算三角形周长和面积的方法添加一个property装饰器(Python 内置类型),这样三角形类的perimeterarea就变成了两个属性,不再通过调用方法的方式来访问,而是用对象访问属性的方式直接获得,修改后的代码如下所示。

    class Triangle(object):
        """三角形"""

    def __init__(self, a, b, c): """初始化方法""" self.a = a self.b = b self.c = c

    @staticmethod def is_valid(a, b, c): """判断三条边长能否构成三角形(静态方法)""" return a + b > c and b + c > a and a + c > b

    @property def perimeter(self): """计算周长""" return self.a + self.b + self.c

    @property def area(self): """计算面积""" p = self.perimeter / 2 return (p (p - self.a) (p - self.b) (p - self.c)) * 0.5

    if Triangle.is_valid(3, 4, 5): t = Triangle(3, 4, 5) print(f'周长: {t.perimeter}') print(f'面积: {t.area}') else: print('无效的边长!!!')

    继承和多态

    面向对象的编程语言支持在已有类的基础上创建新类,从而减少重复代码的编写。提供继承信息的类叫做父类(超类、基类),得到继承信息的类叫做子类(派生类、衍生类)。例如,我们定义一个学生类和一个老师类,我们会发现他们有大量的重复代码,而这些重复代码都是老师和学生作为人的公共属性和行为,所以在这种情况下,我们应该先定义人类,再通过继承,从人类派生出老师类和学生类,代码如下所示。

    class Person:
        """人"""

    def __init__(self, name, age): self.name = name self.age = age def eat(self): print(f'{self.name}正在吃饭.') def sleep(self): print(f'{self.name}正在睡觉.')

    class Student(Person): """学生""" def __init__(self, name, age): super().__init__(name, age) def study(self, course_name): print(f'{self.name}正在学习{course_name}.')

    class Teacher(Person): """老师"""

    def __init__(self, name, age, title): super().__init__(name, age) self.title = title def teach(self, course_name): print(f'{self.name}{self.title}正在讲授{course_name}.')

    stu1 = Student('白元芳', 21) stu2 = Student('狄仁杰', 22) tea1 = Teacher('武则天', 35, '副教授') stu1.eat() stu2.sleep() tea1.eat() stu1.study('Python程序设计') tea1.teach('Python程序设计') stu2.study('数据科学导论')

    继承的语法是在定义类的时候,在类名后的圆括号中指定当前类的父类。如果定义一个类的时候没有指定它的父类是谁,那么默认的父类是object类。object类是 Python 中的顶级类,这也就意味着所有的类都是它的子类,要么直接继承它,要么间接继承它。Python 语言允许多重继承,也就是说一个类可以有一个或多个父类,关于多重继承的问题我们在后面会有更为详细的讨论。在子类的初始化方法中,我们可以通过super().__init__()来调用父类初始化方法,super函数是 Python 内置函数中专门为获取当前对象的父类对象而设计的。从上面的代码可以看出,子类除了可以通过继承得到父类提供的属性和方法外,还可以定义自己特有的属性和方法,所以子类比父类拥有的更多的能力。在实际开发中,我们经常会用子类对象去替换掉一个父类对象,这是面向对象编程中一个常见的行为,也叫做“里氏替换原则”(Liskov Substitution Principle)。

    子类继承父类的方法后,还可以对方法进行重写(重新实现该方法),不同的子类可以对父类的同一个方法给出不同的实现版本,这样的方法在程序运行时就会表现出多态行为(调用相同的方法,做了不同的事情)。多态是面向对象编程中最精髓的部分,当然也是对初学者来说最难以理解和灵活运用的部分,我们会在下一个章节用专门的例子来讲解这个知识点。

    总结

    Python 是动态类型语言,Python 中的对象可以动态的添加属性,对象的方法其实也是属性,只不过和该属性对应的是一个可以调用的函数。在面向对象的世界中,一切皆为对象,我们定义的类也是对象,所以类也可以接收消息,对应的方法是类方法或静态方法。通过继承,我们可以从已有的类创建新类,实现对已有类代码的复用。

  • 面向对象编程入门

    面向对象编程入门

    面向对象编程是一种非常流行的编程范式(programming paradigm),所谓编程范式就是程序设计的方法论,简单的说就是程序员对程序的认知和理解以及他们编写代码的方式。

    在前面的课程中,我们说过“程序是指令的集合”,运行程序时,程序中的语句会变成一条或多条指令,然后由CPU(中央处理器)去执行。为了简化程序的设计,我们又讲到了函数,把相对独立且经常重复使用的代码放置到函数中,在需要使用这些代码的时候调用函数即可。如果一个函数的功能过于复杂和臃肿,我们又可以进一步将函数进一步拆分为多个子函数来降低系统的复杂性。

    不知大家是否发现,编程其实是写程序的人按照计算机的工作方式通过代码控制机器完成任务。但是,计算机的工作方式与人类正常的思维模式是不同的,如果编程就必须抛弃人类正常的思维方式去迎合计算机,编程的乐趣就少了很多。这里,我想说的并不是我们不能按照计算机的工作方式去编写代码,但是当我们需要开发一个复杂的系统时,这种方式会让代码过于复杂,从而导致开发和维护工作都变得举步维艰。

    随着软件复杂性的增加,编写正确可靠的代码会变成了一项极为艰巨的任务,这也是很多人都坚信“软件开发是人类改造世界所有活动中最为复杂的活动”的原因。如何用程序描述复杂系统和解决复杂问题,就成为了所有程序员必须要思考和直面的问题。诞生于上世纪70年代的 Smalltalk 语言让软件开发者看到了希望,因为它引入了一种新的编程范式叫面向对象编程。在面向对象编程的世界里,程序中的数据和操作数据的函数是一个逻辑上的整体,我们称之为对象对象可以接收消息,解决问题的方法就是创建对象并向对象发出各种各样的消息;通过消息传递,程序中的多个对象可以协同工作,这样就能构造出复杂的系统并解决现实中的问题。当然,面向对象编程的雏形还可以向前追溯到更早期的Simula语言,但这不是我们要讨论的重点。

    > 说明: 今天我们使用的很多高级程序设计语言都支持面向对象编程,但是面向对象编程也不是解决软件开发中所有问题的“银弹”,或者说在软件开发这个行业目前还没有所谓的“银弹”。关于这个问题,大家可以参考 IBM360 系统之父弗雷德里克·布鲁克斯所发表的论文《没有银弹:软件工程的本质性与附属性工作》或软件工程的经典著作《人月神话》一书。

    类和对象

    如果要用一句话来概括面向对象编程,我认为下面的说法是相当精辟和准确的。

    > 面向对象编程:把一组数据和处理数据的方法组成对象,把行为相同的对象归纳为,通过封装隐藏对象的内部细节,通过继承实现类的特化和泛化,通过多态实现基于对象类型的动态分派。

    这句话对初学者来说可能不那么容易理解,但是我可以先为大家圈出几个关键词:对象(object)、(class)、封装(encapsulation)、继承(inheritance)、多态(polymorphism)。

    我们先说说类和对象这两个词。在面向对象编程中,类是一个抽象的概念,对象是一个具体的概念。我们把同一类对象的共同特征抽取出来就是一个类,比如我们经常说的人类,这是一个抽象概念,而我们每个人就是人类的这个抽象概念下的实实在在的存在,也就是一个对象。简而言之,类是对象的蓝图和模板,对象是类的实例,是可以接受消息的实体

    在面向对象编程的世界中,一切皆为对象对象都有属性和行为每个对象都是独一无二的,而且对象一定属于某个类。对象的属性是对象的静态特征,对象的行为是对象的动态特征。按照上面的说法,如果我们把拥有共同特征的对象的属性和行为都抽取出来,就可以定义出一个类。

    定义类

    在 Python 语言中,我们可以使用class关键字加上类名来定义类,通过缩进我们可以确定类的代码块,就如同定义函数那样。在类的代码块中,我们需要写一些函数,我们说过类是一个抽象概念,那么这些函数就是我们对一类对象共同的动态特征的提取。写在类里面的函数我们通常称之为方法,方法就是对象的行为,也就是对象可以接收的消息。方法的第一个参数通常都是self,它代表了接收这个消息的对象本身。

    class Student:

    def study(self, course_name): print(f'学生正在学习{course_name}.')

    def play(self): print(f'学生正在玩游戏.')

    创建和使用对象

    在我们定义好一个类之后,可以使用构造器语法来创建对象,代码如下所示。

    stu1 = Student()
    stu2 = Student()
    print(stu1)    # <__main__.Student object at 0x10ad5ac50>
    print(stu2)    # <__main__.Student object at 0x10ad5acd0> 
    print(hex(id(stu1)), hex(id(stu2)))    # 0x10ad5ac50 0x10ad5acd0
    

    在类的名字后跟上圆括号就是所谓的构造器语法,上面的代码创建了两个学生对象,一个赋值给变量stu1,一个赋值给变量stu2。当我们用print函数打印stu1stu2两个变量时,我们会看到输出了对象在内存中的地址(十六进制形式),跟我们用id函数查看对象标识获得的值是相同的。现在我们可以告诉大家,我们定义的变量其实保存的是一个对象在内存中的逻辑地址(位置),通过这个逻辑地址,我们就可以在内存中找到这个对象。所以stu3 = stu2这样的赋值语句并没有创建新的对象,只是用一个新的变量保存了已有对象的地址。

    接下来,我们尝试给对象发消息,即调用对象的方法。刚才的Student类中我们定义了studyplay两个方法,两个方法的第一个参数self代表了接收消息的学生对象,study方法的第二个参数是学习的课程名称。Python中,给对象发消息有两种方式,请看下面的代码。

    通过“类.方法”调用方法

    第一个参数是接收消息的对象

    第二个参数是学习的课程名称

    Student.study(stu1, 'Python程序设计') # 学生正在学习Python程序设计.

    通过“对象.方法”调用方法

    点前面的对象就是接收消息的对象

    只需要传入第二个参数课程名称

    stu1.study('Python程序设计') # 学生正在学习Python程序设计.

    Student.play(stu2) # 学生正在玩游戏. stu2.play() # 学生正在玩游戏.

    初始化方法

    大家可能已经注意到了,刚才我们创建的学生对象只有行为没有属性,如果要给学生对象定义属性,我们可以修改Student类,为其添加一个名为__init__的方法。在我们调用Student类的构造器创建对象时,首先会在内存中获得保存学生对象所需的内存空间,然后通过自动执行__init__方法,完成对内存的初始化操作,也就是把数据放到内存空间中。所以我们可以通过给Student类添加__init__方法的方式为学生对象指定属性,同时完成对属性赋初始值的操作,正因如此,__init__方法通常也被称为初始化方法。

    我们对上面的Student类稍作修改,给学生对象添加name(姓名)和age(年龄)两个属性。

    class Student:
        """学生"""

    def __init__(self, name, age): """初始化方法""" self.name = name self.age = age

    def study(self, course_name): """学习""" print(f'{self.name}正在学习{course_name}.')

    def play(self): """玩耍""" print(f'{self.name}正在玩游戏.')

    修改刚才创建对象和给对象发消息的代码,重新执行一次,看看程序的执行结果有什么变化。

    调用Student类的构造器创建对象并传入初始化参数

    stu1 = Student('骆昊', 44) stu2 = Student('王大锤', 25) stu1.study('Python程序设计') # 骆昊正在学习Python程序设计. stu2.play() # 王大锤正在玩游戏.

    面向对象的支柱

    面向对象编程有三大支柱,就是我们之前给大家划重点的时候圈出的三个词:封装继承多态。后面两个概念在下一节课中会详细说明,这里我们先说一下什么是封装。我自己对封装的理解是:隐藏一切可以隐藏的实现细节,只向外界暴露简单的调用接口。我们在类中定义的对象方法其实就是一种封装,这种封装可以让我们在创建对象之后,只需要给对象发送一个消息就可以执行方法中的代码,也就是说我们在只知道方法的名字和参数(方法的外部视图),不知道方法内部实现细节(方法的内部视图)的情况下就完成了对方法的使用。

    举一个例子,假如要控制一个机器人帮我倒杯水,如果不使用面向对象编程,不做任何的封装,那么就需要向这个机器人发出一系列的指令,如站起来、向左转、向前走5步、拿起面前的水杯、向后转、向前走10步、弯腰、放下水杯、按下出水按钮、等待10秒、松开出水按钮、拿起水杯、向右转、向前走5步、放下水杯等,才能完成这个简单的操作,想想都觉得麻烦。按照面向对象编程的思想,我们可以将倒水的操作封装到机器人的一个方法中,当需要机器人帮我们倒水的时候,只需要向机器人对象发出倒水的消息就可以了,这样做不是更好吗?

    在很多场景下,面向对象编程其实就是一个三步走的问题。第一步定义类,第二步创建对象,第三步给对象发消息。当然,有的时候我们是不需要第一步的,因为我们想用的类可能已经存在了。之前我们说过,Python内置的listsetdict其实都是类,如果要创建列表、集合、字典对象,我们就不用自定义类了。当然,有的类并不是 Python 标准库中直接提供的,它可能来自于第三方的代码,如何安装和使用三方代码在后续课程中会进行讨论。在某些特殊的场景中,我们会用到名为“内置对象”的对象,所谓“内置对象”就是说上面三步走的第一步和第二步都不需要了,因为类已经存在而且对象已然创建过了,直接向对象发消息就可以了,这也就是我们常说的“开箱即用”。

    面向对象案例

    #### 例子1:时钟

    > 要求:定义一个类描述数字时钟,提供走字和显示时间的功能。

    import time

    定义时钟类

    class Clock: """数字时钟"""

    def __init__(self, hour=0, minute=0, second=0): """初始化方法 :param hour: 时 :param minute: 分 :param second: 秒 """ self.hour = hour self.min = minute self.sec = second

    def run(self): """走字""" self.sec += 1 if self.sec == 60: self.sec = 0 self.min += 1 if self.min == 60: self.min = 0 self.hour += 1 if self.hour == 24: self.hour = 0

    def show(self): """显示时间""" return f'{self.hour:0>2d}:{self.min:0>2d}:{self.sec:0>2d}'

    创建时钟对象

    clock = Clock(23, 59, 58) while True: # 给时钟对象发消息读取时间 print(clock.show()) # 休眠1秒钟 time.sleep(1) # 给时钟对象发消息使其走字 clock.run()

    #### 例子2:平面上的点

    > 要求:定义一个类描述平面上的点,提供计算到另一个点距离的方法。

    class Point:
        """平面上的点"""

    def __init__(self, x=0, y=0): """初始化方法 :param x: 横坐标 :param y: 纵坐标 """ self.x, self.y = x, y

    def distance_to(self, other): """计算与另一个点的距离 :param other: 另一个点 """ dx = self.x - other.x dy = self.y - other.y return (dx dx + dy dy) ** 0.5

    def __str__(self): return f'({self.x}, {self.y})'

    p1 = Point(3, 5) p2 = Point(6, 9) print(p1) # 调用对象的__str__魔法方法 print(p2) print(p1.distance_to(p2))

    总结

    面向对象编程是一种非常流行的编程范式,除此之外还有指令式编程函数式编程等编程范式。由于现实世界是由对象构成的,而对象是可以接收消息的实体,所以面向对象编程更符合人类正常的思维习惯。类是抽象的,对象是具体的,有了类就能创建对象,有了对象就可以接收消息,这就是面向对象编程的基础。定义类的过程是一个抽象的过程,找到对象公共的属性属于数据抽象,找到对象公共的方法属于行为抽象。抽象的过程是一个仁者见仁智者见智的过程,对同一类对象进行抽象可能会得到不同的结果,如下图所示。

    > 说明: 本节课的插图来自于 Grady Booc 等撰写的《面向对象分析与设计》一书,该书是讲解面向对象编程的经典著作,有兴趣的读者可以购买和阅读这本书来了解更多的面向对象的相关知识。

  • 函数高级应用

    函数高级应用

    在上一个章节中,我们探索了 Python 中的高阶函数,相信大家对函数的定义和应用有了更深刻的认知。本章我们继续为大家讲解函数相关的知识,一个是 Python 中的特色语法装饰器,一个是函数的递归调用。

    装饰器

    Python 语言中,装饰器是“用一个函数装饰另外一个函数并为其提供额外的能力”的语法现象。装饰器本身是一个函数,它的参数是被装饰的函数,它的返回值是一个带有装饰功能的函数。通过前面的描述,相信大家已经听出来了,装饰器是一个高阶函数,它的参数和返回值都是函数。但是,装饰器的概念对编程语言的初学者来说,还是让人头疼的,下面我们先通过一个简单的例子来说明装饰器的作用。假设有名为downlaodupload的两个函数,分别用于文件的下载和上传,如下所示。

    import random
    import time

    def download(filename): """下载文件""" print(f'开始下载{filename}.') time.sleep(random.random() * 6) print(f'{filename}下载完成.')

    def upload(filename): """上传文件""" print(f'开始上传{filename}.') time.sleep(random.random() * 8) print(f'{filename}上传完成.')

    download('MySQL从删库到跑路.avi') upload('Python从入门到住院.pdf')

    > 说明:上面的代码用休眠一段随机时间的方式模拟了下载和上传文件需要花费一定的时间,并没有真正的联网上传下载文件。用 Python 语言实现联网上传下载文件也非常简单,后面我们会讲到相关的知识。

    现在有一个新的需求,我们希望知道调用downloadupload函数上传下载文件到底用了多少时间,这应该如何实现呢?相信很多小伙伴已经想到了,我们可以在函数开始执行的时候记录一个时间,在函数调用结束后记录一个时间,两个时间相减就可以计算出下载或上传的时间,代码如下所示。

    start = time.time()
    download('MySQL从删库到跑路.avi')
    end = time.time()
    print(f'花费时间: {end - start:.2f}秒')
    start = time.time()
    upload('Python从入门到住院.pdf')
    end = time.time()
    print(f'花费时间: {end - start:.2f}秒')
    

    通过上面的代码,我们可以在下载和上传文件时记录下耗费的时间,但不知道大家是否注意到,上面记录时间、计算和显示执行时间的代码都是重复代码。有编程经验的人都知道,重复的代码是万恶之源,那么有没有办法在不写重复代码的前提下,用一种简单优雅的方式记录下函数的执行时间呢?在 Python 语言中,装饰器就是解决这类问题的最佳选择。通过装饰器语法,我们可以把跟原来的业务(上传和下载)没有关系计时功能的代码封装到一个函数中,如果uploaddownload函数需要记录时间,我们直接把装饰器作用到这两个函数上即可。既然上面提到了,装饰器是一个高阶函数,它的参数和返回值都是函数,我们将记录时间的装饰器姑且命名为record_time,那么它的整体结构应该如下面的代码所示。

    def record_time(func):
        
        def wrapper(args, *kwargs):
            
            result = func(args, *kwargs)
            
            return result
        
        return wrapper
    

    相信大家注意到了,record_time函数的参数func代表了一个被装饰的函数,函数里面定义的wrapper函数是带有装饰功能的函数,它会执行被装饰的函数func,它还需要返回在最后产生函数执行的返回值。不知大家是否留意到,上面的代码我在第4行和第6行留下了两个空行,这意味着我们可以这些地方添加代码来实现额外的功能。record_time函数最终会返回这个带有装饰功能的函数wrapper并通过它替代原函数func,当原函数funcrecord_time函数装饰后,我们调用它时其实调用的是wrapper函数,所以才获得了额外的能力。wrapper函数的参数比较特殊,由于我们要用wrapper替代原函数func,但是我们又不清楚原函数func会接受哪些参数,所以我们就通过可变参数和关键字参数照单全收,然后在调用func的时候,原封不动的全部给它。这里还要强调一下,Python 语言支持函数的嵌套定义,就像上面,我们可以在record_time函数中定义wrapper函数,这个操作在很多编程语言中并不被支持。

    看懂这个结构后,我们就可以把记录时间的功能写到这个装饰器中,代码如下所示。

    import time

    def record_time(func):

    def wrapper(args, *kwargs): # 在执行被装饰的函数之前记录开始时间 start = time.time() # 执行被装饰的函数并获取返回值 result = func(args, *kwargs) # 在执行被装饰的函数之后记录结束时间 end = time.time() # 计算和显示被装饰函数的执行时间 print(f'{func.__name__}执行时间: {end - start:.2f}秒') # 返回被装饰函数的返回值 return result return wrapper

    写装饰器虽然颇费周折,但是这是个一劳永逸的骚操作,将来再有记录函数执行时间的需求时,我们只需要添加上面的装饰器即可。使用上面的装饰器函数有两种方式,第一种方式就是直接调用装饰器函数,传入被装饰的函数并获得返回值,我们可以用这个返回值直接替代原来的函数,那么在调用时就已经获得了装饰器提供的额外的能力(记录执行时间),大家试试下面的代码就明白了。

    download = record_time(download)
    upload = record_time(upload)
    download('MySQL从删库到跑路.avi')
    upload('Python从入门到住院.pdf')
    

    在 Python 中,使用装饰器还有更为便捷的语法糖(编程语言中添加的某种语法,这种语法对语言的功能没有影响,但是使用更加便捷,代码的可读性也更强,我们将其称之为“语法糖”或“糖衣语法”),可以用@装饰器函数将装饰器函数直接放在被装饰的函数上,效果跟上面的代码相同。我们把完整的代码为大家罗列出来,大家可以再看看我们是如何定义和使用装饰器的。

    import random
    import time

    def record_time(func):

    def wrapper(args, *kwargs): start = time.time() result = func(args, *kwargs) end = time.time() print(f'{func.__name__}执行时间: {end - start:.2f}秒') return result

    return wrapper

    @record_time def download(filename): print(f'开始下载{filename}.') time.sleep(random.random() * 6) print(f'{filename}下载完成.')

    @record_time def upload(filename): print(f'开始上传{filename}.') time.sleep(random.random() * 8) print(f'{filename}上传完成.')

    download('MySQL从删库到跑路.avi') upload('Python从入门到住院.pdf')

    上面的代码,我们通过装饰器语法糖为downloadupload函数添加了装饰器,被装饰后的downloadupload函数其实就是我们在装饰器中返回的wrapper函数,调用它们其实就是在调用wrapper函数,所以才有了记录函数执行时间的功能。

    如果在代码的某些地方,我们想去掉装饰器的作用执行原函数,那么在定义装饰器函数的时候,需要做一点点额外的工作。Python 标准库functools模块的wraps函数也是一个装饰器,我们将它放在wrapper函数上,这个装饰器可以帮我们保留被装饰之前的函数,这样在需要取消装饰器时,可以通过被装饰函数的__wrapped__属性获得被装饰之前的函数。

    import random
    import time

    from functools import wraps

    def record_time(func):

    @wraps(func) def wrapper(args, *kwargs): start = time.time() result = func(args, *kwargs) end = time.time() print(f'{func.__name__}执行时间: {end - start:.2f}秒') return result

    return wrapper

    @record_time def download(filename): print(f'开始下载{filename}.') time.sleep(random.random() * 6) print(f'{filename}下载完成.')

    @record_time def upload(filename): print(f'开始上传{filename}.') time.sleep(random.random() * 8) print(f'{filename}上传完成.')

    调用装饰后的函数会记录执行时间

    download('MySQL从删库到跑路.avi') upload('Python从入门到住院.pdf')

    取消装饰器的作用不记录执行时间

    download.__wrapped__('MySQL必知必会.pdf') upload.__wrapped__('Python从新手到大师.pdf')

    装饰器函数本身也可以参数化,简单的说就是装饰器也是可以通过调用者传入的参数来进行定制的,这个知识点我们在后面用到的时候再为大家讲解。

    递归调用

    Python 中允许函数嵌套定义,也允许函数之间相互调用,而且一个函数还可以直接或间接的调用自身。函数自己调用自己称为递归调用,那么递归调用有什么用处呢?现实中,有很多问题的定义本身就是一个递归定义,例如我们之前讲到的阶乘,非负整数N的阶乘是N乘以N-1的阶乘,即 $\small{N! = N \times (N-1)!}$ ,定义的左边和右边都出现了阶乘的概念,所以这是一个递归定义。既然如此,我们可以使用递归调用的方式来写一个求阶乘的函数,代码如下所示。

    def fac(num):
        if num in (0, 1):
            return 1
        return num * fac(num - 1)
    

    上面的代码中,fac函数中又调用了fac函数,这就是所谓的递归调用。代码第2行的if条件叫做递归的收敛条件,简单的说就是什么时候要结束函数的递归调用,在计算阶乘时,如果计算到01的阶乘,就停止递归调用,直接返回1;代码第4行的num * fac(num - 1)是递归公式,也就是阶乘的递归定义。下面,我们简单的分析下,如果用fac(5)计算5的阶乘,整个过程会是怎样的。

    递归调用函数入栈

    5 * fac(4)

    5 (4 fac(3))

    5 (4 (3 * fac(2)))

    5 (4 (3 (2 fac(1))))

    停止递归函数出栈

    5 (4 (3 (2 1)))

    5 (4 (3 * 2))

    5 (4 6)

    5 * 24

    120

    print(fac(5)) # 120

    注意,函数调用会通过内存中称为“栈”(stack)的数据结构来保存当前代码的执行现场,函数调用结束后会通过这个栈结构恢复之前的执行现场。栈是一种先进后出的数据结构,这也就意味着最早入栈的函数最后才会返回,而最后入栈的函数会最先返回。例如调用一个名为a的函数,函数a的执行体中又调用了函数b,函数b的执行体中又调用了函数c,那么最先入栈的函数是a,最先出栈的函数是c。每进入一个函数调用,栈就会增加一层栈帧(stack frame),栈帧就是我们刚才提到的保存当前代码执行现场的结构;每当函数调用结束后,栈就会减少一层栈帧。通常,内存中的栈空间很小,因此递归调用的次数如果太多,会导致栈溢出(stack overflow),所以递归调用一定要确保能够快速收敛。我们可以尝试执行fac(5000),看看是不是会提示RecursionError错误,错误消息为:maximum recursion depth exceeded in comparison(超出最大递归深度),其实就是发生了栈溢出。

    如果我们使用官方的 Python 解释器(CPython),默认将函数调用的栈结构最大深度设置为1000层。如果超出这个深度,就会发生上面说的RecursionError。当然,我们可以使用sys模块的setrecursionlimit函数来改变递归调用的最大深度,但是我们不建议这样做,因为让递归快速收敛才是我们应该做的事情,否则就应该考虑使用循环递推而不是递归。

    再举一个之前讲过的生成斐波那契数列的例子,因为斐波那契数列前两个数都是1,从第三个数开始,每个数是前两个数相加的和,可以记为f(n) = f(n - 1) + f(n - 2),很显然这又是一个递归的定义,所以我们可以用下面的递归调用函数来计算第​n个斐波那契数。

    def fib1(n):
        if n in (1, 2):
            return 1
        return fib1(n - 1) + fib1(n - 2)

    for i in range(1, 21): print(fib1(i))

    需要提醒大家,上面计算斐波那契数的代码虽然看起来非常简单明了,但执行性能是比较糟糕的。大家可以试一试,把上面代码for循环中range函数的第二个参数修改为51,即输出前50个斐波那契数,看看需要多长时间,也欢迎大家在评论区留下你的代码执行时间。至于为什么这么慢,大家可以自己思考一下原因。很显然,直接使用循环递推的方式获得斐波那契数列是更好的选择,代码如下所示。

    def fib2(n):
        a, b = 0, 1
        for _ in range(n):
            a, b = b, a + b
        return a
    

    除此以外,我们还可以使用 Python 标准库中functools模块的lru_cache函数来优化上面的递归代码。lru_cache函数是一个装饰器函数,我们将其置于上面的函数fib1之上,它可以缓存该函数的执行结果从而避免在递归调用的过程中产生大量的重复运算,这样代码的执行性能就有“飞一般”的提升。大家可以尝试输出前50个斐波那契数,看看加上装饰器以后代码需要执行多长时间,评论区见!

    from functools import lru_cache

    @lru_cache() def fib1(n): if n in (1, 2): return 1 return fib1(n - 1) + fib1(n - 2)

    for i in range(1, 51): print(i, fib1(i))

    > 提示lru_cache函数是一个带参数的装饰器,所以上面第4行代码使用装饰器语法糖时,lru_cache后面要跟上圆括号。lru_cache函数有一个非常重要的参数叫maxsize,它可以用来定义缓存空间的大小,默认值是128。

    总结

    装饰器是 Python 语言中的特色语法,可以通过装饰器来增强现有的函数,这是一种非常有用的编程技巧。另一方面,通过函数递归调用,可以在代码层面将一些复杂的问题简单化,但是递归调用一定要注意收敛条件和递归公式,找到递归公式才有机会使用递归调用,而收敛条件则确保了递归调用能停下来。函数调用通过内存中的栈空间来保存现场和恢复现场,栈空间通常都很小,所以递归如果不能迅速收敛,很可能会引发栈溢出错误,从而导致程序的崩溃

  • 函数使用进阶

    函数使用进阶

    我们继续探索定义和使用函数的相关知识。通过前面的学习,我们知道了函数有自变量(参数)和因变量(返回值),自变量可以是任意的数据类型,因变量也可以是任意的数据类型,那么这里就有一个小问题,我们能不能用函数作为函数的参数,用函数作为函数的返回值?这里我们先说结论:Python 中的函数是“一等函数”,所谓“一等函数”指的就是函数可以赋值给变量,函数可以作为函数的参数,函数也可以作为函数的返回值。把一个函数作为其他函数的参数或返回值的用法,我们通常称之为“高阶函数”。

    高阶函数

    我们回到之前讲过的一个例子,设计一个函数,传入任意多个参数,对其中int类型或float类型的元素实现求和操作。我们对之前的代码稍作调整,让整个代码更加紧凑一些,如下所示。

    def calc(args, *kwargs):
        items = list(args) + list(kwargs.values())
        result = 0
        for item in items:
            if type(item) in (int, float):
                result += item
        return result
    

    如果我们希望上面的calc函数不仅仅可以做多个参数的求和,还可以实现更多的甚至是自定义的二元运算,我们该怎么做呢?上面的代码只能求和是因为函数中使用了+=运算符,这使得函数跟加法运算形成了耦合关系,如果能解除这种耦合关系,函数的通用性和灵活性就会更好。解除耦合的办法就是将+运算符变成函数调用,并将其设计为函数的参数,代码如下所示。

    def calc(init_value, op_func, args, *kwargs):
        items = list(args) + list(kwargs.values())
        result = init_value
        for item in items:
            if type(item) in (int, float):
                result = op_func(result, item)
        return result
    

    注意,上面的函数增加了两个参数,其中init_value代表运算的初始值,op_func代表二元运算函数,为了调用修改后的函数,我们先定义做加法和乘法运算的函数,代码如下所示。

    def add(x, y):
        return x + y

    def mul(x, y): return x * y

    如果要做求和的运算,我们可以按照下面的方式调用calc函数。

    print(calc(0, add, 1, 2, 3, 4, 5))  # 15
    

    如果要做求乘积运算,我们可以按照下面的方式调用calc函数。

    print(calc(1, mul, 1, 2, 3, 4, 5))  # 120 
    

    上面的calc函数通过将运算符变成函数的参数,实现了跟加法运算的解耦合,这是一种非常高明和实用的编程技巧,但对于最初学者来说可能会觉得难以理解,建议大家细品一下。需要注意上面的代码中,将函数作为参数传入其他函数和直接调用函数是有显著的区别的,调用函数需要在函数名后面跟上圆括号,而把函数作为参数时只需要函数名即可**。

    如果我们没有提前定义好addmul函数,也可以使用 Python 标准库中的operator模块提供的addmul函数,它们分别代表了做加法和做乘法的二元运算,我们拿过来直接使用即可,代码如下所示。

    import operator

    print(calc(0, operator.add, 1, 2, 3, 4, 5)) # 15 print(calc(1, operator.mul, 1, 2, 3, 4, 5)) # 120

    Python 内置函数中有不少高阶函数,我们前面提到过的filtermap函数就是高阶函数,前者可以实现对序列中元素的过滤,后者可以实现对序列中元素的映射,例如我们要去掉一个整数列表中的奇数,并对所有的偶数求平方得到一个新的列表,就可以直接使用这两个函数来做到,具体的做法是如下所示。

    def is_even(num):
        """判断num是不是偶数"""
        return num % 2 == 0

    def square(num): """求平方""" return num ** 2

    old_nums = [35, 12, 8, 99, 60, 52] new_nums = list(map(square, filter(is_even, old_nums))) print(new_nums) # [144, 64, 3600, 2704]

    当然,要完成上面代码的功能,也可以使用列表生成式,列表生成式的做法更为简单优雅。

    old_nums = [35, 12, 8, 99, 60, 52]
    new_nums = [num ** 2 for num in old_nums if num % 2 == 0]
    print(new_nums)  # [144, 64, 3600, 2704]
    

    我们再来讨论一个内置函数sorted,它可以实现对容器型数据类型(如:列表、字典等)元素的排序。我们之前讲过list类型的sort方法,它实现了对列表元素的排序,sorted函数从功能上来讲跟列表的sort方法没有区别,但它会返回排序后的列表对象,而不是直接修改原来的列表,这一点我们称为函数的无副作用设计,也就是说调用函数除了产生返回值以外,不会对程序的状态或外部环境产生任何其他的影响。使用sorted函数排序时,可以通过高阶函数的形式自定义排序的规则,我们通过下面的例子加以说明。

    old_strings = ['in', 'apple', 'zoo', 'waxberry', 'pear']
    new_strings = sorted(old_strings)
    print(new_strings)  # ['apple', 'in', 'pear', waxberry', 'zoo']
    

    上面的代码对大家来说并不陌生,但是如果希望根据字符串的长度而不是字母表顺序对列表元素排序,我们可以向sorted函数传入一个名为key的参数,将key参数赋值为获取字符串长度的函数len,这个函数我们在之前的课程中讲到过,代码如下所示。

    old_strings = ['in', 'apple', 'zoo', 'waxberry', 'pear']
    new_strings = sorted(old_strings, key=len)
    print(new_strings)  # ['in', 'zoo', 'pear', 'apple', 'waxberry']
    

    > 说明:列表类型的sort方法也有同样的key参数,有兴趣的读者可以自行尝试。

    Lambda函数

    在使用高阶函数的时候,如果作为参数或者返回值的函数本身非常简单,一行代码就能够完成,也不需要考虑对函数的复用,那么我们可以使用 lambda 函数。Python 中的 lambda 函数是没有的名字函数,所以很多人也把它叫做匿名函数,lambda 函数只能有一行代码,代码中的表达式产生的运算结果就是这个匿名函数的返回值。之前的代码中,我们写的is_evensquare函数都只有一行代码,我们可以考虑用 lambda 函数来替换掉它们,代码如下所示。

    old_nums = [35, 12, 8, 99, 60, 52]
    new_nums = list(map(lambda x: x ** 2, filter(lambda x: x % 2 == 0, old_nums)))
    print(new_nums)  # [144, 64, 3600, 2704]
    

    通过上面的代码可以看出,定义 lambda 函数的关键字是lambda,后面跟函数的参数,如果有多个参数用逗号进行分隔;冒号后面的部分就是函数的执行体,通常是一个表达式,表达式的运算结果就是 lambda 函数的返回值,不需要写return 关键字。

    前面我们说过,Python 中的函数是“一等函数”,函数是可以直接赋值给变量的。在学习了 lambda 函数之后,前面我们写过的一些函数就可以用一行代码来实现它们了,大家可以看看能否理解下面的求阶乘和判断素数的函数。

    import functools
    import operator

    用一行代码实现计算阶乘的函数

    fac = lambda n: functools.reduce(operator.mul, range(2, n + 1), 1)

    用一行代码实现判断素数的函数

    is_prime = lambda x: all(map(lambda f: x % f, range(2, int(x ** 0.5) + 1)))

    调用Lambda函数

    print(fac(6)) # 720 print(is_prime(37)) # True

    > 提示1:上面使用的reduce函数是 Python 标准库functools模块中的函数,它可以实现对一组数据的归约操作,类似于我们之前定义的calc函数,第一个参数是代表运算的函数,第二个参数是运算的数据,第三个参数是运算的初始值。很显然,reduce函数也是高阶函数,它和filter函数、map函数一起构成了处理数据中非常关键的三个动作:过滤映射归约
    >
    > 提示2:上面判断素数的 lambda 函数通过range函数构造了从 2 到 $\small{\sqrt{x}}$ 的范围,检查这个范围有没有x的因子。all函数也是 Python 内置函数,如果传入的序列中所有的布尔值都是Trueall函数返回True,否则all函数返回False

    偏函数

    偏函数是指固定函数的某些参数,生成一个新的函数,这样就无需在每次调用函数时都传递相同的参数。在 Python 语言中,我们可以使用functools模块的partial函数来创建偏函数。例如,int函数在默认情况下可以将字符串视为十进制整数进行类型转换,如果我们修修改它的base参数,就可以定义出三个新函数,分别用于将二进制、八进制、十六进制字符串转换为整数,代码如下所示。

    import functools

    int2 = functools.partial(int, base=2) int8 = functools.partial(int, base=8) int16 = functools.partial(int, base=16)

    print(int('1001')) # 1001

    print(int2('1001')) # 9 print(int8('1001')) # 513 print(int16('1001')) # 4097

    不知大家是否注意到,partial函数的第一个参数和返回值都是函数,它将传入的函数处理成一个新的函数返回。通过构造偏函数,我们可以结合实际的使用场景将原函数变成使用起来更为便捷的新函数,不知道大家有没有觉得这波操作很有意思。

    总结

    Python 中的函数是一等函数,可以赋值给变量,也可以作为函数的参数和返回值,这也就意味着我们可以在 Python 中使用高阶函数。高阶函数的概念对新手并不友好,但它却带来了函数设计上的灵活性。如果我们要定义的函数非常简单,只有一行代码,而且不需要函数名来复用它,我们可以使用 lambda 函数。

  • 函数应用实战

    函数应用实战

    例子1:随机验证码

    设计一个生成随机验证码的函数,验证码由数字和英文大小写字母构成,长度可以通过参数设置。

    import random
    import string

    ALL_CHARS = string.digits + string.ascii_letters

    def generate_code(*, code_len=4): """ 生成指定长度的验证码 :param code_len: 验证码的长度(默认4个字符) :return: 由大小写英文字母和数字构成的随机验证码字符串 """ return ''.join(random.choices(ALL_CHARS, k=code_len))

    > 说明1string模块的digits代表0到9的数字构成的字符串'0123456789'string模块的ascii_letters代表大小写英文字母构成的字符串'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
    >
    > 说明2random模块的samplechoices函数都可以实现随机抽样,sample实现无放回抽样,这意味着抽样取出的元素是不重复的;choices实现有放回抽样,这意味着可能会重复选中某些元素。这两个函数的第一个参数代表抽样的总体,而参数k代表样本容量,需要说明的是choices函数的参数k是一个命名关键字参数,在传参时必须指定参数名。

    可以用下面的代码生成5组随机验证码来测试上面的函数。

    for _ in range(5):
        print(generate_code()) 
    

    输出:

    59tZ
    QKU5
    izq8
    IBBb
    jIfX
    

    或者

    for _ in range(5):
        print(generate_code(code_len=6))
    

    输出:

    FxJucw
    HS4H9G
    0yyXfz
    x7fohf
    ReO22w
    

    > 说明:我们设计的generate_code函数的参数是命名关键字参数,由于它有默认值,可以不给它传值,使用默认值4。如果需要给函数传入参数,必须指定参数名code_len

    例子2:判断素数

    设计一个判断给定的大于1的正整数是不是质数的函数。质数是只能被1和自身整除的正整数(大于1),如果一个大于 1 的正整数 $\small{N}$ 是质数,那就意味着在 2 到 $\small{N-1}$ 之间都没有它的因子。

    def is_prime(num: int) -> bool:
        """
        判断一个正整数是不是质数
        :param num: 大于1的正整数
        :return: 如果num是质数返回True,否则返回False
        """
        for i in range(2, int(num ** 0.5) + 1):
            if num % i == 0:
                return False
        return True
    

    > 说明1:上面is_prime函数的参数num后面的: int用来标注参数的类型,虽然它对代码的执行结果不产生任何影响,但是很好的增强了代码的可读性。同理,参数列表后面的-> bool用来标注函数返回值的类型,它也不会对代码的执行结果产生影响,但是却让我们清楚的知道,调用函数会得到一个布尔值,要么是True,要么是False
    >
    > 说明2:上面的循环并不需要从 2 循环到 $\small{N-1}$ ,因为如果循环进行到 $\small{\sqrt{N}}$ 时,还没有找到$\small{N}$的因子,那么 $\small{\sqrt{N}}$ 之后也不会出现 $\small{N}$ 的因子,大家可以自己想一想这是为什么。

    例子3:最大公约数和最小公倍数

    设计计算两个正整数最大公约数和最小公倍数的函数。 $\small{x}$ 和 $\small{y}$ 的最大公约数是能够同时整除 $\small{x}$ 和 $\small{y}$ 的最大整数,如果 $\small{x}$ 和 $\small{y}$ 互质,那么它们的最大公约数为 1; $\small{x}$ 和 $\small{y}$ 的最小公倍数是能够同时被 $\small{x}$ 和 $\small{y}$ 整除的最小正整数,如果 $\small{x}$ 和 $\small{y}$ 互质,那么它们的最小公倍数为 $\small{x \times y}$ 。需要提醒大家注意的是,计算最大公约数和最小公倍数是两个不同的功能,应该设计成两个函数,而不是把两个功能放到同一个函数中。

    def lcm(x: int, y: int) -> int:
        """求最小公倍数"""
        return x * y // gcd(x, y)

    def gcd(x: int, y: int) -> int: """求最大公约数""" while y % x != 0: x, y = y % x, x return x

    > 说明:函数之间可以相互调用,上面求最小公倍数的lcm函数调用了求最大公约数的gcd函数,通过 $\frac{x \times y}{ gcd(x, y)}$ 来计算最小公倍数。

    例子4:数据统计

    假设样本数据保存一个列表中,设计计算样本数据描述性统计信息的函数。描述性统计信息通常包括:算术平均值、中位数、极差(最大值和最小值的差)、方差、标准差、变异系数等,计算公式如下所示。

    样本均值(sample mean):

    $$
    \bar{x} = \frac{\sum_{i=1}^{n}x_{i}}{n} = \frac{x_{1}+x_{2}+\cdots +x_{n}}{n}
    $$

    样本方差(sample variance):

    $$
    s^2 = \frac {\sum_{i=1}^{n}(x_i – \bar{x})^2} {n-1}
    $$

    样本标准差(sample standard deviation):

    $$
    s = \sqrt{\frac{\sum_{i=1}^{n}(x_i – \bar{x})^2}{n-1}}
    $$

    变异系数(coefficient of sample variation):

    $$
    CV = \frac{s}{\bar{x}}
    $$

    def ptp(data):
        """极差(全距)"""
        return max(data) - min(data)

    def mean(data): """算术平均""" return sum(data) / len(data)

    def median(data): """中位数""" temp, size = sorted(data), len(data) if size % 2 != 0: return temp[size // 2] else: return mean(temp[size // 2 - 1:size // 2 + 1])

    def var(data, ddof=1): """方差""" x_bar = mean(data) temp = [(num - x_bar) ** 2 for num in data] return sum(temp) / (len(temp) - ddof)

    def std(data, ddof=1): """标准差""" return var(data, ddof) ** 0.5

    def cv(data, ddof=1): """变异系数""" return std(data, ddof) / mean(data)

    def describe(data): """输出描述性统计信息""" print(f'均值: {mean(data)}') print(f'中位数: {median(data)}') print(f'极差: {ptp(data)}') print(f'方差: {var(data)}') print(f'标准差: {std(data)}') print(f'变异系数: {cv(data)}')

    > 说明1:中位数是将数据按照升序或降序排列后位于中间的数,它描述了数据的中等水平。中位数的计算分两种情况:当数据体量$n$为奇数时,中位数是位于 $\frac{n + 1}{2}$ 位置的元素;当数据体量 $\small{n}$ 为偶数时,中位数是位于 $\frac{n}{2}$ 和 $\frac{n}{2} + 1$ 两个位置元素的均值。
    >
    > 说明2:计算方差和标准差的函数中有一个名为ddof的参数,它代表了可以调整的自由度,默认值为 1。在计算样本方差和样本标准差时,需要进行自由度校正;如果要计算总体方差和总体标准差,可以将ddof参数赋值为 0,即不需要进行自由度校正。
    >
    > 说明3describe函数将上面封装好的统计函数组装到一起,用于输出数据的描述性统计信息。事实上,Python 标准库中有一个名为statistics的模块,它已经把获取描述性统计信息的函数封装好了,有兴趣的读者可以自行了解。

    例子5:双色球随机选号

    我们用函数重构之前讲过的双色球随机选号的例子(《第09课:常用数据结构之列表-2》),将生成随机号码和输出一组号码的功能分别封装到两个函数中,然后通过调用函数实现机选N注号码的功能。

    """
    双色球随机选号程序

    Author: 骆昊 Version: 1.3 """ import random

    RED_BALLS = [i for i in range(1, 34)] BLUE_BALLS = [i for i in range(1, 17)]

    def choose(): """ 生成一组随机号码 :return: 保存随机号码的列表 """ selected_balls = random.sample(RED_BALLS, 6) selected_balls.sort() selected_balls.append(random.choice(BLUE_BALLS)) return selected_balls

    def display(balls): """ 格式输出一组号码 :param balls: 保存随机号码的列表 """ for ball in balls[:-1]: print(f'\033[031m{ball:0>2d}\033[0m', end=' ') print(f'\033[034m{balls[-1]:0>2d}\033[0m')

    n = int(input('生成几注号码: ')) for _ in range(n): display(choose())

    > 说明:大家看看display(choose())这行代码,这里我们先通过choose函数获得一组随机号码,然后把choose函数的返回值作为display函数的参数,通过display函数将选中的随机号码显示出来。重构之后的代码逻辑非常清晰,代码的可读性更强了。如果有人为你封装了这两个函数,你仅仅是函数的调用者,其实你根本不用关心choose函数和display函数的内部实现,你只需要知道调用choose函数可以生成一组随机号码,而调用display函数传入一个列表,就可以输出这组号码。将来我们使用各种各样的 Python 三方库时,我们也根本不关注它们的底层实现,我们需要知道的仅仅是调用哪个函数可以解决问题。

    总结

    在写代码尤其是开发商业项目的时候,一定要有意识的将相对独立且重复使用的功能封装成函数,这样不管是自己还是团队的其他成员都可以通过调用函数的方式来使用这些功能,减少工作中那些重复且乏味的劳动。

  • 函数和模块

    函数和模块

    在讲解本节课的内容之前,我们先来研究一道数学题,请说出下面的方程有多少组正整数解。

    $$
    x_{1} + x_{2} + x_{3} + x_{4} = 8
    $$

    你可能已经想到了,这个问题其实等同于将 8 个苹果分成四组且每组至少一个苹果有多少种方案,也等价于在分隔 8 个苹果的 7 个间隙之间放入三个隔断将苹果分成四组有多少种方案,所以答案是 $\small{C_{7}^{3} = 35}$ ,其中 $\small{C_{7}^{3}}$ 代表 7 选 3 的组合数,其计算公式如下所示。

    $$
    C_m^n = \frac {m!} {n!(m-n)!}
    $$

    根据之前学习的知识,我们可以用循环做累乘的方式分别计算出 $\small{m!}$ 、 $\small{n!}$ 和 $\small{(m-n)!}$ ,然后再通过除法运算得到组合数 $\small{C_{m}^{n}}$ ,代码如下所示。

    """
    输入m和n,计算组合数C(m,n)的值

    Version: 1.0 Author: 骆昊 """

    m = int(input('m = ')) n = int(input('n = '))

    计算m的阶乘

    fm = 1 for num in range(1, m + 1): fm *= num

    计算n的阶乘

    fn = 1 for num in range(1, n + 1): fn *= num

    计算m-n的阶乘

    fk = 1 for num in range(1, m - n + 1): fk *= num

    计算C(M,N)的值

    print(fm // fn // fk)

    输入:

    m = 7
    n = 3
    

    输出:

    35
    

    不知大家是否注意到,上面的代码中我们做了三次求阶乘的操作,虽然 $\small{m}$ 、 $\small{n}$ 、 $\small{m – n}$ 的值各不相同,但是三段代码并没有实质性的区别,属于重复代码。世界级的编程大师Martin Fowler曾经说过:“代码有很多种坏味道,重复是最坏的一种!”。要写出高质量的代码,首先就要解决重复代码的问题。对于上面的代码来说,我们可以将求阶乘的功能封装到一个称为“函数”的代码块中,在需要计算阶乘的地方,我们只需“调用函数”即可实现对求阶乘功能的复用。

    定义函数

    数学上的函数通常形如 $\small{y = f(x)}$ 或者 $\small{z = g(x, y)}$ 这样的形式,在 $\small{y = f(x)}$ 中, $\small{f}$ 是函数的名字, $\small{x}$ 是函数的自变量, $\small{y}$ 是函数的因变量;而在 $\small{z = g(x, y)}$ 中, $\small{g}$ 是函数名, $\small{x}$ 和 $\small{y}$ 是函数的自变量, $\small{z}$ 是函数的因变量。Python 中的函数跟这个结构是一致的,每个函数都有自己的名字、自变量和因变量。我们通常把 Python 函数的自变量称为函数的参数,而因变量称为函数的返回值。

    Python 中可以使用def关键字来定义函数,和变量一样每个函数也应该有一个漂亮的名字,命名规则跟变量的命名规则是一样的(大家赶紧想想我们之前讲过的变量的命名规则)。在函数名后面的圆括号中可以设置函数的参数,也就是我们刚才说的函数的自变量,而函数执行完成后,我们会通过return关键字来返回函数的执行结果,这就是我们刚才说的函数的因变量。如果函数中没有return语句,那么函数会返回代表空值的None。另外,函数也可以没有自变量(参数),但是函数名后面的圆括号是必须有的。一个函数要做的事情(要执行的代码),是通过代码缩进的方式放到函数定义行之后,跟之前分支和循环结构的代码块类似,如下图所示。

    下面,我们将之前代码中求阶乘的操作放到一个函数中,通过这种方式来重构上面的代码。所谓重构,是在不影响代码执行结果的前提下对代码的结构进行调整,重构之后的代码如下所示。

    """
    输入m和n,计算组合数C(m,n)的值

    Version: 1.1 Author: 骆昊 """

    通过关键字def定义求阶乘的函数

    自变量(参数)num是一个非负整数

    因变量(返回值)是num的阶乘

    def fac(num): result = 1 for n in range(2, num + 1): result *= n return result

    m = int(input('m = ')) n = int(input('n = '))

    计算阶乘的时候不需要写重复的代码而是直接调用函数

    调用函数的语法是在函数名后面跟上圆括号并传入参数

    print(fac(m) // fac(n) // fac(m - n))

    大家可以感受下,上面的代码是不是比之前的版本更加简单优雅。更为重要的是,我们定义的求阶乘函数fac还可以在其他需要求阶乘的代码中重复使用。所以,使用函数可以帮助我们将功能上相对独立且会被重复使用的代码封装起来,当我们需要这些的代码,不是把重复的代码再编写一遍,而是通过调用函数实现对既有代码的复用。事实上,Python 标准库的math模块中,已经有一个名为factorial的函数实现了求阶乘的功能,我们可以直接用import math导入math模块,然后使用math.factorial来调用求阶乘的函数;我们也可以通过from math import factorial直接导入factorial函数来使用它,代码如下所示。

    """
    输入m和n,计算组合数C(m,n)的值

    Version: 1.2 Author: 骆昊 """ from math import factorial

    m = int(input('m = ')) n = int(input('n = ')) print(factorial(m) // factorial(n) // factorial(m - n))

    将来我们使用的函数,要么是自定义的函数,要么是 Python 标准库或者三方库中提供的函数,如果已经有现成的可用的函数,我们就没有必要自己去定义,“重复发明轮子”是一件非常糟糕的事情。对于上面的代码,如果你觉得factorial这个名字太长,书写代码的时候不是特别方便,我们在导入函数的时候还可以通过as关键字为其别名。在调用函数的时候,我们可以用函数的别名,而不再使用它之前的名字,代码如下所示。

    """
    输入m和n,计算组合数C(m,n)的值

    Version: 1.3 Author: 骆昊 """ from math import factorial as f

    m = int(input('m = ')) n = int(input('n = ')) print(f(m) // f(n) // f(m - n))

    函数的参数

    #### 位置参数和关键字参数

    我们再来写一个函数,根据给出的三条边的长度判断是否可以构成三角形,如果可以构成三角形则返回True,否则返回False,代码如下所示。

    def make_judgement(a, b, c):
        """判断三条边的长度能否构成三角形"""
        return a + b > c and b + c > a and a + c > b
    

    上面make_judgement函数有三个参数,这种参数叫做位置参数,在调用函数时通常按照从左到右的顺序依次传入,而且传入参数的数量必须和定义函数时参数的数量相同,如下所示。

    print(make_judgement(1, 2, 3))  # False
    print(make_judgement(4, 5, 6))  # True
    

    如果不想按照从左到右的顺序依次给出abc 三个参数的值,也可以使用关键字参数,通过“参数名=参数值”的形式为函数传入参数,如下所示。

    print(make_judgement(b=2, c=3, a=1))  # False
    print(make_judgement(c=6, b=4, a=5))  # True
    

    在定义函数时,我们可以在参数列表中用/设置强制位置参数positional-only arguments),用*设置命名关键字参数。所谓强制位置参数,就是调用函数时只能按照参数位置来接收参数值的参数;而命名关键字参数只能通过“参数名=参数值”的方式来传递和接收参数,大家可以看看下面的例子。

    /前面的参数是强制位置参数

    def make_judgement(a, b, c, /): """判断三条边的长度能否构成三角形""" return a + b > c and b + c > a and a + c > b

    下面的代码会产生TypeError错误,错误信息提示“强制位置参数是不允许给出参数名的”

    TypeError: make_judgement() got some positional-only arguments passed as keyword arguments

    print(make_judgement(b=2, c=3, a=1))

    > 说明:强制位置参数是 Python 3.8 引入的新特性,在使用低版本的 Python 解释器时需要注意。

    *后面的参数是命名关键字参数

    def make_judgement(*, a, b, c): """判断三条边的长度能否构成三角形""" return a + b > c and b + c > a and a + c > b

    下面的代码会产生TypeError错误,错误信息提示“函数没有位置参数但却给了3个位置参数”

    TypeError: make_judgement() takes 0 positional arguments but 3 were given

    print(make_judgement(1, 2, 3))

    #### 参数的默认值

    Python 中允许函数的参数拥有默认值,我们可以把之前讲过的一个例子“CRAPS赌博游戏”(《第07课:分支和循环结构的应用》)中摇色子获得点数的功能封装到函数中,代码如下所示。

    from random import randrange

    定义摇色子的函数

    函数的自变量(参数)n表示色子的个数,默认值为2

    函数的因变量(返回值)表示摇n颗色子得到的点数

    def roll_dice(n=2): total = 0 for _ in range(n): total += randrange(1, 7) return total

    如果没有指定参数,那么n使用默认值2,表示摇两颗色子

    print(roll_dice())

    传入参数3,变量n被赋值为3,表示摇三颗色子获得点数

    print(roll_dice(3))

    我们再来看一个更为简单的例子。

    def add(a=0, b=0, c=0):
        """三个数相加求和"""
        return a + b + c

    调用add函数,没有传入参数,那么a、b、c都使用默认值0

    print(add()) # 0

    调用add函数,传入一个参数,该参数赋值给变量a, 变量b和c使用默认值0

    print(add(1)) # 1

    调用add函数,传入两个参数,分别赋值给变量a和b,变量c使用默认值0

    print(add(1, 2)) # 3

    调用add函数,传入三个参数,分别赋值给a、b、c三个变量

    print(add(1, 2, 3)) # 6

    需要注意的是,带默认值的参数必须放在不带默认值的参数之后,否则将产生SyntaxError错误,错误消息是:non-default argument follows default argument,翻译成中文的意思是“没有默认值的参数放在了带默认值的参数后面”。

    #### 可变参数

    Python 语言中可以通过星号表达式语法让函数支持可变参数。所谓可变参数指的是在调用函数时,可以向函数传入0个或任意多个参数。将来我们以团队协作的方式开发商业项目时,很有可能要设计函数给其他人使用,但有的时候我们并不知道函数的调用者会向该函数传入多少个参数,这个时候可变参数就能派上用场。

    下面的代码演示了如何使用可变位置参数实现对任意多个数求和的add函数,调用函数时传入的参数会保存到一个元组,通过对该元组的遍历,可以获取传入函数的参数。

    用星号表达式来表示args可以接收0个或任意多个参数

    调用函数时传入的n个参数会组装成一个n元组赋给args

    如果一个参数都没有传入,那么args会是一个空元组

    def add(*args): total = 0 # 对保存可变参数的元组进行循环遍历 for val in args: # 对参数进行了类型检查(数值型的才能求和) if type(val) in (int, float): total += val return total

    在调用add函数时可以传入0个或任意多个参数

    print(add()) # 0 print(add(1)) # 1 print(add(1, 2, 3)) # 6 print(add(1, 2, 'hello', 3.45, 6)) # 12.45

    如果我们希望通过“参数名=参数值”的形式传入若干个参数,具体有多少个参数也是不确定的,我们还可以给函数添加可变关键字参数,把传入的关键字参数组装到一个字典中,代码如下所示。

    参数列表中的**kwargs可以接收0个或任意多个关键字参数

    调用函数时传入的关键字参数会组装成一个字典(参数名是字典中的键,参数值是字典中的值)

    如果一个关键字参数都没有传入,那么kwargs会是一个空字典

    def foo(args, *kwargs): print(args) print(kwargs)

    foo(3, 2.1, True, name='骆昊', age=43, gpa=4.95)

    输出:

    (3, 2.1, True)
    {'name': '骆昊', 'age': 43, 'gpa': 4.95}
    

    用模块管理函数

    不管用什么样的编程语言来写代码,给变量、函数起名字都是一个让人头疼的问题,因为我们会遇到命名冲突这种尴尬的情况。最简单的场景就是在同一个.py文件中定义了两个同名的函数,如下所示。

    def foo():
        print('hello, world!')

    def foo(): print('goodbye, world!')

    foo() # 大家猜猜调用foo函数会输出什么

    当然上面的这种情况我们很容易就能避免,但是如果项目是团队协作多人开发的时候,团队中可能有多个程序员都定义了名为foo的函数,这种情况下怎么解决命名冲突呢?答案其实很简单,Python 中每个文件就代表了一个模块(module),我们在不同的模块中可以有同名的函数,在使用函数的时候,我们通过import关键字导入指定的模块再使用完全限定名模块名.函数名)的调用方式,就可以区分到底要使用的是哪个模块中的foo函数,代码如下所示。

    module1.py

    def foo():
        print('hello, world!')
    

    module2.py

    def foo():
        print('goodbye, world!')
    

    test.py

    import module1
    import module2

    用“模块名.函数名”的方式(完全限定名)调用函数,

    module1.foo() # hello, world! module2.foo() # goodbye, world!

    在导入模块时,还可以使用as关键字对模块进行别名,这样我们可以使用更为简短的完全限定名。

    test.py

    import module1 as m1
    import module2 as m2

    m1.foo() # hello, world! m2.foo() # goodbye, world!

    上面两段代码,我们导入的是定义函数的模块,我们也可以使用from...import...语法从模块中直接导入需要使用的函数,代码如下所示。

    test.py

    from module1 import foo

    foo() # hello, world!

    from module2 import foo

    foo() # goodbye, world!

    但是,如果我们如果从两个不同的模块中导入了同名的函数,后面导入的函数会替换掉之前的导入,就像下面的代码,调用foo会输出goodbye, world!,因为我们先导入了module1foo,后导入了module2foo 。如果两个from...import...反过来写,那就是另外一番光景了。

    test.py

    from module1 import foo
    from module2 import foo

    foo() # goodbye, world!

    如果想在上面的代码中同时使用来自两个模块的foo函数还是有办法的,大家可能已经猜到了,还是用as关键字对导入的函数进行别名,代码如下所示。

    test.py

    from module1 import foo as f1
    from module2 import foo as f2

    f1() # hello, world! f2() # goodbye, world!

    标准库中的模块和函数

    Python 标准库中提供了大量的模块和函数来简化我们的开发工作,我们之前用过的random模块就为我们提供了生成随机数和进行随机抽样的函数;而time模块则提供了和时间操作相关的函数;我们之前用到过的math模块中还包括了计算正弦、余弦、指数、对数等一系列的数学函数。随着我们深入学习 Python 语言,我们还会用到更多的模块和函数。

    Python 标准库中还有一类函数是不需要import就能够直接使用的,我们将其称之为内置函数,这些内置函数不仅有用而且还很常用,下面的表格列出了一部分的内置函数。

    | 函数 | 说明 |
    | ——- | ———————————————————— |
    | abs | 返回一个数的绝对值,例如:abs(-1.3)会返回1.3。 |
    | bin | 把一个整数转换成以'0b'开头的二进制字符串,例如:bin(123)会返回'0b1111011'。 |
    | chr | 将Unicode编码转换成对应的字符,例如:chr(8364)会返回'€'。 |
    | hex | 将一个整数转换成以'0x'开头的十六进制字符串,例如:hex(123)会返回'0x7b'。 |
    | input | 从输入中读取一行,返回读到的字符串。 |
    | len | 获取字符串、列表等的长度。 |
    | max | 返回多个参数或一个可迭代对象中的最大值,例如:max(12, 95, 37)会返回95。 |
    | min | 返回多个参数或一个可迭代对象中的最小值,例如:min(12, 95, 37)会返回12。 |
    | oct | 把一个整数转换成以'0o'开头的八进制字符串,例如:oct(123)会返回'0o173'。 |
    | open | 打开一个文件并返回文件对象。 |
    | ord | 将字符转换成对应的Unicode编码,例如:ord('€')会返回8364。 |
    | pow | 求幂运算,例如:pow(2, 3)会返回8pow(2, 0.5)会返回1.4142135623730951。 |
    | print | 打印输出。 |
    | range | 构造一个范围序列,例如:range(100)会产生099的整数序列。 |
    | round | 按照指定的精度对数值进行四舍五入,例如:round(1.23456, 4)会返回1.2346。 |
    | sum | 对一个序列中的项从左到右进行求和运算,例如:sum(range(1, 101))会返回5050。 |
    | type | 返回对象的类型,例如:type(10)会返回int;而 type('hello')会返回str。 |

    总结

    函数是对功能相对独立且会重复使用的代码的封装。学会使用定义和使用函数,就能够写出更为优质的代码。当然,Python 语言的标准库中已经为我们提供了大量的模块和常用的函数,用好这些模块和函数就能够用更少的代码做更多的事情;如果这些模块和函数不能满足我们的要求,可能就需要自定义函数,然后再通过模块的概念来管理这些自定义函数。

  • 常用数据结构之字典

    常用数据结构之字典

    迄今为止,我们已经为大家介绍了 Python 中的三种容器型数据类型(列表、元组、集合),但是这些数据类型仍然不足以帮助我们解决所有的问题。例如,我们需要一个变量来保存一个人的多项信息,包括:姓名、年龄、身高、体重、家庭住址、本人手机号、紧急联系人手机号,此时你会发现,我们之前学过的列表、元组和集合类型都不够好使。

    person1 = ['王大锤', 55, 168, 60, '成都市武侯区科华北路62号1栋101', '13122334455', '13800998877']
    person2 = ('王大锤', 55, 168, 60, '成都市武侯区科华北路62号1栋101', '13122334455', '13800998877')
    person3 = {'王大锤', 55, 168, 60, '成都市武侯区科华北路62号1栋101', '13122334455', '13800998877'}
    

    集合肯定是最不合适的,因为集合中不能有重复元素,如果一个人的年龄和体重刚好相同,那么集合中就会少一项信息;同理,如果这个人的手机号和紧急联系人手机号是相同的,那么集合中又会少一项信息。另一方面,虽然列表和元组可以把一个人的所有信息都保存下来,但是当你想要获取这个人的手机号或家庭住址时,你得先知道他的手机号是列表或元组中的第几个元素。总之,在遇到上述的场景时,列表、元组、集合都不是最合适的选择,此时我们需要字典(dictionary)类型,这种数据类型最适合把相关联的信息组装到一起,可以帮助我们解决 Python 程序中为真实事物建模的问题。

    说到字典这个词,大家一定不陌生,读小学的时候,每个人手头基本上都有一本《新华字典》,如下图所示。

    Python 程序中的字典跟现实生活中的字典很像,它以键值对(键和值的组合)的方式把数据组织到一起,我们可以通过键找到与之对应的值并进行操作。就像《新华字典》中,每个字(键)都有与它对应的解释(值)一样,每个字和它的解释合在一起就是字典中的一个条目,而字典中通常包含了很多个这样的条目。

    创建和使用字典

    Python 中创建字典可以使用{}字面量语法,这一点跟上一节课讲的集合是一样的。但是字典的{}中的元素是以键值对的形式存在的,每个元素由:分隔的两个值构成,:前面是键,:后面是值,代码如下所示。

    xinhua = {
        '麓': '山脚下',
        '路': '道,往来通行的地方;方面,地区:南~货,外~货;种类:他俩是一~人',
        '蕗': '甘草的别名',
        '潞': '潞水,水名,即今山西省的浊漳河;潞江,水名,即云南省的怒江'
    }
    print(xinhua)
    person = {
        'name': '王大锤',
        'age': 55,
        'height': 168,
        'weight': 60,
        'addr': '成都市武侯区科华北路62号1栋101', 
        'tel': '13122334455',
        'emergence contact': '13800998877'
    }
    print(person)
    

    通过上面的代码,相信大家已经看出来了,用字典来保存一个人的信息远远优于使用列表或元组,因为我们可以用:前面的键来表示条目的含义,而:后面就是这个条目所对应的值。

    当然,如果愿意,我们也可以使用内置函数dict或者是字典的生成式语法来创建字典,代码如下所示。

    dict函数(构造器)中的每一组参数就是字典中的一组键值对

    person = dict(name='王大锤', age=55, height=168, weight=60, addr='成都市武侯区科华北路62号1栋101') print(person) # {'name': '王大锤', 'age': 55, 'height': 168, 'weight': 60, 'addr': '成都市武侯区科华北路62号1栋101'}

    可以通过Python内置函数zip压缩两个序列并创建字典

    items1 = dict(zip('ABCDE', '12345')) print(items1) # {'A': '1', 'B': '2', 'C': '3', 'D': '4', 'E': '5'} items2 = dict(zip('ABCDE', range(1, 10))) print(items2) # {'A': 1, 'B': 2, 'C': 3, 'D': 4, 'E': 5}

    用字典生成式语法创建字典

    items3 = {x: x ** 3 for x in range(1, 6)} print(items3) # {1: 1, 2: 8, 3: 27, 4: 64, 5: 125}

    想知道字典中一共有多少组键值对,仍然是使用len函数;如果想对字典进行遍历,可以用for循环,但是需要注意,for循环只是对字典的键进行了遍历,不过没关系,在学习了字典的索引运算后,我们可以通过字典的键访问它对应的值。

    person = {
        'name': '王大锤',
        'age': 55,
        'height': 168,
        'weight': 60,
        'addr': '成都市武侯区科华北路62号1栋101'
    }
    print(len(person))  # 5
    for key in person:
        print(key)
    

    字典的运算

    对于字典类型来说,成员运算和索引运算肯定是很重要的,前者可以判定指定的键在不在字典中,后者可以通过键访问对应的值或者向字典中添加新的键值对。值得注意的是,字典的索引不同于列表的索引,列表中的元素因为有属于自己有序号,所以列表的索引是一个整数;字典中因为保存的是键值对,所以字典需要用键去索引对应的值。需要特别提醒大家注意的是,字典中的键必须是不可变类型,例如整数(int)、浮点数(float)、字符串(str)、元组(tuple)等类型,这一点跟集合类型对元素的要求是一样的;很显然,之前我们讲的列表(list)和集合(set)不能作为字典中的键,字典类型本身也不能再作为字典中的键,因为字典也是可变类型,但是列表、集合、字典都可以作为字典中的值,例如:

    person = {
        'name': '王大锤',
        'age': 55,
        'height': 168,
        'weight': 60,
        'addr': ['成都市武侯区科华北路62号1栋101', '北京市西城区百万庄大街1号'],
        'car': {
            'brand': 'BMW X7',
            'maxSpeed': '250',
            'length': 5170,
            'width': 2000,
            'height': 1835,
            'displacement': 3.0
        }
    }
    print(person)
    

    大家可以看看下面的代码,了解一下字典的成员运算和索引运算。

    person = {'name': '王大锤', 'age': 55, 'height': 168, 'weight': 60, 'addr': '成都市武侯区科华北路62号1栋101'}

    成员运算

    print('name' in person) # True print('tel' in person) # False

    索引运算

    print(person['name']) print(person['addr']) person['age'] = 25 person['height'] = 178 person['tel'] = '13122334455' person['signature'] = '你的男朋友是一个盖世垃圾,他会踏着五彩祥云去迎娶你的闺蜜' print(person)

    循环遍历

    for key in person: print(f'{key}:\t{person[key]}')

    需要注意,在通过索引运算获取字典中的值时,如指定的键没有在字典中,将会引发KeyError异常。

    字典的方法

    字典类型的方法基本上都跟字典的键值对操作相关,其中get方法可以通过键来获取对应的值。跟索引运算不同的是,get方法在字典中没有指定的键时不会产生异常,而是返回None或指定的默认值,代码如下所示。

    person = {'name': '王大锤', 'age': 25, 'height': 178, 'addr': '成都市武侯区科华北路62号1栋101'}
    print(person.get('name'))       # 王大锤
    print(person.get('sex'))        # None
    print(person.get('sex', True))  # True
    

    如果需要获取字典中所有的键,可以使用keys方法;如果需要获取字典中所有的值,可以使用values方法。字典还有一个名为items的方法,它会将键和值组装成二元组,通过该方法来遍历字典中的元素也是非常方便的。

    person = {'name': '王大锤', 'age': 25, 'height': 178}
    print(person.keys())    # dict_keys(['name', 'age', 'height'])
    print(person.values())  # dict_values(['王大锤', 25, 178])
    print(person.items())   # dict_items([('name', '王大锤'), ('age', 25), ('height', 178)])
    for key, value in person.items():
        print(f'{key}:\t{value}')
    

    字典的update方法实现两个字典的合并操作。例如,有两个字典xy,当执行x.update(y)操作时,xy相同的键对应的值会被y中的值更新,而y中有但x中没有的键值对会直接添加到x中,代码如下所示。

    person1 = {'name': '王大锤', 'age': 55, 'height': 178}
    person2 = {'age': 25, 'addr': '成都市武侯区科华北路62号1栋101'}
    person1.update(person2)
    print(person1)  # {'name': '王大锤', 'age': 25, 'height': 178, 'addr': '成都市武侯区科华北路62号1栋101'}
    

    如果使用 Python 3.9 及以上的版本,也可以使用|运算符来完成同样的操作,代码如下所示。

    person1 = {'name': '王大锤', 'age': 55, 'height': 178}
    person2 = {'age': 25, 'addr': '成都市武侯区科华北路62号1栋101'}
    person1 |= person2
    print(person1)  # {'name': '王大锤', 'age': 25, 'height': 178, 'addr': '成都市武侯区科华北路62号1栋101'}
    

    可以通过poppopitem方法从字典中删除元素,前者会返回(获得)键对应的值,但是如果字典中不存在指定的键,会引发KeyError错误;后者在删除元素时,会返回(获得)键和值组成的二元组。字典的clear方法会清空字典中所有的键值对,代码如下所示。

    person = {'name': '王大锤', 'age': 25, 'height': 178, 'addr': '成都市武侯区科华北路62号1栋101'}
    print(person.pop('age'))  # 25
    print(person)             # {'name': '王大锤', 'height': 178, 'addr': '成都市武侯区科华北路62号1栋101'}
    print(person.popitem())   # ('addr', '成都市武侯区科华北路62号1栋101')
    print(person)             # {'name': '王大锤', 'height': 178}
    person.clear()
    print(person)             # {}
    

    跟列表一样,从字典中删除元素也可以使用del关键字,在删除元素的时候如果指定的键索引不到对应的值,一样会引发KeyError错误,具体的做法如下所示。

    person = {'name': '王大锤', 'age': 25, 'height': 178, 'addr': '成都市武侯区科华北路62号1栋101'}
    del person['age']
    del person['addr']
    print(person)  # {'name': '王大锤', 'height': 178}
    

    字典的应用

    我们通过几个简单的例子来看看如何使用字典类型解决一些实际的问题。

    例子1:输入一段话,统计每个英文字母出现的次数,按出现次数从高到低输出。

    sentence = input('请输入一段话: ')
    counter = {}
    for ch in sentence:
        if 'A' <= ch <= 'Z' or 'a' <= ch <= 'z':
            counter[ch] = counter.get(ch, 0) + 1
    sorted_keys = sorted(counter, key=counter.get, reverse=True)
    for key in sorted_keys:
        print(f'{key} 出现了 {counter[key]} 次.')
    

    输入:

    Man is distinguished, not only by his reason, but by this singular passion from other animals, which is a lust of the mind, that by a perseverance of delight in the continued and indefatigable generation of knowledge, exceeds the short vehemence of any carnal pleasure.
    

    输出:

    e 出现了 27 次.
    n 出现了 21 次.
    a 出现了 18 次.
    i 出现了 18 次.
    s 出现了 16 次.
    t 出现了 16 次.
    o 出现了 14 次.
    h 出现了 13 次.
    r 出现了 10 次.
    d 出现了 9 次.
    l 出现了 9 次.
    g 出现了 6 次.
    u 出现了 6 次.
    f 出现了 6 次.
    c 出现了 6 次.
    y 出现了 5 次.
    b 出现了 5 次.
    m 出现了 4 次.
    p 出现了 3 次.
    w 出现了 2 次.
    v 出现了 2 次.
    M 出现了 1 次.
    k 出现了 1 次.
    x 出现了 1 次.
    

    例子2:在一个字典中保存了股票的代码和价格,找出股价大于100元的股票并创建一个新的字典。

    > 说明:可以用字典的生成式语法来创建这个新字典。

    stocks = {
        'AAPL': 191.88,
        'GOOG': 1186.96,
        'IBM': 149.24,
        'ORCL': 48.44,
        'ACN': 166.89,
        'FB': 208.09,
        'SYMC': 21.29
    }
    stocks2 = {key: value for key, value in stocks.items() if value > 100}
    print(stocks2)
    

    输出:

    {'AAPL': 191.88, 'GOOG': 1186.96, 'IBM': 149.24, 'ACN': 166.89, 'FB': 208.09}
    

    总结

    Python 程序中的字典跟现实生活中字典非常像,允许我们以键值对的形式保存数据,再通过键访问对应的值。字典是一种非常有利于数据检索的数据类型,但是需要再次提醒大家,字典中的键必须是不可变类型,列表、集合、字典等类型的数据都不能作为字典的键。

  • 常用数据结构之集合

    常用数据结构之集合

    在学习了列表和元组之后,我们再来学习一种容器型的数据类型,它的名字叫集合(set)。说到集合这个词大家一定不会陌生,在数学课本上就有这个概念。如果我们把一定范围的、确定的、可以区别的事物当作一个整体来看待,那么这个整体就是集合,集合中的各个事物称为集合的元素。通常,集合需要满足以下要求:

  • 无序性:一个集合中,每个元素的地位都是相同的,元素之间是无序的。
  • 互异性:一个集合中,任何两个元素都是不相同的,即元素在集合中只能出现一次。
  • 确定性:给定一个集合和一个任意元素,该元素要么属这个集合,要么不属于这个集合,二者必居其一,不允许有模棱两可的情况出现。
  • Python 程序中的集合跟数学上的集合没有什么本质区别,需要强调的是上面所说的无序性和互异性。无序性说明集合中的元素并不像列中的元素那样存在某种次序,可以通过索引运算就能访问任意元素,集合并不支持索引运算。另外,集合的互异性决定了集合中不能有重复元素,这一点也是集合区别于列表的地方,我们无法将重复的元素添加到一个集合中。集合类型必然是支持innot in成员运算的,这样就可以确定一个元素是否属于集合,也就是上面所说的集合的确定性。集合的成员运算在性能上要优于列表的成员运算,这是集合的底层存储特性决定的,此处我们暂时不做讨论,大家记住这个结论即可。

    > 说明:集合底层使用了哈希存储(散列存储),对哈希存储不了解的读者可以先看看“Hello 算法”网站对哈希表的讲解,感谢作者的开源精神。

    创建集合

    在 Python 中,创建集合可以使用{}字面量语法,{}中需要至少有一个元素,因为没有元素的{}并不是空集合而是一个空字典,字典类型我们会在下一节课中为大家介绍。当然,也可以使用 Python 内置函数set来创建一个集合,准确的说set并不是一个函数,而是创建集合对象的构造器,这个知识点会在后面讲解面向对象编程的地方为大家介绍。我们可以使用set函数创建一个空集合,也可以用它将其他序列转换成集合,例如:set('hello')会得到一个包含了4个字符的集合(重复的字符l只会在集合中出现一次)。除了这两种方式,还可以使用生成式语法来创建集合,就像我们之前用生成式语法创建列表那样。

    set1 = {1, 2, 3, 3, 3, 2}
    print(set1)

    set2 = {'banana', 'pitaya', 'apple', 'apple', 'banana', 'grape'} print(set2)

    set3 = set('hello') print(set3)

    set4 = set([1, 2, 2, 3, 3, 3, 2, 1]) print(set4)

    set5 = {num for num in range(1, 20) if num % 3 == 0 or num % 7 == 0} print(set5)

    需要提醒大家,集合中的元素必须是hashable类型,所谓hashable类型指的是能够计算出哈希码的数据类型,通常不可变类型都是hashable类型,如整数(int)、浮点小数(float)、布尔值(bool)、字符串(str)、元组(tuple)等。可变类型都不是hashable类型,因为可变类型无法计算出确定的哈希码,所以它们不能放到集合中。例如:我们不能将列表作为集合中的元素;同理,由于集合本身也是可变类型,所以集合也不能作为集合中的元素。我们可以创建出嵌套列表(列表的元素也是列表),但是我们不能创建出嵌套的集合,这一点在使用集合的时候一定要引起注意。

    > 温馨提示:如果不理解上面提到的哈希码、哈希存储这些概念,可以先放放,因为它并不影响你继续学习和使用 Python 语言。当然,如果是计算机专业的小伙伴,不理解哈希存储是很难被原谅的,要赶紧去补课了。

    元素的遍历

    我们可以通过len函数来获得集合中有多少个元素,但是我们不能通过索引运算来遍历集合中的元素,因为集合元素并没有特定的顺序。当然,要实现对集合元素的遍历,我们仍然可以使用for-in循环,代码如下所示。

    set1 = {'Python', 'C++', 'Java', 'Kotlin', 'Swift'}
    for elem in set1:
        print(elem)
    

    > 提示:大家看看上面代码的运行结果,通过单词输出的顺序体会一下集合的无序性。

    集合的运算

    Python 为集合类型提供了非常丰富的运算,主要包括:成员运算、交集运算、并集运算、差集运算、比较运算(相等性、子集、超集)等。

    #### 成员运算

    可以通过成员运算innot in 检查元素是否在集合中,代码如下所示。

    set1 = {11, 12, 13, 14, 15}
    print(10 in set1)      # False 
    print(15 in set1)      # True
    set2 = {'Python', 'Java', 'C++', 'Swift'}
    print('Ruby' in set2)  # False
    print('Java' in set2)  # True
    

    #### 二元运算

    集合的二元运算主要指集合的交集、并集、差集、对称差等运算,这些运算可以通过运算符来实现,也可以通过集合类型的方法来实现,代码如下所示。

    set1 = {1, 2, 3, 4, 5, 6, 7}
    set2 = {2, 4, 6, 8, 10}

    交集

    print(set1 & set2) # {2, 4, 6} print(set1.intersection(set2)) # {2, 4, 6}

    并集

    print(set1 | set2) # {1, 2, 3, 4, 5, 6, 7, 8, 10} print(set1.union(set2)) # {1, 2, 3, 4, 5, 6, 7, 8, 10}

    差集

    print(set1 - set2) # {1, 3, 5, 7} print(set1.difference(set2)) # {1, 3, 5, 7}

    对称差

    print(set1 ^ set2) # {1, 3, 5, 7, 8, 10} print(set1.symmetric_difference(set2)) # {1, 3, 5, 7, 8, 10}

    通过上面的代码可以看出,对两个集合求交集,&运算符和intersection方法的作用是完全相同的,使用运算符的方式显然更直观且代码也更简短。需要说明的是,集合的二元运算还可以跟赋值运算一起构成复合赋值运算,例如:set1 |= set2相当于set1 = set1 | set2,跟|=作用相同的方法是updateset1 &= set2相当于set1 = set1 & set2,跟&=作用相同的方法是intersection_update,代码如下所示。

    set1 = {1, 3, 5, 7}
    set2 = {2, 4, 6}
    set1 |= set2
    

    set1.update(set2)

    print(set1) # {1, 2, 3, 4, 5, 6, 7} set3 = {3, 6, 9} set1 &= set3

    set1.intersection_update(set3)

    print(set1) # {3, 6} set2 -= set1

    set2.difference_update(set1)

    print(set2) # {2, 4}

    #### 比较运算

    两个集合可以用==!=进行相等性判断,如果两个集合中的元素完全相同,那么==比较的结果就是True,否则就是False。如果集合A的任意一个元素都是集合B的元素,那么集合A称为集合B的子集,即对于 $\small{\forall{a} \in {A}}$ ,均有 $\small{{a} \in {B}}$ ,则 $\small{{A} \subseteq {B}}$ ,AB的子集,反过来也可以称BA的超集。如果AB的子集且A不等于B,那么A就是B的真子集。Python 为集合类型提供了判断子集和超集的运算符,其实就是我们非常熟悉的<<=>>=这些运算符。当然,我们也可以通过集合类型的方法issubsetissuperset来判断集合之间的关系,代码如下所示。

    set1 = {1, 3, 5}
    set2 = {1, 2, 3, 4, 5}
    set3 = {5, 4, 3, 2, 1}

    print(set1 < set2) # True print(set1 <= set2) # True print(set2 < set3) # False print(set2 <= set3) # True print(set2 > set1) # True print(set2 == set3) # True

    print(set1.issubset(set2)) # True print(set2.issuperset(set1)) # True

    > 说明:上面的代码中,set1 < set2判断set1是不是set2的真子集,set1 <= set2判断set1是不是set2的子集,set2 > set1判断set2是不是set1的超集。当然,我们也可以通过set1.issubset(set2)判断set1是不是set2的子集;通过set2.issuperset(set1)判断set2是不是set1的超集。

    集合的方法

    刚才我们说过,Python 中的集合是可变类型,我们可以通过集合的方法向集合添加元素或从集合中删除元素。

    set1 = {1, 10, 100}

    添加元素

    set1.add(1000) set1.add(10000) print(set1) # {1, 100, 1000, 10, 10000}

    删除元素

    set1.discard(10) if 100 in set1: set1.remove(100) print(set1) # {1, 1000, 10000}

    清空元素

    set1.clear() print(set1) # set()

    > 说明:删除元素的remove方法在元素不存在时会引发KeyError错误,所以上面的代码中我们先通过成员运算判断元素是否在集合中。集合类型还有一个pop方法可以从集合中随机删除一个元素,该方法在删除元素的同时会返回(获得)被删除的元素,而removediscard方法仅仅是删除元素,不会返回(获得)被删除的元素。

    集合类型还有一个名为isdisjoint的方法可以判断两个集合有没有相同的元素,如果没有相同元素,该方法返回True,否则该方法返回False,代码如下所示。

    set1 = {'Java', 'Python', 'C++', 'Kotlin'}
    set2 = {'Kotlin', 'Swift', 'Java', 'Dart'}
    set3 = {'HTML', 'CSS', 'JavaScript'}
    print(set1.isdisjoint(set2))  # False
    print(set1.isdisjoint(set3))  # True
    

    不可变集合

    Python 中还有一种不可变类型的集合,名字叫frozensetsetfrozenset的区别就如同listtuple的区别,frozenset由于是不可变类型,能够计算出哈希码,因此它可以作为set中的元素。除了不能添加和删除元素,frozenset在其他方面跟set是一样的,下面的代码简单的展示了frozenset的用法。

    fset1 = frozenset({1, 3, 5, 7})
    fset2 = frozenset(range(1, 6))
    print(fset1)          # frozenset({1, 3, 5, 7})
    print(fset2)          # frozenset({1, 2, 3, 4, 5})
    print(fset1 & fset2)  # frozenset({1, 3, 5})
    print(fset1 | fset2)  # frozenset({1, 2, 3, 4, 5, 7})
    print(fset1 - fset2)  # frozenset({7})
    print(fset1 < fset2)  # False
    

    总结

    Python 中的集合类型是一种无序容器不允许有重复运算,由于底层使用了哈希存储,集合中的元素必须是hashable类型。集合与列表最大的区别在于集合中的元素没有顺序、所以不能够通过索引运算访问元素、但是集合可以执行交集、并集、差集等二元运算,也可以通过关系运算符检查两个集合是否存在超集、子集等关系。

  • 常用数据结构之字符串

    常用数据结构之字符串

    第二次世界大战促使了现代电子计算机的诞生,世界上的第一台通用电子计算机名叫 ENIAC(电子数值积分计算机),诞生于美国的宾夕法尼亚大学,占地 167 平米,重量约 27 吨,每秒钟大约能够完成约 5000 次浮点运算,如下图所示。ENIAC 诞生之后被应用于导弹弹道的计算,而数值计算也是现代电子计算机最为重要的一项功能。

    随着时间的推移,虽然数值运算仍然是计算机日常工作中最为重要的组成部分,但是今天的计算机还要处理大量的以文本形式存在的信息。如果我们希望通过 Python 程序来操作本这些文本信息,就必须要先了解字符串这种数据类型以及与它相关的运算和方法。

    字符串的定义

    所谓字符串,就是由零个或多个字符组成的有限序列,一般记为:

    $$
    s = a_1a_2 \cdots a_n \,\,\,\,\, (0 \le n \le \infty)
    $$

    在 Python 程序中,我们把单个或多个字符用单引号或者双引号包围起来,就可以表示一个字符串。字符串中的字符可以是特殊符号、英文字母、中文字符、日文的平假名或片假名、希腊字母、Emoji 字符(如:💩、🐷、🀄️)等。

    s1 = 'hello, world!'
    s2 = "你好,世界!❤️"
    s3 = '''hello,
    wonderful
    world!'''
    print(s1)
    print(s2)
    print(s3)
    

    #### 转义字符

    我们可以在字符串中使用\(反斜杠)来表示转义,也就是说\后面的字符不再是它原来的意义,例如:\n不是代表字符\和字符n,而是表示换行;\t也不是代表字符\和字符t,而是表示制表符。所以如果字符串本身又包含了'"\这些特殊的字符,必须要通过\进行转义处理。例如要输出一个带单引号或反斜杠的字符串,需要用如下所示的方法。

    s1 = '\'hello, world!\''
    s2 = '\\hello, world!\\'
    print(s1)
    print(s2)
    

    #### 原始字符串

    Python 中有一种以rR开头的字符串,这种字符串被称为原始字符串,意思是字符串中的每个字符都是它本来的含义,没有所谓的转义字符。例如,在字符串'hello\n'中,\n表示换行;而在r'hello\n'中,\n不再表示换行,就是字符\和字符n。大家可以运行下面的代码,看看会输出什么。

    s1 = '\it \is \time \to \read \now'
    s2 = r'\it \is \time \to \read \now'
    print(s1)
    print(s2)
    

    > 说明:上面的变量s1中,\t\r\n都是转义字符。\t是制表符(table),\n是换行符(new line),\r是回车符(carriage return)相当于让输出回到了行首。对比一下两个print函数的输出,看看到底有什么区别!

    #### 字符的特殊表示

    Python 中还允许在\后面还可以跟一个八进制或者十六进制数来表示字符,例如\141\x61都代表小写字母a,前者是八进制的表示法,后者是十六进制的表示法。另外一种表示字符的方式是在\u后面跟 Unicode 字符编码,例如\u9a86\u660a代表的是中文“骆昊”。运行下面的代码,看看输出了什么。

    s1 = '\141\142\143\x61\x62\x63'
    s2 = '\u9a86\u660a'
    print(s1)
    print(s2)
    

    字符串的运算

    Python 语言为字符串类型提供了非常丰富的运算符,有很多运算符跟列表类型的运算符作用类似。例如,我们可以使用+运算符来实现字符串的拼接,可以使用*运算符来重复一个字符串的内容,可以使用innot in来判断一个字符串是否包含另外一个字符串,我们也可以用[][:]运算符从字符串取出某个字符或某些字符。

    #### 拼接和重复

    下面的例子演示了使用+*运算符来实现字符串的拼接和重复操作。

    s1 = 'hello' + ', ' + 'world'
    print(s1)    # hello, world
    s2 = '!' * 3
    print(s2)    # !!!
    s1 += s2
    print(s1)    # hello, world!!!
    s1 *= 2
    print(s1)    # hello, world!!!hello, world!!!
    

    实现字符串的重复是非常有意思的一个运算符,在很多编程语言中,要表示一个有10个a的字符串,你只能写成'aaaaaaaaaa',但是在 Python 中,你可以写成'a' 10。你可能觉得'aaaaaaaaaa'这种写法也没有什么不方便的,但是请想一想,如果字符a要重复100次或者1000次又会如何呢?

    #### 比较运算

    对于两个字符串类型的变量,可以直接使用比较运算符来判断两个字符串的相等性或比较大小。需要说明的是,因为字符串在计算机内存中也是以二进制形式存在的,那么字符串的大小比较比的是每个字符对应的编码的大小。例如A的编码是65, 而a的编码是97,所以'A' < 'a'的结果相当于就是65 < 97的结果,这里很显然是True;而'boy' < 'bad',因为第一个字符都是'b'比不出大小,所以实际比较的是第二个字符的大小,显然'o' < 'a'的结果是False,所以'boy' < 'bad'的结果是False。如果不清楚两个字符对应的编码到底是多少,可以使用ord函数来获得,之前我们有提到过这个函数。例如ord('A')的值是65,而ord('昊')的值是26122。下面的代码展示了字符串的比较运算,请大家仔细看看。

    s1 = 'a whole new world'
    s2 = 'hello world'
    print(s1 == s2)             # False
    print(s1 < s2)              # True
    print(s1 == 'hello world')  # False
    print(s2 == 'hello world')  # True
    print(s2 != 'Hello world')  # True
    s3 = '骆昊'
    print(ord('骆'))            # 39558
    print(ord('昊'))            # 26122
    s4 = '王大锤'
    print(ord('王'))            # 29579
    print(ord('大'))            # 22823
    print(ord('锤'))            # 38180
    print(s3 >= s4)             # True
    print(s3 != s4)             # True
    

    #### 成员运算

    Python 中可以用innot in判断一个字符串中是否包含另外一个字符或字符串,跟列表类型一样,innot in称为成员运算符,会产生布尔值TrueFalse,代码如下所示。

    s1 = 'hello, world'
    s2 = 'goodbye, world'
    print('wo' in s1)      # True
    print('wo' not in s2)  # False
    print(s2 in s1)        # False
    

    #### 获取字符串长度

    获取字符串长度跟获取列表元素个数一样,使用内置函数len,代码如下所示。

    s = 'hello, world'
    print(len(s))                 # 12
    print(len('goodbye, world'))  # 14
    

    #### 索引和切片

    字符串的索引和切片操作跟列表、元组几乎没有区别,因为字符串也是一种有序序列,可以通过正向或反向的整数索引访问其中的元素。但是有一点需要注意,因为字符串是不可变类型,所以不能通过索引运算修改字符串中的字符

    s = 'abc123456'
    n = len(s)
    print(s[0], s[-n])    # a a
    print(s[n-1], s[-1])  # 6 6
    print(s[2], s[-7])    # c c
    print(s[5], s[-4])    # 3 3
    print(s[2:5])         # c12
    print(s[-7:-4])       # c12
    print(s[2:])          # c123456
    print(s[:2])          # ab
    print(s[::2])         # ac246
    print(s[::-1])        # 654321cba
    

    需要再次提醒大家注意的是,在进行索引运算时,如果索引越界,会引发IndexError异常,错误提示信息为:string index out of range(字符串索引超出范围)。

    字符的遍历

    如果希望遍历字符串中的每个字符,可以使用for-in循环,有如下所示的两种方式。

    方式一:

    s = 'hello'
    for i in range(len(s)):
        print(s[i])
    

    方式二:

    s = 'hello'
    for elem in s:
        print(elem)
    

    字符串的方法

    在 Python 中,我们可以通过字符串类型自带的方法对字符串进行操作和处理,假设我们有名为foo的字符串,字符串有名为bar的方法,那么使用字符串方法的语法是:foo.bar(),这是一种通过对象引用调用对象方法的语法,跟前面使用列表方法的语法是一样的。

    #### 大小写相关操作

    下面的代码演示了和字符串大小写变换相关的方法。

    s1 = 'hello, world!'
    

    字符串首字母大写

    print(s1.capitalize()) # Hello, world!

    字符串每个单词首字母大写

    print(s1.title()) # Hello, World!

    字符串变大写

    print(s1.upper()) # HELLO, WORLD! s2 = 'GOODBYE'

    字符串变小写

    print(s2.lower()) # goodbye

    检查s1和s2的值

    print(s1) # hello, world print(s2) # GOODBYE

    > 说明:由于字符串是不可变类型,使用字符串的方法对字符串进行操作会产生新的字符串,但是原来变量的值并没有发生变化。所以上面的代码中,当我们最后检查s1s2两个变量的值时,s1s2 的值并没有发生变化。

    #### 查找操作

    如果想在一个字符串中从前向后查找有没有另外一个字符串,可以使用字符串的findindex方法。在使用findindex方法时还可以通过方法的参数来指定查找的范围,也就是查找不必从索引为0的位置开始。

    s = 'hello, world!'
    print(s.find('or'))      # 8
    print(s.find('or', 9))   # -1
    print(s.find('of'))      # -1
    print(s.index('or'))     # 8
    print(s.index('or', 9))  # ValueError: substring not found
    

    >说明find方法找不到指定的字符串会返回-1index方法找不到指定的字符串会引发ValueError错误。

    findindex方法还有逆向查找(从后向前查找)的版本,分别是rfindrindex,代码如下所示。

    s = 'hello world!'
    print(s.find('o'))       # 4
    print(s.rfind('o'))      # 7
    print(s.rindex('o'))     # 7
    

    print(s.rindex('o', 8)) # ValueError: substring not found

    #### 性质判断

    可以通过字符串的startswithendswith来判断字符串是否以某个字符串开头和结尾;还可以用is开头的方法判断字符串的特征,这些方法都返回布尔值,代码如下所示。

    s1 = 'hello, world!'
    print(s1.startswith('He'))   # False
    print(s1.startswith('hel'))  # True
    print(s1.endswith('!'))      # True
    s2 = 'abc123456'
    print(s2.isdigit())  # False
    print(s2.isalpha())  # False
    print(s2.isalnum())  # True
    

    > 说明:上面的isdigit用来判断字符串是不是完全由数字构成的,isalpha用来判断字符串是不是完全由字母构成的,这里的字母指的是 Unicode 字符但不包含 Emoji 字符,isalnum用来判断字符串是不是由字母和数字构成的。

    #### 格式化

    在 Python 中,字符串类型可以通过centerljustrjust方法做居中、左对齐和右对齐的处理。如果要在字符串的左侧补零,也可以使用zfill方法。

    s = 'hello, world'
    print(s.center(20, ''))  # hello, world*
    print(s.rjust(20))        #         hello, world
    print(s.ljust(20, '~'))   # hello, world~~~~~~~~
    print('33'.zfill(5))      # 00033
    print('-33'.zfill(5))     # -0033
    

    我们之前讲过,在用print函数输出字符串时,可以用下面的方式对字符串进行格式化。

    a = 321
    b = 123
    print('%d  %d = %d' % (a, b, a  b))
    

    当然,我们也可以用字符串的format方法来完成字符串的格式,代码如下所示。

    a = 321
    b = 123
    print('{0}  {1} = {2}'.format(a, b, a  b))
    

    从 Python 3.6 开始,格式化字符串还有更为简洁的书写方式,就是在字符串前加上f来格式化字符串,在这种以f打头的字符串中,{变量名}是一个占位符,会被变量对应的值将其替换掉,代码如下所示。

    a = 321
    b = 123
    print(f'{a}  {b} = {a  b}')
    

    如果需要进一步控制格式化语法中变量值的形式,可以参照下面的表格来进行字符串格式化操作。

    | 变量值 | 占位符 | 格式化结果 | 说明 |
    | ----------- | ---------- | ------------- | ---- |
    | 3.1415926 | {:.2f} | '3.14' | 保留小数点后两位 |
    | 3.1415926 | {:+.2f} | '+3.14' | 带符号保留小数点后两位 |
    | -1 | {:+.2f} | '-1.00' | 带符号保留小数点后两位 |
    | 3.1415926 | {:.0f} | '3' | 不带小数 |
    | 123 | {:0>10d} | '0000000123' | 左边补0,补够10位 |
    | 123 | {:x<10d} | '123xxxxxxx' | 右边补x ,补够10位 |
    | 123 | {:>10d} | ' 123' | 左边补空格,补够10位 |
    | 123 | {:<10d} | '123 ' | 右边补空格,补够10位 |
    | 123456789 | {:,} | '123,456,789' | 逗号分隔格式 |
    | 0.123 | {:.2%} | '12.30%' | 百分比格式 |
    | 123456789 | {:.2e} | '1.23e+08' | 科学计数法格式 |

    #### 修剪操作

    字符串的strip方法可以帮我们获得将原字符串修剪掉左右两端指定字符之后的字符串,默认是修剪空格字符。这个方法非常有实用价值,可以用来将用户输入时不小心键入的头尾空格等去掉,strip方法还有lstriprstrip两个版本,相信从名字大家已经猜出来这两个方法是做什么用的。

    s1 = '   jackfrued@126.com  '
    print(s1.strip())      # jackfrued@126.com
    s2 = '~你好,世界~'
    print(s2.lstrip('~'))  # 你好,世界~
    print(s2.rstrip('~'))  # ~你好,世界
    

    #### 替换操作

    如果希望用新的内容替换字符串中指定的内容,可以使用replace方法,代码如下所示。replace方法的第一个参数是被替换的内容,第二个参数是替换后的内容,还可以通过第三个参数指定替换的次数。

    s = 'hello, good world'
    print(s.replace('o', '@'))     # hell@, g@@d w@rld
    print(s.replace('o', '@', 1))  # hell@, good world
    

    #### 拆分与合并

    可以使用字符串的split方法将一个字符串拆分为多个字符串(放在一个列表中),也可以使用字符串的join方法将列表中的多个字符串连接成一个字符串,代码如下所示。

    s = 'I love you'
    words = s.split()
    print(words)            # ['I', 'love', 'you']
    print('~'.join(words))  # I~love~you
    

    需要说明的是,split方法默认使用空格进行拆分,我们也可以指定其他的字符来拆分字符串,而且还可以指定最大拆分次数来控制拆分的效果,代码如下所示。

    s = 'I#love#you#so#much'
    words = s.split('#')
    print(words)  # ['I', 'love', 'you', 'so', 'much']
    words = s.split('#', 2)
    print(words)  # ['I', 'love', 'you#so#much']
    

    #### 编码和解码

    Python 中除了字符串str类型外,还有一种表示二进制数据的字节串类型(bytes)。所谓字节串,就是由零个或多个字节组成的有限序列。通过字符串的encode方法,我们可以按照某种编码方式将字符串编码为字节串,我们也可以使用字节串的decode方法,将字节串解码为字符串,代码如下所示。

    a = '骆昊'
    b = a.encode('utf-8')
    c = a.encode('gbk')
    print(b)                  # b'\xe9\xaa\x86\xe6\x98\x8a'
    print(c)                  # b'\xc2\xe6\xea\xbb'
    print(b.decode('utf-8'))  # 骆昊
    print(c.decode('gbk'))    # 骆昊
    

    注意,如果编码和解码的方式不一致,会导致乱码问题(无法再现原始的内容)或引发UnicodeDecodeError错误,导致程序崩溃。

    #### 其他方法

    对于字符串类型来说,还有一个常用的操作是对字符串进行匹配检查,即检查字符串是否满足某种特定的模式。例如,一个网站对用户注册信息中用户名和邮箱的检查,就属于模式匹配检查。实现模式匹配检查的工具叫做正则表达式,Python 语言通过标准库中的re模块提供了对正则表达式的支持,我们会在后续的课程中为大家讲解这个知识点。

    总结

    知道如何表示和操作字符串对程序员来说是非常重要的,因为我们经常需要处理文本信息,Python 中操作字符串可以用拼接、索引、切片等运算符,也可以使用字符串类型提供的非常丰富的方法。