Properties and laws. Scripts. Inversion of Control in Haskell.Quite a bit of theory
In the
last part, we made sure that it is very easy to get lost in a poorly designed code. Fortunately, since ancient times, we know the principle of "divide and conquer" - it is widely used in the construction of architecture and design of large systems. We know different embodiments of this principle, such as: separation into components, reduction of dependence between modules, interaction interfaces, abstraction from details, the allocation of specific languages. This works well for imperative languages, and it must be assumed that it will work in functional languages, with the exception that the means of implementation will be different. What?
Consider the principle of Inversion of Control (a detailed description of this principle can be easily found on the web, for example,
here and
here ). It helps reduce connectivity between parts of a program by inverting the flow of execution. Literally, this means that we are introducing our code to a different place so that it will ever be called there; at the same time, the embedded code is considered as a black box with an abstract interface. Let us show that in any functional code both signs of IoC are combined - “code injection” and “black box”, for this we consider a simple example:
progression op = iterate ( `op` 2 )
')
geometricProgression , arithmeticalProgression :: Integer -> [ Integer ]
geometricProgression = progression ( * )
arithmeticalProgression = progression ( + )
geometricals , arithmeticals :: [ Integer ]
geometricals = take $ 10 geometricProgression 1
arithmeticals = take $ 10 arithmeticalProgression 1
Here, other functions ((*), (+), `op` 2) are transferred to the input of one function (iterate, progression), that is, some code is being implemented. And inside the receiving functions, this code is considered as a black box, for which only the type is known. In the case of iterate, for example, the second argument must be of type Integer -> Integer, and no matter how complicated its device will be. Thus, inversion of control underlies functional programming; in theory, higher-order functions allow us to construct an arbitrarily large application. There is only one problem: this interpretation of IoC is too naive, and this leads, of course, to naive code. Already in the example above, it can be seen that the code is a monolithic pyramid, and in a real application it would grow to gigantic sizes and would become completely unsupported.
Let's look at IoC from the other side, that is, from the side of the “hospitable” client code. In it we get some kind of external artifact serving a specific purpose. Outside, this artifact can be replaced by another, but for the receiving party, the substitution should be invisible. This is the so-called
Liskov substitution principle. It serves as a guide in the PLO world and dictates that the artifacts have predictable behavior. “It prescribes” and not “guarantees”, since such a guarantee cannot be given in OOP languages, any side effect can suddenly appear in any artifact that violates the principle. Does this principle apply in functional languages? Yes of course. Moreover, provided that the code is clean, we will get stronger guarantees, especially if the language is with strict static typing.
At the end of the article there is a brief description of different implementations of Inversion of Control in Haskell. Some templates are almost complete analogues of those in the imperative world (for example, the monadic state injection is Dependency Injection), and some only slightly resemble IoC. But they are all equally useful for good design.
Lot of practice
It's time to write some good code. In this article we will continue to study the design of the game “The Amoeba World”, - a whole era of it, outlined by
this and
these commits. The epoch was saturated. In addition to the completely rewritten game logic, tools such as
lenses were tried,
QuickCheck testing was introduced, a scripting language was invented, an interpreter was written, A * was integrated - a search algorithm in the world graph, and another specific antipattern was found . In this article, our conversation will affect only the properties and scenarios, we will leave the rest for the following parts.
Properties and ObjectsFrom past experience it became clear what the objects are in fact, what they consist of. The main idea embodied in this design is as follows: an object is an entity composed of several properties. The objects “Karyon”, “Plasma”, “Border” and others were dissected, and the following set of properties was obtained:
- Unique identificator
- Title
- Durability (maximum and current amount of HP)
- Owner (player)
- Layer (dungeon, earth, sky)
- Location (map)
- Age (maximum and current age)
- Battery (maximum and current amount of energy)
- Prohibition of movement (on a certain layer in this cell)
- Direction
- Motion
- Factory (the ability to create other objects)
- Self-destruction
- Collisions (with other objects)
A meticulous reader may see imperfections here, for example, for some reason the “layer” and “location” are divided into two properties, although they seem to be about the same thing. And what is the property of such a "collision"? And the “Factory”? And “Age” and “Self-Destruction”? And why does each object have a string name that will eat memory? The claims are well-founded, and already in the next era, the list was once again revised, and in the same way: by highlighting properties from properties. As a result, only six remained, the most important, “runtime” and “static”, and the rest logically turned into external effects and actions ...
For example, we will verbally describe a couple of real objects that could be on the game map:
Core:
Name = “Karyon”
Location = ( 1 , 1 , 1 )
Layer = Earth
Owner = Player 1
Strength = 100/100
Battery = 300/2000
Factory = Plasma Player 1
Plasma:
Name = “Plasma”
Location = ( 2 , 1 , 1 )
Layer = Earth
Owner = Player 1
Strength = 30/40
Since the properties are finite, it was decided to make a wrapper for each type and place them all under one algebraic type (
code ):
- Object.hs:
data Property = PNamed Named
| PDurability ( Resource Durability )
| PBattery ( Resource Energy )
| POwnership Player
| Player layer
...
deriving ( Show , Read , Eq )
Define the type of an abstract object:
- Object.hs:
type PropertyKey = Int
type PropertyMap = M. Map PropertyKey Property
data Object = Object { propertyMap :: PropertyMap }
deriving ( Show , Read , Eq )
The first thought that suggests itself when seeing Property is that we returned to where we started, that is, to the God ADT problem (at that moment it was an Item type). However, it is not. The essential difference is in the level of abstraction that the Object type gives us. We have something that can be called “combinatorial freedom”: a small amount of properties gives a combinatorial explosion of opportunities for the layout of new objects. Some other properties are not planned - and if they appear, changes will not spread along the code, like a wave through dominoes. We will see this when we talk about scenarios, but for now let's ask ourselves: how to create these very specific objects?
The easiest way is to fill the property list and convert it to
Data.Map :
- Objects.hs:
import object
karyon = Object $ M. fromList [ ( 1 , PObjectId 1 )
, ( 4 , PNamed “Karyon” )
, ( 2 , PDurability ( Resource 100 100 ) )
, ( 3 , PBattery ( Resource 300 2000 ) )
, ( 10 , POwnership Player1 )
, ( 5 , PDislocation ( Point 1 1 1 ) )
... ]
... but stop! What is the logic behind PObjectId, Dislocation and Ownership? After all, it makes sense to talk about them only for objects on the map! On the other hand, there are common properties that define a class of objects and then do not change: PNamed and PLayer, PFabric and PPassRestriction (prohibition of movement). In Karyon, the layer can only have the Ground, and the PNamed “Plasma” property can belong, respectively, only to the plasma. Here we are faced with the problem that objects must be created when directly placed on the map, and at the same time you need to have templates with the original data. The so-called “
smart designers ” are suitable as templates - functions that will create for us a ready-made object using ready-made patterns and a small set of input parameters. This is what the smarter karyon function looks like:
- Objects.hs:
import object
karyon pId player point = Object $ M. fromList [ ( 1 , PObjectId pId )
, ( 4 , PNamed “Karyon” )
, ( 2 , PDurability ( Resource 100 100 ) )
, ( 3 , PBattery ( Resource 300 2000 ) )
, ( 10 , POwnership player )
, ( 5 , PDislocation point )
... ]
This syntax can hardly be called elegant, too much “noise” and gestures. Haskell is a laconic language, and we should strive for simplicity and functional minimalism, then the code will be more beautiful, clearer and more convenient. Oh, how good it would be if the verbal description of the template, represented by several paragraphs above, could be transferred to the code ... Nothing is
impossible !
- Objects.hs
plasmaFabric :: Player -> Point -> Fabric
plasmaFabric pl p = makeObject $ do
energyCost . = 1
scheme . = plasma pl p
producing . = True
placementAlg . = placeToNearestEmptyCell
karyon :: Player -> Point -> Object
karyon pl p = makeObject $ do
namedA | = karyonName
layerA | = ground
dislocationA | = p
batteryA | = ( 300 , Just 2000 )
durabilityA | = ( 100 , Just 100 )
ownershipA | = pl
fabricA | = plasmaFabric pl p
The comprehensibility of the code depends on how much the reader’s knowledge and thinking coincided with the author’s knowledge and thinking. Is this code clear? It is clear what he does, but how does it work? What, for example, do the operators “. =” And “| =” mean here? How does the makeObject function work? Why do some names have the letter “A”, and some don't have it? And is that a monad, or what? ..
The foggy answer to these correct questions is as follows: this code uses the internal language for the layout of objects. Its design is based on the use of lenses in conjunction with the State monad. Functions with “A” -postfixes are smart constructors (“accessors”) of the properties themselves, knowing the sequence number of a particular property and able to validate values. Functions without “A” are lenses. The “. =” Operator belongs to the lens library and allows you to set a value under the zoom inside the State monad. The plasmaFabric function fills the Fabric ADT, and the karyon function fills the PropertyMap and Object. In the second example, the accessors and data are transferred to the custom operator | =, for correctness, we will call it the “filling operator”. The fill statement works inside the State monad. He pulls out the current PropertyMap and places into it the property that has been validated by the accessor:
- Object.hs:
makeObject :: Default a => State a ( ) -> a
makeObject = flip execState def
data PAccessor a = PAccessor { key :: PropertyKey
, constr :: a -> Property }
- Operator fill properties:
( | = ) accessor v = do
props <- get
let oldPropMap = _ propertyMap props
let newPropMap = insertProperty ( key accessor ) ( constr accessor v ) oldPropMap
put $ props { _ propertyMap = newPropMap }
- Accessor for the Named property:
isNamedValid ( Named n ) = not . null $ n
namedValidator n | isNamedValid n = n
| otherwise = error $ "Invalid named property:" ++ show n
namedA = PAccessor 0 $ PNamed . namedValidator
This design is not perfect. Validation of properties looks very dangerous, as it can fall with an error in runtime. We also do not monitor whether there is already such a property in the set - we just write a new one on top of it. Both can be easily corrected by creating a stack of the Either and State monads and handling exceptions in a safe manner. In this case, the code in the module with templates (Objects.hs) will change slightly. There are many advantages, but there is one objection: as long as the object layout language is used only to create templates, and as long as they can be tested, the extra logic will only get in the way. On the other hand, when this code goes into the script, security will become important.
Our last question related to objects is this: what is the World data type now? Here, no significant changes have occurred, the world is still the Map type:
type World = M. Map Point Object
The Data.Map structure suffers from performance. A more suitable solution here is a two-dimensional array; in Haskell, there are effective implementations of vectors, such as
vector or
repa . When it becomes clear that the performance of the game is not high enough, it will be possible to return and revise the repository of the world, but for now the speed of development is more important.
ScenariosScenarios are the laws of the world. Scenarios describe this or that phenomenon. The phenomena in the world are local; in one phenomenon only the necessary properties are involved in a certain part of the map. For example, when a bomb explodes, we are interested in the strength of objects in a radius N, namely, we must reduce it by the amount of damage, and if the strength drops below 0, we need to remove objects from the map. If we have a factory, we must first provide it with a resource, then get the product and place it somewhere nearby. Durability is not important, but the resources, the factory itself and the empty space for the product are important.
Scripts must be run against basic properties. If there is an object on the map with the property “Movement”, then we will launch the movement scenario. If the factory works, we will launch a scenario for the production of combat units. Scripts are not allowed to change the current world; they work alternately and accumulate results in the overall data structure. It should be borne in mind that sometimes the work of some scenarios affects the work of others, up to a complete cancellation.
We illustrate this with examples. Suppose we have two factories that produce one tank for one unit. In stock, we have only 1 resource unit. The first scenario will work successfully, but the second should know that all resources have been spent and stop working. Or another situation: two objects are moving in opposite directions. When there is only one cell between them, what should happen? Collision or inability to move one of the objects? There can be a lot of similar nuances; I would like the scripts to be complete, but remain extremely simple to read and write.
We outline the requirements for the scripts subsystem:
- reliability;
- focus on properties;
- sequence;
- simplicity;
- scripts can fail;
- speed;
- scripts can run other scripts;
- ...
In the game “The Amoeba World”, the language Scenario DSL was designed, and its interpreter (code) was written. Here’s what a piece of script looks like for the Fabric property (code):
- Scenario.hs:
createProduct :: Energy -> Object -> Eval Object
createProduct eCost sch = do
pl <- read ownership
d <- read dislocation
withdrawEnergy pl eCost
return $ adjust sch [ ownership . ~ pl , dislocation . ~ d ]
placeProduct prod plAlg = do
l <- withDefault ground $ getProperty layer prod
obj <- getActedObject
p <- evaluatePlacementAlg plAlg l obj
save $ objectDislocation . ~ p $ prod
produce f = do
prodObj <- createProduct ( f ^. energyCost ) ( f ^. scheme )
placeProduct prodObj ( f ^. placementAlg )
return "Successfully produced."
producingScenario :: Eval String
producingScenario = do
f <- read fabric
if f ^. producing
then produce f
else return "Producing paused."
In the second part of the series of articles, namely in the section 'let-functions', we saw the code as cumbersome and incomprehensible. Now we see the code is light, still incomprehensible, but in it a certain system is already visible. Let's try to figure it out.
Scenario DSL is divided into two parts: the language of the requests for game data and the execution environment. At the heart of everything is the Eval type - a stack of the Either and State monads:
- Evaluation.hs:
type EvalType ctx res = EitherT EvalError ( State ctx ) res
type Eval res = EvalType EvaluationContext res
The internal state monad allows you to store and change the execution context. The current world, operational data, random generator - all of this lies in the context:
data DataContext = DataContext { dataObjects :: Eval Objects
, dataObjectGraph :: Eval ( NeighborsFunc -> ObjectGraph )
, dataObjectAt :: Point -> Eval ( Maybe Object ) }
data EvaluationContext = EvaluationContext { ctxData :: DataContext
, ctxTransactionMap :: TransactionMap
, ctxActedObject :: Maybe Object
, ctxNextRndNum :: Eval Int }
The external Either monad allows you to safely handle execution errors. The most common situation is when there are collisions, and some scenario should end in the middle of work. In order for the state of the game to remain correct, you need to roll back all its changes, and if the script was called from another scenario, then you should somehow react to the problem. Therefore, many functions have the Eval type, which hides the Either monad. In fact, all functions with an Eval type are scripts. Even interpreter functions (evalTransact, getTransactionObjects) and query language functions (single, find) work in this type and, in fact, are also scripts. In other words, the Scenario DSL language is unified by the Eval type, which makes the code consistent and monad-composable.
Since any function with the Eval type is a script, each of them can be launched and tested. Scenario interpretation is just the execution of a monad stack:
- Evaluation.hs:
evaluate scenario = evalState ( runEitherT scenario )
execute scenario = execState ( runEitherT scenario )
run scenario = runState ( runEitherT scenario )
For game scenarios, there is one entry point - the mainScenario generic function:
- Scenario.hs:
mainScenario :: Eval ( )
mainScenario = do
forProperty fabric producingScenario
forProperty moving movingScenario
return ( )
- Somewhere in the main code - one tick of the whole game:
stepGame gameContext = runScenario mainScenario gameContext
Similarly, separate scripts are launched, which means that you can enter modular and functional code testing. Here, for example, the debugging code from the ScenarioTest.hs module, - if necessary, it can be transformed into a full-fledged QuickCheck or HUnit test:
main = do
let ctx = testContext $ initialGame 1
let result = execute ( placeProduct ( plasma player1 point1 ) nearestEmptyCell ) ctx
print result
Now that we have learned some of the features of the Scenario DSL runtime, we prepare the following function:
withdrawEnergy pl cnt = do
obj <- singleActual $ named `is` karyonName ~ & ~ ownership` is` pl ~ & ~ batteryCharge `suchThat` ( > = cnt )
batRes <- getProperty battery obj
save $ batteryCharge . ~ modifyResourceStock batRes cnt $ obj
This is also a scenario serving a specific purpose: for a pl player to remove cnt energy from the nucleus. What needs to be done for this? First of all, find an object on the map with such properties: Named == “Karyon” and Ownership == pl. In the code above, we see the call to singleActual — this function searches for an object by predicate. Thanks to the query language, the verbal description is almost exactly translated into code:
named `is` karyonName
~ & ~ ownership `is` pl
~ & ~ batteryCharge `suchThat` ( > = cnt )
It is not difficult to guess that the operator (~ & ~) means “and”, and the operator `is` specifies the equality of a certain property to a value. The third condition of the predicate selects only those objects for which the battery is charged enough to extract more energy from there. Of course, the energy may run out, and then the object will not be found, - in this case, the fail-line of the Either monad will begin, and the whole script will be canceled. But if energy can be removed, then we seize and accumulate changes:
save $ batteryCharge . ~ modifyResourceStock batRes cnt $ obj
It is worth mentioning that the Scenario DSL actively uses lenses, which greatly reduces the code. For example, instead of laconic (batteryCharge. ~ 10) we would have to do archaeological excavations along the chain: Object -> PropertyMap -> PBattery -> Resource -> change stock -> save all back. Although the idiomatic lens is in
doubt , this tool is very, very useful.
Query language has many useful features. You can search for many objects by predicate (the function query), you can search for a single object (single function), and if there are a lot of those, you can find the script. There are also search strategies: look only for old data, look only for new, or all together, and let the client code itself understand. In general, the Scenario DSL coped well with its function, and there were opportunities for its expansion. And there was only one serious problem, which again had to revise the basis of the fundamentals - the design of the Object type. The name of this problem ...
Antipattern Lens + NoMonomorphismRestrictionThe reason for all the trouble lies in the data type PropertyMap and in the lenses for the properties:
property k l = propertyMap . at k . traverse . l
named = property ( key namedA ) _ named
durability = property ( key durabilityA ) _ durability
battery = property ( key batteryA ) _ battery
...
The property function in all cases returns different lenses, which cannot be done when the monomorphism check is on. Therefore, we had to include the extension of the NoMonomorphismRestriction language. Unfortunately, because of this, type inference began to break down in the most unexpected places, and we had to look for workarounds. Worse, the NoMonomorphismRestriction mode began to spread throughout the code. He appeared everywhere where the lenses of the Object.hs module were used, and infested with a Tupcheker insanity. In the end, the design of the Scenario DSL began to sag under the restrictions of the tick checker — which led to several not very good solutions.
The problem can be eradicated by abandoning the type PropertyMap. Then in the type of Object will be all the properties - even those that are not needed for a particular object. Perhaps there are other solutions, but in the next version of the design it was done this way:
data Object = Object {
- Properties:
objectId :: ObjectId - static property
, objectType :: ObjectType - predefined property
- Runtime properties, resources:
, ownership :: Player - runtime property ... or can be effect!
, lifebound :: IntResource - runtime property
, durability :: IntResource - runtime property
, energy :: IntResource - runtime property
}
A blessing in disguise, as a result of the revision, other properties have become external effects and actions. The design became more correct, although I had to throw away most of the developments on the Scenario DSL ...
Instead of conclusion
The new script engine is supposed to be based on different principles. In particular, it is planned to make not an internal DSL, but an external one — then it will be possible to write scripts in plain text files. At the moment, the author is working on the Application and View layers, on finding the optimal model for using FRP. In the following chapters, you will learn about the idea behind the FRP, and how you can use the reactive programming to unite the fragments of a large application.
Implements Inversion of Control in Haskell
Disclaimer: The author did not have time to complete the research for this section. Continuation will be in the following articles.Monadic state injection (Monadic state injection)What is : Dependency injection (Dependency Injection).
What it is used for : For abstracted work with the external state in the client code.
Description : The external state is implemented through the State monad as a context. Client code runs in the State monad with this context. When accessing the context, the client code receives data from the external state.
Structure :
We define the Context data type - it will contain the external state in the form of the State monad:
data Context = Context { ctxNextId :: State Context Int }
We define specific instances of the code being implemented. The code can produce a constant result:
constantId :: State Context Int
constantId = return 42
Or it may produce different results for each challenge:
nextId :: Int -> State Context Int
nextId prevId = do let nId = prevId + 1
modify ( \ ctx -> ctx { ctxNextId = nextId nId } )
return nId
Create client code in the State monad:
client = do
externalId <- get >> = ctxNextId
doStuff externalId
return externalId
Run the client code, implementing a specific instance of the external state:
print $ evalState client ( ContextId )
print $ evalState client ( Context ( nextId 0 ) )
Full example : gist
Example program output :
Sequental ids:
[ ( 1 , "GNVOERK" ) , ( 2 , "RIKTIG YOGLA" ) ]
Random ids:
[ ( 59 , "GNVOERK" ) , ( 64 , "RIKTIG YOGLA" ) ]
Module AbstractionWhat is : Black box.
What it is used for : Select the implementation of the algorithm in runtime.
Description : There is a facade module in which several modules are connected that implement the same function. According to a certain algorithm, one or another implementation is selected in the switch function of the facade module. In the client code, the facade module is connected, and the required algorithm is used through the switch function.
Full example :
gist