📜 ⬆️ ⬇️

Design and architecture in the OP. Part 2

Ascending design in OP. The idea is the basis of good design. Antipatterns in haskell

Some theory

In the last part, we built a high-level application architecture. We defined subsystems and their connections, and also divided the program into three layers: Application, Game Logic, Views. Logically, the next stage is the design of the application. In terms of importance, this stage is not inferior to the previous one, since it is during the design that we must support all functional requirements, determine the actual structure of the subsystems, describe the main technical problems, apply any typical solutions, or come up with others. But first we will try to answer the question: what is it, good software design? By what criteria do we determine the "good" design?

Here are three possible answers:

  1. Completeness . A good design is one in which all requirements are taken into account with minimal actual costs for implementation, maintenance and introduction of new functionality.
  2. Performance . A good design is one in which development goals are achieved.
  3. Simplicity A good design is one in which the objective complexity of the domain is successfully mapped to simple working concepts.

From the latter follows the consequence that a good design should be easy to explain in 10 minutes without sacrificing accuracy and completeness (S. Teplyakov, " About Design ").
')
The above can be illustrated with a diagram:

Chart: Goals-Requirements-Simplicity


Here the outer triangle describes an ideal hypothetical system in which each of the three criteria is 100% satisfied. The red triangle shows the current state of affairs. The center of gravity of the outer triangle is the zero point. Note that when the criterion is shifted to the "negative" side, we see various problem situations that should not be in any way, since this is more like sabotage.

Examples of problem situations:

  1. Requirements are not met, but the development goals have been achieved: it means that the team imitates vigorous activity in the pursuit of bonuses or because of a different motivation. The team has achieved its own internal goals; they do not coincide with the goals of the market or company, and the product does not do what is expected of it.
  2. The goals were not achieved, but all the requirements were met: for two years we made the Tic-Tac-Toe game, it’s all good, you can even rob the cows there ... But we didn’t have time for the contest - it ended a long time ago, and now we are out of work.
  3. Requirements are met, goals are achieved, the code is quickly written, but ... the framework being the basis for the design is overly complicated, lost, written under the current situation, and only two people from the team understand how it works.

Diagrams of problem situations

Now it became clear why good design is so important. But none of the above criteria says anything about what needs to be done to get it. In the literature there is usually no answer to this question either; You can find general advice like: “follow the principles of such and such”, “use patterns”, “model the process of the name of that”. The tips are right: you can get a great code. There is only one problem: code is not design. Even with the code itself, there is no guarantee that all three criteria for the “goodness” of the design will be satisfied. Especially - to make the design simple and complete.

Where does this come from? Take for example the end-to-end modeling process using UML. The developer reveals the following diagrams: Use Cases → User Scenarios → Sequence Diagrams → Object Diagrams → Communication Diagrams → Class Diagrams →…. At the same time, the terms of the domain become classes, modules, packages and interactions (for example, like this or this way ). The goal is simple: identify all entities and actions with them; that is, to develop a system "in the forehead," as we see and understand. We simply translate our knowledge of the system into the model of this system, and it is impossible to say in advance how many entities there will be, how many elements, and what the network of connections will be - even taking into account any generalizations. It is very difficult to introduce DSL, since a domain-specific language does not directly follow from one diagram. Is the model simple and complete? Unknown.

At the top level of abstraction, our world is imperative. We always have a state, there is always a sequence of actions with this state. “Going to the crossroads, look left, then right, and if there are no cars, move on.” Transferring this level of abstraction one-to-one into the design, we get an imperative solution that does not fit the functional paradigm. In contrast, deeper levels of abstraction of the world are not imperative, because they are based on properties and laws, and not on entities and actions. "You can cross the road when there is no danger." It would be a mistake to use the imperative approach to describe the physical world: “If in the next second the earth is not yet reached, increase the speed of fall by the value of g”. When applied to AF, this problem is only exacerbated. We will see further that the “head on” approach leads to serious conceptual problems. The code is not idiomatic, hard-to-manage, it becomes overcomplicated, and all the benefits of AF are simply lost. But if we decompose the subject area into properties and laws, we will get a solution that fits perfectly with the OP.

We fix this in the fourth criterion of "goodness" of design:

4. Fundamentality . A good design is one that is based on the right idea, which describes the subject area at a fundamental level. In other words, “what it is,” and not “how it works.”

Chart: The right idea is the basis of a good design.


Right ideas are important everywhere. Any scientific concept is the essence of a certain idea from which all the consequences are derived. The basis of the Riemann integral is the idea of ​​the sum of the infinitesimal. Einstein's GTR is based on the equivalence of inertial and gravitational masses. At the heart of the Scala language is the position that everything is an object, and any object is a function. And in Lisp, everything is lists. The idea plays a crucial role in building software design.

For example, consider parsing ini-file. What is the difference between good and bad design? In the first case, the idea is applied that the configuration and state machine can exist separately. As a result, the state machine can work with any configuration, irrespective of what it does. In the second case, these entities are mixed, the code looks unprincipled, it cannot be reused. And here is what a solution to the same problem might look like with the use of another idea: Parsec , Boost.Spirit . We used combinatorial DSL, and simply transferred the properties of the ini-file - its grammar - into the code.

Coming up with DSL, we will implement the idea of ​​how you can map the complexity of the subject area to an understandable formal language. Thanks to the right idea, we can encode not the entities as such, but the sets of their properties; then any entity can be expressed by combining properties. At the same time, there will be obviously less properties, and fewer interactions (by the way, this is what physicists are striving for in the search for the fundamental foundations of the Universe).

Thus, the fourth criterion allows us to come to a good design in practice. But this will already be a bottom-up design approach, starting from the lowest levels that we have learned from a good idea.

Some practice

Not to be unfounded, let's follow the evolution of design ideas for the game “The Amoeba World”. This is a strategic game about the world inhabited by amoebas. The player is invited once every 10 seconds to give orders to his colony of amoebae and then just watch how events will develop. Amoebas produce and absorb energy, grow, fight for territory and resources. The resource is energy. The amoeba is almost entirely composed of plasma interspersed with organelles: the nucleus, which functions as a battery, the mitochondria, the energy sources, and the organelles for defense and attack. The world of the game is endless and is a square grid. In addition to amoebas, it has canyons, rivers, stones, sources of additional energy and other objects. The world has three layers: ground, air and underground, with its objects and laws.

What are the requirements for the content of the game?


In this case, I would like some polymorphism in the interaction of objects. How can this be implemented?

Frontal solution. Antipattern God ADT

The first idea that came to mind is to make a single universal algebraic data type for the objects of the game world, and to keep these objects in the Data.Map structure. Like this:
module GameLogic . Types where
import qualified Data . Map as m
data Item = Karyon ...
| Plasma ...
| Mitochondrion ...
| Stone ...

type World = M. Map Point Item
type OperatedWorld = World

The iterations of the world will be represented as a function of stepWorld. In it, we will go through each object on the map and call the function apply for it. In it, the value of the World type is the world at the last iteration, and in the value of the OperatedWorld type, the results of the apply function accumulate.
module GameLogic . Logic where
import GameLogic . Types
stepWorld :: World -> World
apply :: World -> Item -> OperatedWorld -> OperatedWorld

The solution looks simple and just as easy to implement. However, in the near future we will need some other functions, which will be compared with the sample of the Item type. And we will inevitably get into a problem situation: with each update of the Item type we will have to edit all these functions:
apply w ( Karyon ... ) ow = ...
apply w ( Plasma ... ) ow = ...
apply w ( Mitochondrion ... ) ow = ...
...
getEnergy :: Item -> Int
getEnergy ( Karyon e b c ) = e
getEnergy ( Mitochondrion e f g ) = e
getEnergy _ = error "getEnergy unsupported."

We tied too much to the data type Item. If we write a simple game like "Tic-tac-toe" or "Game of Life" - you can stop here. But in the case of The Amoeba World, this approach seems out of place. You need to come up with something else to remove pattern matching, add generosity and get rid of naive code.

Expand our requirements:


Well, now the requirements look more serious. Let's try to figure it out.

Antipattern "Existential Class Types"

To fulfill both the requirement of modularity and locality, we will have to make the module of a specific object contain everything necessary for interacting with other objects on the game map. At the same time, the above-mentioned GameLogic.Logic and GameLogic.Types modules should not change when a new module is added. To make this possible, consider the following design idea: each object on the map is a separate type (Plasma, Karyon) for which there is an activate function — the inheritor of the apply function.
- module GameLogic.Karyon:
activate :: Karyon -> Point -> World -> OperatedWorld -> OperatedWorld
- module GameLogic.Plasma:
activate :: Plasma -> Point -> World -> OperatedWorld -> OperatedWorld

These functions are generalized by the type class . Let's call it Active:
class Active i where
activate :: i -> Point -> World -> OperatedWorld -> OperatedWorld

Having this interface, we will go through all the objects of the map and activate them, no matter what type they are. In OOP languages, this is a common solution, and it works through dynamic dispatching, but if we try to adapt the World type to Haskell, we will have difficulty compiling.
type World = M. Map Point ????

We want to keep different types of objects in the Map container: Karyon, Plasma, or something else, and this is a problem, because Haskell collections are not heterogeneous. The problem is known , it has several solutions; we are interested in a solution based on an existential data type. In this article we confine ourselves to a brief statement of the idea and an explanation of why this is an anti-pattern . For more detailed explanations, see the article by Roman Dushkin “ Monomorphism, polymorphism and existential types ”.

To begin with, we will make the following blanks of the types of interest to us and implement the activate function for them.
module GameLogic . Plasma where
data Plasma = Plasma { plasmaPlayer :: Player }
instance Active Karyon where
activate ( Plasma pl ) = undefined

module GameLogic . Karyon where
data Karyon = Karyon { karyonPlayer :: Player
, karyonEnergy :: Energy }
instance Active Plasma where
activate ( Karyon pl e ) = undefined

Now you need to create a heterogeneous map of the world. To do this, we define the existential type-wrapper ActiveItem, which can already be stored in the world map:
- module GameLogic.Types:
{- # LANGUAGE ExistentialQuantification # -}
data ActiveItem = forall i . Active i => MkActiveItem i
type World = M. Map Point ActiveItem
packItem :: Active i => i -> ActiveItem
packItem = MkActiveItem

The ActiveItem type does not know anything about type i except that for the latter there is an instance of the type class Active. When we pack any object like Karyon or Plasma, we lose information about the type, but we can put it in any collection:
packedKaryon = packItem ( Karyon 1,100 )
packedPlasma = packItem ( Plasma 1 )
world = M. fromList [ ( Point 1 1 1 , packedKaryon )
, ( Point 1 1 2 , packedPlasma ) ]

Now you can go through the objects of the map and call for each its own version of activate something like this:
stepWorld world = M. foldrWithKey f M. empty world
where
f point ( MkActiveItem i ) operatedWorld = activate i point world operatedWorld

You can slightly reduce the record by defining an instance of the Active type class for ActiveItem:
instance Active ActiveItem where
activate ( MkActiveItem i ) = activate i
...
stepWorld world = M. foldrWithKey activate M. empty world
where
f point i = activate i point world

We have achieved the desired - we have spread the functionality of changing the card according to different types and modules. What is wrong with this code? The “Existential type class” pattern is useful if all you need to do with objects in the collection is to serialize them (the Show type class), render it on a 3D scene (the Figure type class with the render function is like in R. Dushkin’s article), or still somehow be used for unmutable purposes. The anti-pattern “Existential Class of Types” shows the inapplicability of OOP thinking in the functional world. Understanding map elements as objects, we inevitably come to the fact that we need new and new functions for working with them, and the type class becomes inconsistent, and other types of types appear. In the case of the game “The Amoeba World”, this practice led to the fact that I really wanted to somehow return from the “interface” Active another “interface” - the Interactable type class to make different objects interact in a generic way. (See eafd64de12 commit). Perhaps the solution to this OOP problem exists in Haskell, but the author could not find it, which is for the best, since this is a dead-end path.

Let-functions and antipattern "Object type"

Let-functions are unsupportable functions with a large number of arguments, let- and where-expressions. Let-functions themselves are not anti-patterns, but a sign that the current design does not give us other tools for expressing logic. Very often, let-functions are a functional wrapper over an imperative code — and this, as we have already found out, is not an idiomatic approach.

An example from “The Amoeba World” (commit 3a0500a217 ):
Hidden text
activatePieceGrowing :: Player -> Bounds -> Point -> Direction -> ( World , Annotations , Energy ) -> ( World , Annotations , Energy )
activatePieceGrowing _ _ _ _ actRes @ ( w , anns , 0 ) = actRes
activatePieceGrowing pl bounds p dir ( w , anns , e ) = case growPlasmaFunc of
Left ann -> ( w , anns ++ [ ann ] , e )
Right ( w ', anns' ) -> ( w ', anns ++ anns' , e - 1 )
where
growPlasmaFunc = growPlasma pl bounds p dir w

activatePiece :: Point -> Karyon -> Shift -> ( World , Annotations , Energy ) -> ( World , Annotations , Energy )
activatePiece _ ( KaryonFiller { } ) _ r = r
activatePiece p @ ( Karyon _ pl _ _ bound ) sh activationData | isCornerShift sh = let
actFunc = activatePieceGrowing pl [ bound p ] p ( subDirection2 sh )
. activatePieceGrowing pl [ bound p ] p ( subDirection1 sh )
in actFunc activationData
activatePiece p @ ( Karyon _ pl _ _ bound ) sh activationData = let
actFunc = activatePieceGrowing pl [ bound p ] p ( direction sh )
in actFunc activationData

Partially, the code can be improved by bringing immutable data into context with the help of the Reader monad, but this will not save imperativeness (commit 8df9b7a224 ):
Hidden text
activatePiece :: ActivationData -> R. Reader ActivationContext ActivationData
activatePiece actData = do
isCornerPiece <- askIsCornerPiece
if isCornerPiece then activateCornerPiece actData
else activateOrdinaryPiece actData

activatePieceGrowing :: Direction -> ActivationData -> R. Reader ActivationContext ActivationData
activatePieceGrowing _ actData @ ( w , anns , 0 ) = return actData
activatePieceGrowing dir actData @ ( w , anns , e ) = do
( pl , bounds , p ) <- askLocals
case growPlasma pl bounds p dir w of
Left ann -> return ( w , anns ++ [ ann ] , e )
Right ( w ', anns' ) -> return ( w ', anns ++ anns' , e - 1 )
where
askLocals = do
( ActivationContext k p _ ) <- R. ask
return ( karyonPlayer k , [ karyonBound k p ] , p )

The above code arose due to the fact that the entire design is based on object ATD Karyon, Border and others. “Object Type” is the main anti-pattern of the early design of the game “The Amoeba World”. Because of it, a bulky unsupported code with poor structure was born. The idea of ​​object types obviously derives from OOP thinking, which leads to dire consequences when we try to implement it in functional programming. Therefore, the requirements of modularity, locality and polymorphism should not be understood straightforwardly. They do not impose an explicit restriction that objects must be types, and interfaces must be type classes.

In the following parts, we will look at more “functional” design ideas, when the subject area is described by a set of properties and laws, but not objects and interactions.

Source: https://habr.com/ru/post/215161/


All Articles