• python 视角下的 6 大程序设计原则


    众所周知,python 是面向对象的语言。

    但大多数人学习 python 只是为了写出“能够实现某些任务的自动化脚本”,因此,python 更令人熟知的是它脚本语言的身份。

    那么,更近一步,如果使用 python 实现并维护一个大的项目,进行软件开发,简单的串并联脚本就难以满足日常繁重的维护需求。

    此时需要借鉴其他编程语言的精化,比如说 JAVA 语言中的面向对象的思想。通过将核心功能抽象化并原子化,赋予 python 脚本更高的可维护性。

    这篇文章是我读 秦小波 老师的 《设计模式之禅》有感。虽然书里全篇都是Java语言,看不懂,但书中提到的 6 大程序设计原则还是让我受益匪浅。下面我将分别介绍这 6 大原则,并将尝试给出正反例帮助大家理解。

    单一职责原则

    简单理解就是,一个类、或者一个函数,只能承担一项职责。
    这一设计思想指导我们,在设计业务逻辑时,要尽可能的剥离不相干的业务,降低业务逻辑的颗粒度,进而实现“原子函数”、“原子类”。

    如何有效的鉴别“不相干”的业务,成为了履行单一职责原则的核心难点。一种常见的解释是:类的变更仅由一件业务决定。

    一个违反了该原则的案例可以是:

    class Tiger:
        def walk(self):
            print('walk')
            
        def swim(self):
            print('swim')
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    可以看到,老虎类包含了两个方法,走和游泳。然而,游泳的业务和走是相对独立的。所以说, Tiger 类将受到两种变更因素的影响,违背了单一职责的原则。

    一种解决方案是将二者剥离,如下:

    class Walk_animal():
        def walk(self):
            print('walk')
    
    
    class Swim_animal():
        def swim(self):
            print('swim')
    
    
    class Tiger(Walk_animal, Swim_animal):
        pass
    
    
    a_tiger = Tiger()
    a_tiger.swim()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    Tiger 类通过继承父类,获得游泳和走的能力,这种设计很好的遵守了单一职责原则,但凭空增加了两个类,因此在大多数业务实践中,我们也不会这样做。

    秦小波 老师的 《设计模式之禅》中是这样解决的:Java 环境下,面向对象的精髓是面向接口编程。

    我们通过增加接口来规范类的功能,虽然 python 原生不支持接口,但我们可以使用抽象类来实现接口类。相关教程:python–接口类与抽象类 - 知乎 (zhihu.com)

    from abc import abstractmethod, ABCMeta
    
    
    class Walk_animal(metaclass=ABCMeta):
        @abstractmethod
        def walk(self): pass
    
    
    class Swim_animal(metaclass=ABCMeta):
        @abstractmethod
        def swim(self): pass
    
    
    class Tiger(Walk_animal, Swim_animal):
        def walk(self):
            print('walk')
    
        def swim(self):
            print('swim')
    
    
    a_tiger = Tiger()
    a_tiger.swim()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    这种实现方式看似更复杂,但在大型项目中,可以提高代码的可读性和可维护性。

    里氏替换原则

    该原则规定了继承操作的要点,通俗解释的话就是,父类能够使用的地方,将父类替换为子类,程序也能正常运行。

    具体来说:

    1. 子类必须完全使用父类的方法;
    2. 子类可以有自己的个性;
    3. 覆写方法时,子类对应的参数类型范围只能比父类宽;
    4. 覆写方法时,子类对应的输出结果的类型只能比父类窄;

    可以看到,里氏替换原则关注的焦点是覆写父类方法。因为正常的继承操作是一定满足上述要求的,如下:

    class Walk_animal():
        def walk(self):
            print('walk')
    
    
    class Swim_animal():
        def swim(self):
            print('swim')
    
    
    class Tiger(Walk_animal, Swim_animal):
        pass
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    那么一种违反里氏替换原则的案例可以是:

    class do_plus:
        def plus(self, addon):
            return addon + 1
    
    class math(do_plus):
        def plus(self, addon):
            return addon + 2
    
    a = do_plus()
    assert a.plus(1) == 2
    
    b = math()
    assert b.plus(1) == 2
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    会报错:

    Traceback (most recent call last):
      File "H:/123123123/main.py", line 65, in <module>
        assert b.plus(1) == 2
    AssertionError
    
    • 1
    • 2
    • 3
    • 4

    可见,math 的覆写没有通过父类的测试案例。上述代码改为:

    class do_plus:
        def plus(self, addon):
            return addon + 1
    
    class math(do_plus):
        def plus_2(self, addon):
            return addon + 2
    
    a = do_plus()
    assert a.plus(1) == 2
    
    b = math()
    assert b.plus(1) == 2
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    通过了测试案例,同时子类也有了自己的个性,很好的满足了里氏替换原则的前两条。

    事实上,第一条,子类必须完全使用父类的方法 更多是针对接口类的继承,接口类中的抽象方法是一定要继承的。

    另一方面,后两条,则可以通过下面一段代码展示:

    from typing import Union
    
    class do_plus:
        def plus(self, addon: int) -> float:
            return float(addon) + 1
    
    class math(do_plus):
        def plus(self, addon: Union[int, float]) -> Union[int, float]:
            return addon + 1
    
    
    a = do_plus()
    a_results = a.plus(1)
    assert type(a_results) == float
    
    b = math()
    b_results = b.plus(1)
    assert type(b_results) == float
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    运行后,报错信息如下:

    Traceback (most recent call last):
      File "H:/123123123/main.py", line 70, in <module>
        assert type(b_results) == float
    AssertionError
    
    • 1
    • 2
    • 3
    • 4

    可以看到,math 拓宽了 do_plus 类的应用范围,输入类型增加了浮点型。这点是正确的,符合里氏替换原则的第三条。但输出时的类型也拓宽了,因此没有通过原有测试案例的类型检查。

    解决办法就是约束输出的变量类型,如下:

    from typing import Union
    
    class do_plus:
        def plus(self, addon: int) -> float:
            return float(addon) + 1
    
    class math(do_plus):
        def plus(self, addon: Union[int, float]) -> float:
            return float(addon) + 1
    
    
    a = do_plus()
    a_results = a.plus(1)
    assert type(a_results) == float
    
    b = math()
    b_results = b.plus(1)
    assert type(b_results) == float
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    总的来说,检验里氏替换原则的最简单方法就是,子类能够通过所有情况下的父类的测试案例。

    依赖倒置原则

    该原则在面向对象的语境下指:

    1. 高层模块不应该依赖于低层模块,二者均应该依赖其抽象。
    2. 抽象不应该依赖细节,细节应该依赖于抽象。

    所谓抽象就是指接口类,细节就是实例化的接口类。

    依赖倒置原则指,面向对象编程的核心是面向接口编程,即不同类之间的依赖耦合在接口层上就已经实现。

    一种不满足依赖倒置原则的情况可以是:

    class ase_optimizer():
        def __init__(self):
            self.name = 'ASE'
        def ase_check(self):
            print('ase check')
    
    class xtb_optimizer():
        def __init__(self):
            self.name = 'xTB'
        def xtb_check(self):
            print('xtb check')
    
    class checker():
        def __init__(self, optimizer):
            self.optimizer = optimizer
        def check(self):
            if self.optimizer.name == 'ASE':
                self.optimizer.ase_check()
            elif self.optimizer.name == 'xTB':
                self.optimizer.xtb_check()
    
    a = checker(ase_optimizer())
    a.check()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    这段代码中,高层模块 checker 通过调用低层模块 optimizer 完成业务逻辑,没有用到接口类,三个类均为细节,因此我们说,这是以细节为基础搭建起来的架构。

    一个很显然的弊端在于:

            if self.optimizer.name == 'ASE':
                self.optimizer.ase_check()
            elif self.optimizer.name == 'xTB':
                self.optimizer.xtb_check()
    
    • 1
    • 2
    • 3
    • 4

    如果需要增加优化器,我们要修改 checker 类,如下:

            if self.optimizer.name == 'ASE':
                self.optimizer.ase_check()
            elif self.optimizer.name == 'xTB':
                self.optimizer.xtb_check()
            elif self.optimizer.name == 'gauss':
                self.optimizer.gauss_check()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    显然,低层业务逻辑的变动,惊动了高层业务逻辑,因此我们说,违反了依赖倒置原则。

    一种改进方法如下:

    from abc import abstractmethod, ABCMeta
    
    
    class opt_check(metaclass=ABCMeta):
        @abstractmethod
        def check(self): pass
    
    
    class ase_optimizer(opt_check):
        def __init__(self):
            self.name = 'ASE'
    
        def check(self):
            print('ase check')
    
    
    class xtb_optimizer(opt_check):
        def __init__(self):
            self.name = 'xTB'
    
        def check(self):
            print('xtb check')
    
    
    class checker():
        def __init__(self, optimizer):
            self.optimizer = optimizer
    
        def check(self):
            self.optimizer.check()
    
    
    a = checker(ase_optimizer())
    a.check()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    可以看到,业务逻辑大大简化,此时如果需要新增逻辑,如下所示:

    from abc import abstractmethod, ABCMeta
    
    
    class opt_check(metaclass=ABCMeta):
        @abstractmethod
        def check(self): pass
    
    
    class ase_optimizer(opt_check):
        def __init__(self):
            self.name = 'ASE'
    
        def check(self):
            print('ase check')
    
    
    class xtb_optimizer(opt_check):
        def __init__(self):
            self.name = 'xTB'
    
        def check(self):
            print('xtb check')
    
    
    class gauss_optimizer(opt_check):
        def __init__(self):
            self.name = 'gauss'
    
        def check(self):
            print('gauss check')
    
    
    class checker():
        def __init__(self, optimizer):
            self.optimizer = optimizer
    
        def check(self):
            self.optimizer.check()
    
    
    a = checker(ase_optimizer())
    a.check()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42

    可以看到,虽然我们新增了一个低层类,但在高层 checker 上完全看不到影响。

    这就是面向接口编程的奇妙之处:我们通过接口,预先指定了方法 check,因此高层在调用时,不需要了解具体的低层实现,只要知道低层,有这个 check 方法就可以完成业务逻辑的搭建。

    这样带来的好处是:

    1. 利于并行开发。比如说10个人开发10个子类,如果按照第一种实现方式,每一个人完成开发后都需要改动高层类,这会带来潜在的风险。
    2. 高层类和低层类相对解耦,可以分别进行测试。

    然而,做到这一步并没有完全实现依赖倒置原则。因为该原则第一条指出,高层模块也要依赖于抽象。

    我们上面的改动只是实现了,低层模块依赖于抽象(接口),高层模块并没有依赖接口。我们只是利用接口简化了部分业务逻辑。

    事实上,这依然是一种短视的行为。

    因为我们无法预知,现在所谓的高层模块会不会在未来,也变成了庞大业务逻辑中的一个低层模块。

    真正满足依赖倒置原则的实现如下:

    from abc import abstractmethod, ABCMeta
    
    
    class opt_check(metaclass=ABCMeta):
        @abstractmethod
        def opt_check(self): pass
    
    
    class chk_check(metaclass=ABCMeta):
        @abstractmethod
        def __init__(self, optimizer: opt_check): pass
    
        @abstractmethod
        def chk_check(self): pass
    
    
    class ase_optimizer(opt_check):
        def opt_check(self):
            print('ase check')
    
    
    class checker(chk_check):
        def __init__(self, optimizer: opt_check):
            self.optimizer = optimizer
    
        def chk_check(self):
            self.optimizer.opt_check()
    
    
    class auto_checker():
        def check(self, a_checker: checker):
            a_checker.chk_check()
    
    
    a = auto_checker()
    a.check(a_checker=checker(optimizer=ase_optimizer()))
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36

    我们首先通过两个接口 opt_checkchk_check ,搭建了清晰的业务逻辑框架。

    from abc import abstractmethod, ABCMeta
    
    
    class opt_check(metaclass=ABCMeta):
        @abstractmethod
        def opt_check(self): pass
    
    
    class chk_check(metaclass=ABCMeta):
        @abstractmethod
        def __init__(self, optimizer: opt_check): pass
    
        @abstractmethod
        def chk_check(self): pass
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    剩余部分只是对抽象的具体实现,即细节

    class ase_optimizer(opt_check):
        def opt_check(self):
            print('ase check')
    
    
    class checker(chk_check):
        def __init__(self, optimizer: opt_check):
            self.optimizer = optimizer
    
        def chk_check(self):
            self.optimizer.opt_check()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    可以看到,细节实现时,按照抽象的指示,填补空缺即可。此外,更高层调用高层时,依然满足依赖倒置原则:

    class auto_checker():
        def check(self, a_checker: checker):
            a_checker.chk_check()
    
    • 1
    • 2
    • 3

    因为 checker 模块已经被定死了。

    至此,我们完全实现了依赖倒置原则,搭建了以 抽象 为框架的业务逻辑。这部分是完全独立的,可以看作业务实现的草稿,更像是论文写作的大纲。

    总的来说,依赖倒置原则对编程人员的架构能力要求很高,面向接口编程也因此成为了面向对象编程的精髓。

    接口隔离原则

    在面向接口的程序设计理念下,我们需要先确定业务逻辑框架,再去实现细节。因此,在设计接口时,其颗粒度要尽可能的小,不然抽象类的实例会实现一些没有必要的功能。

    from abc import abstractmethod, ABCMeta
    
    
    class teach(metaclass=ABCMeta):
        @abstractmethod
        def teach_eng(self): pass
    
        @abstractmethod
        def teach_math(self): pass
    
    
    class teacher(teach):
        def teach_eng(self):
            print('teach english')
    
        def teach_math(self):
            print('teach math')
    
    
    class eng_class():
        def start_class(self, eng_teacher: teacher):
            eng_teacher.teach_eng()
    
    
    class math_class():
        def start_class(self, math_teacher: teacher):
            math_teacher.teach_math()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    可以看到,实际业务逻辑中,英语课并不需要老师教数学。显然,我们 teacher 接口的设计是失败的。

    将其拆分为两个接口,如下:

    from abc import abstractmethod, ABCMeta
    
    
    class eng_teach(metaclass=ABCMeta):
        @abstractmethod
        def teach_eng(self): pass
    
    class math_teach(metaclass=ABCMeta):
        @abstractmethod
        def teach_math(self): pass
    
    
    class eng_teacher(eng_teach):
        def teach_eng(self):
            print('teach english')
    
    
    class math_teacher(math_teach):
        def teach_math(self):
            print('teach math')
    
    
    class eng_class():
        def start_class(self, eng_teacher: eng_teacher):
            eng_teacher.teach_eng()
    
    
    class math_class():
        def start_class(self, math_teacher: math_teacher):
            math_teacher.teach_math()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    上述为两个接口的简易拆分,值得注意的是,接口并不是颗粒度越小越好,因为这另一方面会无端增加接口数量。

    事实上,在复杂业务逻辑的实践中,我们应该把相似功能的捆绑在一起,在保证接口纯洁性的同时,尽量降低接口数量。

    下图为一种接口设计:

    在这里插入图片描述
    原博客

    该设计中,高层模块 A 调用了低层模块 B,而低层模块 B 通过接口 I1, I2 实现了方法 1,2,3

    通过整合相似功能,这种设计很好的体现了接口隔离原则。

    总的来说,接口隔离原则理念上和最开始的单一职责原则类似,都在讲原子化。二者的区别在于,单一职责原则的对象是类,是实例。接口隔离原则面向的是接口,因为“面向接口编程”的理念被单独列出。

    迪米特原则

    该原则指出:一个对象应该对其他对象了解的越少越好。

    一个违反了迪米特原则的例子如下:

    class products():
        def __init__(self, year):
            self.year = year
    
    
    class employee():
        def query_products(self, query_year: int):
            print(f'{query_year} has products 100')
    
    
    class Boss():
        def query(self, a_employee: employee, products_year: int):
            prod = products(year=products_year)
            a_employee.query_products(query_year=prod.year)
    
    
    b = Boss()
    employee_A = employee()
    b.query(a_employee=employee_A, products_year=2000)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    程序设计的本意是,老板通过员工获知产品信息。

    老板和员工是直接朋友,然而,老板方法里冒出了 products 类,并与其产生了交流。

    修改后如下所示:

    class products():
        def __init__(self, year):
            self.year = year
    
    
    class employee():
        def query_products(self, query_year: int):
            prod = products(year=query_year)
            print(f'{prod.year} has products 100')
    
    
    class Boss():
        def query(self, a_employee: employee, products_year: int):
            a_employee.query_products(query_year=products_year)
    
    
    b = Boss()
    employee_A = employee()
    b.query(a_employee=employee_A, products_year=2000)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    老板告知员工查询哪一年,员工据此查库存,符合我们的设计逻辑。

    总的来说,迪米特法则的理念是类间解耦,弱耦合。但这会增加大量的跳转类。比如上例中,employee 就是一个跳转类,事实上,查库存的操作完全可以有老板类自己完成。所以在采用这一原则时,需要反复权衡,既要让结构清晰,又要做到高内聚低耦合。

    开闭原则

    开闭原则是面向对象编程的极致追求。它要求我们,软件开发过程中对扩展开放,对修改关闭。

    也就是说,已经写好的代码,不能再动。如果有新的业务需求,只要在原有基础上拓展即可,因此对程序架构能力提出了极高的要求。

    实现开闭原则的重要途径就是面向接口编程,如依赖倒置原则那一节所举的例子,在良好定义接口的前提下,我们可以轻松完成拓展,但又不会影响现有业务逻辑,最大程度上降低了增量开发带来的风险。

    开闭原则并没有像前5条原则一样,有着清晰的定义和指导。它更多的是一种口号,是架构师的终极目标。

  • 相关阅读:
    期货十三篇 第十三篇 修行篇
    安全协议之-TLS握手过程详解
    28个团队建设游戏
    字节序转换的问题详细讲解:将unsigned int val字节序逆转
    C语言项目实战——贪吃蛇
    六、数组及其操作《2022 solidity8.+ 版本教程到实战》
    将transformers的tokenizer处理之后(如BPE)的序列映射回输入序列
    Java学习笔记(十八)
    mysql中的update的常用sql
    广州华锐视点:VR3D技术在中学物理实验中的应用
  • 原文地址:https://blog.csdn.net/frank_haha/article/details/127949025