How to decouple a class from it's attributes when the methods of the attributes need to modify state of the owning class?

给你一囗甜甜゛ 提交于 2021-01-29 15:38:01

问题


How do I decouple a class from its attributes when methods of the attributes need to modify the state of the owning class? Alternatively, how can I redesign the architecture so that this isn't a problem?

This question is a bit abstract, but I keep coming across this problem time and time again. I spend a lot of time designing my code base so that my classes are "high cohesion and low coupling", but then as the code evolves over time, they end up becoming more closely coupled.

I'll give the latest example I have been working on. I have a Crane class with 3 Axis that can be moved. The original design had only 1 class, Crane, and the business logic for moving each axis was repeated in the move_x, move_y, and move_z methods of the Crane. This was code duplication, so this functionality was encapsulated inside an Axis class, and then the crane class was composed of 3 of these, and now simply delegated the move_x, move_y, and move_z methods to the appropriate Axis. i.e:

import asyncio

class CraneAxis:
    
    def __init__(self):
        self.__position: float = 0.0

    @property
    def position(self):
        return self.__position

    @position.setter
    def position(self, value: float):
        self.__position = value
        print(f'new position: {value}')
    
    async def move(self, target_position: float):  

        dt=0.1
        crawl_velocity=0.5
        if target_position > self.position:
            while self.position < target_position:
                self.position += crawl_velocity * dt
                await asyncio.sleep(dt)
        else:
            while self.position > target_position:
                self.position -= crawl_velocity * dt
                await asyncio.sleep(dt)


class Crane:

    def __init__(self):
        self.x_axis = CraneAxis()
        self.y_axis = CraneAxis()
        self.z_axis = CraneAxis()

    async def move_x(self, target_position: float):
        await self.x_axis.move(target_position)

    async def move_y(self, target_position: float):
        await self.y_axis.move(target_position)

    async def move_z(self, target_position: float):
        await self.z_axis.move(target_position)

In this example, the Axis is decoupled from the Crane. I can test the Axis in isolation (i.e I do not need a Crane object to test the Axis), and changes to the Crane do not affect the Axis at all. The dependency is in one direction, the Crane depends on the Axis, but the Axis does not depend on the Crane.

We then decide that the Crane needs a status attribute that represents if any axis is currently in motion, and also it needs a way to cancel any movement.

The status attribute and the state that signals that an abort has been requested both belong to the crane, but they will need to be modified by the method belonging to the axis. I originally passed these in as parameters to the Axis move() method: i.e:

async def move(self, target_position: float, status: CraneStatus, mode: CraneMode):  

CraneStatus and CraneMode are enums. This is slightly more coupled to the Crane, but still pretty decoupled as it could be used by anything as long as you pass it a CraneStatus and a CraneMode. i.e it does not care about any implementation details, it just needs these simple things and it uses them directly.

But as I am using python these attributes would be immutable within the move() method, and if I tried to change the value from within the function, I would instead just create a new instance. So instead I passed the owning crane into the Axis move() method. i.e:

import asyncio
from enum import IntEnum

class CraneStatus(IntEnum):
    BUSY=0,
    READY=1

class CraneMode(IntEnum):
    READY=0,
    MOVE=1,
    ABORT=2

class CraneAxis:
    
    def __init__(self):
        self.__position: float = 0.0

    @property
    def position(self):
        return self.__position

    @position.setter
    def position(self, value: float):
        self.__position = value
        print(f'new position: {value}')
    
    async def move(self, target_position: float, owner: 'Crane'):  
        if owner.crane_status == CraneStatus.BUSY:
            return
        owner.crane_status = CraneStatus.BUSY
        owner.crane_mode = CraneMode.MOVE

        dt=0.1
        crawl_velocity=0.5
        if target_position > self.position:
            while (self.position < target_position
            and owner.crane_mode != CraneMode.ABORT):
                self.position += crawl_velocity * dt
                await asyncio.sleep(dt)
        else:
            while (self.position > target_position
            and owner.crane_mode != CraneMode.ABORT):
                self.position -= crawl_velocity * dt
                await asyncio.sleep(dt)

        owner.crane_status = CraneStatus.READY
        owner.crane_mode = CraneMode.READY   

class Crane:

    def __init__(self):
        self.__crane_status = CraneStatus.READY
        self.__crane_mode = CraneMode.READY
        self.x_axis = CraneAxis()
        self.y_axis = CraneAxis()
        self.z_axis = CraneAxis()


    @property
    def crane_status(self):
        return self.__crane_status

    @crane_status.setter
    def crane_status(self, value: CraneStatus):
        self.__crane_status = value
        print(f'new crane status: {value}')

    @property
    def crane_mode(self):
        return self.__crane_mode

    @crane_mode.setter
    def crane_mode(self, value: CraneMode):
        self.__crane_mode = value
        print(f'new crane mode: {value}')

    async def move_x(self, target_position: float):
        await self.x_axis.move(target_position, self)

    async def move_y(self, target_position: float):
        await self.y_axis.move(target_position, self)

    async def move_z(self, target_position: float):
        await self.z_axis.move(target_position, self)

    def abort(self):
        self.crane_mode = CraneMode.ABORT

At this point, I noticed something that keeps happening in my codebases. Often when I use composition, I end up passing an owner parameter into the methods (or the constructor) of the object that is being used to compose the owning class. i.e in this example, the Axis now needs to be passed the Crane object. I have lost the decoupling. I now need a Crane object to test the Axis, and the Axis is sensitive to changes in the Crane. The dependency is in two directions, the Crane depends on the Axis, and the Axis depends on the Crane.

Perhaps this isn't a problem at all, and having these classes coupled is no big deal. But I have been taught that tight coupling is bad. Is this the case?

If I did want to decouple the Axis and the Crane, what would be the best way to go about this?

Thanks!

edit: Just to make it clear, this is a question about code quality and maintainability, and not about getting something to work. The code in the above examples behave exactly how I want them to, I just want to make the implementation better. Also, I am aware that python is dynamically typed, and I have used it in a statically typed way in the above examples, but I also have this problem in statically typed languages and would like a solution that I can use in any language. Also, for this project, we have decided to type check the code base with MyPy, and are trying to use things in a more strongly typed way to try and avoid bugs.


回答1:


You do this by creating interfaces to abstract the dependencies from the concrete class. So instead of passing a Crane into the Axis, you pass in a Moveable in which Crane subclasses. Now you can create separate test stubs that don't require Cranes, and don't care about the fact you are using Moveable Cranes.

To make it more linguistically separated, I'd change CraneStatus to MoveableStatus and CraneMode to MoveableMode.




回答2:



I have found a solution that completely decouples the Axis from the Crane. I need to move any dependencies that the Axis uses from the Crane into the Axis itself.

In the second code snippet in the original question, the Axis looks at the Crane to see if an abort has been requested, and also tells the Crane that something is now moving so it should become BUSY.

Instead, we should encapsulate the ability to abort a move and to signal if it is BUSY inside the Axis class itself. Why should the Axis need a Crane to do these things, it should be able to do them on it's own.

The Crane can then delegate its abort method to the Axis, and can also watch the axis to see when it becomes BUSY, and make itself BUSY when it is notified that this has happened. This can be done with the observer pattern.

As an added benefit, this has removed the "call by reference" from the Axis move() method. i.e, we used to pass in a parameter that would be changed within the function, and that change persists in the parent scope. This is considered an anti-pattern. The new design does not use "call by reference":

import asyncio
from enum import IntEnum
from typing import Callable, List

class AxisStatus(IntEnum):
    BUSY=0
    READY=1

class CraneStatus(AxisStatus): pass

class AxisMode(IntEnum):
    READY=0
    MOVE=1
    JOG=2
    ABORT=3

class CraneMode(AxisMode): pass

class Axis:
    
    def __init__(self):
        self.__position: float = 0.0
        self.__axis_status = AxisStatus.READY
        self.axis_status_observers: List[Callable[[AxisStatus], None]] = []
        self.__axis_mode = AxisMode.READY
        self.axis_mode_observers: List[Callable[[AxisMode], None]] = []

    @property
    def position(self):
        return self.__position

    @position.setter
    def position(self, value: float):
        self.__position = value
        print(f'new position: {value}')

    @property
    def axis_status(self):
        return self.__axis_status

    @axis_status.setter
    def axis_status(self, value: AxisStatus):
        self.__axis_status = value
        print(f'new axis status: {value}')
        #notify observers
        for cb in self.axis_status_observers:
            cb(value)

    @property
    def axis_mode(self):
        return self.__axis_mode

    @axis_mode.setter
    def axis_mode(self, value: AxisMode):
        self.__axis_mode = value
        print(f'new axis mode: {value}')
        #notify observers
        for cb in self.axis_mode_observers:
            cb(value)

    async def move(self, target_position: float):  
        if self.axis_status == AxisStatus.BUSY:
            return
        self.axis_status = AxisStatus.BUSY
        self.axis_mode = AxisMode.MOVE

        dt=0.1
        crawl_velocity=0.5
        if target_position > self.position:
            while (self.position < target_position
            and self.axis_mode != AxisMode.ABORT):
                self.position += crawl_velocity * dt
                await asyncio.sleep(dt)
        else:
            while (self.position > target_position
            and self.axis_mode != AxisMode.ABORT):
                self.position -= crawl_velocity * dt
                await asyncio.sleep(dt)

        self.axis_status = AxisStatus.READY
        self.axis_mode = AxisMode.READY   

    def abort(self):
        self.axis_mode = AxisMode.ABORT   

class Crane:

    def __init__(self):

        self.__crane_status = CraneStatus.READY
        self.__crane_mode = CraneMode.READY

        self.x_axis = Axis()
        self.y_axis = Axis()
        self.z_axis = Axis()

        #whenever an axis status or mode changes, reflect this change in the crane

        def axis_status_observer(axis_status: AxisStatus):
            #if any axis is busy, crane is busy
            if axis_status == AxisStatus.BUSY:
                self.__crane_status = CraneStatus.BUSY
            #if all axis are ready, crane is ready
            if all([axis.axis_status == AxisStatus.READY for axis in [self.x_axis, self.y_axis, self.z_axis]]):
                self.__crane_status = CraneStatus.READY

        self.x_axis.axis_status_observers.append(axis_status_observer)

        def axis_mode_observer(axis_mode: AxisMode):
            #only one axis can be in any other mode than READY at a time
            #the crane mode is whatever this axis mode is
            self.__crane_mode = CraneMode(axis_mode) 

        self.x_axis.axis_mode_observers.append(axis_mode_observer)

    @property
    def crane_status(self):
        return self.__crane_status

    @crane_status.setter
    def crane_status(self, value: CraneStatus):
        self.__crane_status = value
        print(f'new crane status: {value}')

    @property
    def crane_mode(self):
        return self.__crane_mode

    @crane_mode.setter
    def crane_mode(self, value: CraneMode):
        self.__crane_mode = value
        print(f'new crane mode: {value}')

    async def move_x(self, target_position: float):
        await self.x_axis.move(target_position)

    async def move_y(self, target_position: float):
        await self.y_axis.move(target_position)

    async def move_z(self, target_position: float):
        await self.z_axis.move(target_position)

    def abort(self):
        self.x_axis.abort()
        self.y_axis.abort()
        self.z_axis.abort()

 


来源:https://stackoverflow.com/questions/65599788/how-to-decouple-a-class-from-its-attributes-when-the-methods-of-the-attributes

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!