数据类型:一组值的集合,以及可以对这些值执行的操作。
Python不像Java一样区分“基本数据类型”和“对象数据类型”,而是一切皆对象。
变量:用特定数据类型定义,可存储满足类型约束的值。
int(3.99)
bool("hello")
Java是静态类型语言——在编译阶段进行类型检查,所有类型在编译时已知。
Python是动态类型语言——运行阶段进行类型检查。
静态检查——程序运行之前发现错误。对“类型”检查。
动态检查——程序运行时发现错误。对“值”检查
无检查。
可靠性排序:静态>动态>无。
在编译阶段发现错误,避免把错误带入运行阶段,提高程序正确性/健壮性。
可发现错误类型:语法错误;类名/函数名错误;参数数目错误;参数类型错误;返回值类型错误。
可发现的错误类型:非法参数值;无法表示的返回值;索引越界;空指针。
=
赋值可以与变量声明合并在一起。
改变一个变量——将该变量指向另一个值的存储空间。
改变一个变量的值——将该变量当前指向的值的存储空间中写入一个新的值。
不变性——重要设计原则。
不变数据类型——一旦被创建,值不能改变。
不变好,所以尽量使用final变量作为方法的输入参数、作为局部变量。
注意:final类无法派生子类;无法改变值/引用;无法被子类重写。
不变对象——一旦被创建始终指向同一个值/引用。
可变对象——拥有方法可以修改自己的值/引用。
区别:有多个引用时会有差异。
使用不可变变量,对其频繁修改(换房子)会产生大量临时拷贝(需要垃圾回收)。
可变类型最少化拷贝(装修房子)以提高效率。
使用可变数据类型可获得更好的性能。
也适合于在多个模块之间共享数据。
用于描述程序运行时的内部状态。
便于程序员交流。
便于刻画各类变量随时间变化。
便于解释设计思路。
基本类型的值——箭头+值,在Python中不存在。
对象类型的值——箭头+圈+值。
不可变值——双线椭圆。
可变值——单线椭圆。
不可变引用:即只被赋值一次且永远不会被重新赋值的变量。
在快照图中,不可变引用(final)由双线箭头 (double arrow) 表示。
引用不可变,指向的值可变。
可变的引用也可指向不可变的值。
函数——程序的积木,可独立开发、测试、复用。
抽象——使用函数的客户端无需了解函数内部如何工作。
用来交流的编程。 为什么要写出自己的假设——自己记不住,别人不懂。
代码中蕴含“设计决策”给编译器读。
注释中蕴含“设计决策”让自己、别人和大模型读。
什么是规约:
团队工作的关键:没规约,没法写程序,即使写出来也不知道对错。
一种契约:程序与客户端之间达成的一致。
给“供需双方”都确定了责任,在调用的时候双方都要遵守。
为什么需要规约:
现实:很多bug来自于双方之间的误解。不写下来,不同开发者的理解会有所不同。没有规约难以定位错误。
好处:精确的规约有助于区分责任。客户端无需阅读调用函数的代码,只需理解。
规约的作用:
规约可以隔离“变化”,无需通知客户端。
规约可以提高代码效率。
规约扮演防火墙的角色,实现了解耦,不需了解其他的具体实现。
规约规定了什么:
输入/输出的数据类型。
功能和正确性。
性能。
只讲“能做什么”,不讲“怎么实现”。
行为等价性——对于客户端/用户而言,是否可相互替换。
根据规约判断是否行为等价。
单纯看实现代码不足以判断2个函数是否等价;需要根据代码的规约判断;在编写代码前要弄清楚规约如何协商形成、如何撰写。
前置条件——对客户端的约束,在使用方法时必须满足的条件。
后置条件——对开发者的约束,方法结束时必须满足的条件。
契约——如果前置条件满足,后置条件必须满足。前置条件不满足,则方法可做任何事。
方法前注释也是一种规约,但需人工判定是否满足。
规约可包含的内容:一个方法的规约可以涉及方法的参数 (parameters)
和返回值(return value),但绝不应提及方法的局部变量 (local
variables)或方法所属类的私有字段 (private fields)。
除非后置条件里声明过,否则方法内部不应该改变输入参数。
应尽量遵循此规则,尽量不涉及“该方法会修改传入的参数对象”,否则容易引发bugs。
程序员应达成契约,除非spec必须如此,否则不应修改输入参数。
尽量避免使用可变对象。
可变对象使简单契约变复杂。
程序中可能有很多变量指向同一个可变对象(别名)。
无法强迫类的实现体和客户端不保存可变变量的别名
当你使用可变对象时,即便你(实现者)和调用者(客户端)达成了一致,第三方(持有该对象引用的其他人)也能跳出来把你的契约撕得粉碎)。
而且使用可变变量,责任完全在客户端/开发者的良心上。
不能靠程序员的“道德”,要靠严格的“契约”。
关键在于“不可变”,在规约里限定住。
如何判断“哪个规约更好”——规约的确定性;规约的陈述性;规约的强度。
如何比较规约——规约强度S2≥S1当且仅当S2的前置条件更放松后置条件更严格。此时可用S2代替S1。
规约越强,开发者的自由度和责任越重,客户端的责任越轻。
某个具体实现,若满足规约,则落在其范围内;否则在其之外。
程序员可以在规约的范围内自由选择实现方式。
客户端无需了解具体使用了哪个实现。
更强的规约表达为更小的区域。
一个好的“方法”设计,不代表代码写得多好,而是对该方法的规约设计得如何。
客户端用得舒服。开发者编得舒服。
1.规约应该是内聚的——规约的功能单一、简单、易理解。规约若做两件事,则分成两个方法。
2.结果应该是信息丰富的——不能让客户端产生理解的歧义。
3.规约应该足够强——太弱的规约,客户端不敢用。开发者应该尽可能考虑各种特殊情况,在post-condition给出处理措施。
4.规约应该足够弱——太强的规约,在很多特殊情况下难以达到,增加开发者实现难度。
5.规约应该使用抽象模型——给方法的开发者和客户端更大的自由度。
前置条件还是后置条件?
如果检查某个条件会导致方法运行速度慢到无法接受,那么通常非常有必要将其设为前置条件
(Precondition)。
如果你不写
Precondition(即不约束调用者),你就必须在代码内部进行检查(check);如果检查的代价太大,就在规约里加入
Precondition,把责任交给客户端 (Client)。
客户端不喜欢太强的前置条件,不满足前置条件的输入会失败。
惯用的做法是不限定太强的前置条件,而是在后置条件中抛出异常:输入不合法。
尽可能在错误根源处fail,防止错误扩散。
选择前置条件/后置条件的衡量标准——检查参数合法性的代价有多大?
归纳:
是否使用前置条件取决于——check的代价;方法的使用范围。
如果只在类的内部使用该方法(private),那么可以不使用前置条件,在使用该方法的各个位置进行check——责任交给内部client;
如果在其他地方使用该方法(public),那么必须要使用前置条件,若client端不满足则方法抛出异常。
对象:
现实世界的对象有2个性质——状态和行为。
对每个对象思考——状态有哪些,行为有哪些。
对象——一组状态和行为的组合。
状态——字段/属性。
动作——函数/方法。
类:
每个对象有一个类,类定义了方法和字段,两者被称为成员。
类同时定义了类型type和实现implementation。
宽泛地说,类的方法就是它的API。
类的静态变量/方法VS实例变量/方法:
类成员变量,类方法。
实例成员变量,实例方法。
类变量和类方法与类相关联,且每个类只出现一次,使用它们不用创建对象。
实例方法和实例变量在类的每个实例(对象)中都会出现一次。
在Python中,我们用abc.ABC或typing.Prorocol来定义ADT(接口规范),用普通的class实现具体的逻辑。
接口之间可以继承与扩展,Python原生支持多重继承,所以多个Protocol或ABC可以随意相互继承组合。
一个类可以实现多个接口。
一个接口可以有多种实现类。
Java是“必须发誓(implements)自己是鸭子才算鸭子”,原教旨Python是
“只要叫声像鸭子就是鸭子”,而现代Python(Protocol)是“只要静态扫描出
你具备鸭子的叫声和步态,不需要你发誓,IDE就认你是鸭子”。
接口实现类型:
鸭子类型实现接口。
abc模块(抽象基类)。
静态鸭子类型。
Python接口与类:
接口vs类——确定行为规约vs具体实现。接口只定义方法签名,不实现。类提供具体的方法实现。
类本身就可以作为ADT,类的公共方法天然构成一个“隐式接口”,可以直接用具体类作为类型注解。
实际中,函数参数和变量注解更倾向于使用“抽象接口”——在做类型提示时,优先使用抽象基类或协议,除非非得用某个具体的类不可;最大化保持Python鸭子类型的灵活性,方便无缝替换底层数据结构。
接口已定义了“做什么”,为什么还需要多个不同的类实现同一个接口?
性能差异;行为差异;通常性能和行为会同时发生变化。
使用 MyString 及其实现:
问题:打破了抽象边界。
与Java一样:客户端代码中必须写死具体实现类的名字。
区别于Java:虽然Python的抽象类可以包含构造函数,但直接调用具体类依然会强耦合。
由于Python的动态特性,除非严格使用装饰器和类型提示,否则很难在静态阶段保证不同的实现类都具有相同的初始化参数。
在Python的优雅解法:
工厂函数——相比于Java冗长的工厂类,Python常用极简的工厂函数来隐藏具体类的实例化过程。
依赖注入——Python中“类”本身也是变量,可直接作为参数传递,从而在不暴露具体名字的情况下完成实例化。
默认方法VS抽象基类普通方法与混入类:
Python中没有绝对的接口与实现类的物理隔离。
在Python中如何实现default效果?
只需在基类中直接编写普通方法,不加抽象装饰器,子类就会自动基础,完全等同于default方法的效用。
Python增量扩展的高阶玩法——混入类。
Java利用default方法增量式地增加功能。
Python推崇混入类设计模式。借助多重继承,像积木一样无缝、解耦地为目标类“混入”额外功能,而不破坏主继承树。
重写:
可重写方法与严格继承:
可重写方法——允许重写的方法,python中默认如此。
严格继承——子类只能添加新方法,无法重写超类中的方法。
覆盖/重写:方法重写是一种语言特性,它允许子类(subclass)为已经由其超类(superclass/父类)提供的方法提供特定的实现。
重写的函数:相同的名称、相同的参数(签名/signature)以及相同的返回类型。
实际执行时调用哪个方法,运行时决定。
判定规则:如果使用父类对象调用方法,则执行父类中的版本;如果使用子类对象调用方法,则执行子类中的版本。
抽象类:
抽象方法和抽象类:
抽象方法——只有函数签名但没有实现体。
抽象类——包含至少一个抽象方法的类。
接口——一种只有抽象方法的特殊抽象类。
具体类-->抽象类-->接口
三种多态:
特殊多态:一个函数根据有限范围内指定的类型及其组合,表现出不同且可能完全不同的实现,通过函数重载支持。
参数化多态:编写代码时不提及任何特定类型,透明地应用于任何数量的新类型,泛型
(generics) 或 泛型编程 (generic programming)。
子类型多态/包含多态:一个名字可以代表许多不同类的实例,而这些类都关联于某个共同的超类(父类)。
特殊多态/重载:
多态:同一个函数名,根据传入参数的类型不同,执行完全不同的逻辑。Python原生不支持像Java那样写好几个同名函数,但我们可以用内置的@singledispatch完美实现。
重载:多个方法具有同样的名字,但有不同的参数列表或返回值类型。
重载的价值:方便客户端调用,客户端可用不同的参数列表调用同样的函数。
对重载函数的调用将根据调用的上下文运行特定的实现,从而允许一个函数调用根据上下文执行不同的任务。在
Python 中这个不成立!!!Python 均在运行时处理。
重载是一种静态多态——根据参数列表进行最佳匹配。静态类型检查。在编译阶段时决定要具体执行哪个方法。
重载的规则:被重载的函数必须在 参数个数 (Arity) 或 数据类型 (Data types)
上有所不同。
必须改变:不同的参数列表。
可以改变:返回值类型,访问修饰符,异常声明
一个方法可以在同一个类内重载,也可以在子类中重载。在 Python
中这个不成立!!!
参数化多态与泛型编程:
参数化多态:当一个函数能够一致地(uniformly)作用于一系列类型时,就获得了参数化多态;这些类型通常表现出某种共同的结构。
泛型编程:数据类型和函数是基于“稍后指定(types
to-be-specified-later)”的类型编写的,然后在需要时根据作为参数提供的特定类型进行实例化。
在Python中:天生就是泛型,且现代Python提供了严格的泛型类型提示。Python是动态类型的,变量没有固定类型。所以可以写一个函数,它天生就能接收任何类型,这就是一种极端的“泛型”。
子类型多态:
子类型:B是A的子类型”意味着“每一个B都是一个A。
子类型的静态检查:虽然编译器会检查方法名和参数,但它无法检查我们是否在逻辑层面弱化了规约。
编译器无法察觉的“违约”行为:加强前置条件;弱化后置条件;弱化接口向客户端承诺的任何保证。
子类型的规约不能弱化超类型的规约。
不可变类的优点:
简单性;天生线程安全;可自由共享;无需防御性拷贝;优秀的构建基块。
何时使类不可变:除非有充分的理由不这样做,否则始终保持不可变。始终让小型的“值类(Value
classes)”不可变。
何时使类可变:当类代表状态会发生变化的实体时。如果类必须是可变的,也要“最小化可变性”。