From ea4d79dbe5c6d07255c56431ca8e7ad9f2e9f992 Mon Sep 17 00:00:00 2001 From: James Turk Date: Fri, 26 Apr 2024 15:44:04 -0500 Subject: [PATCH] design pattern docs --- README.md | 230 ++++++++++++++++++++++++++-------- src/doodles/draw_engine.py | 2 - src/doodles/examples/balls.py | 7 ++ 3 files changed, 182 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index aeb0880..ff1cbaa 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,184 @@ Notably absent from this list: "make a useful tool" and "demonstrate the *right* This library demonstrates *ways* to do things, there is almost never a single correct way. Sometimes choices will be made to provide more interesting code to learn from, or a more fun API to use. +## Running + +Check this repo out locally, then: + +``` +poetry install +poetry run python -m doodles.main +``` + +Press left/right arrow to toggle through examples. + +Feel free to modify the existing examples (`doodles/examples` or add your own). + +## Architecture + +TODO + +## SOLID Principles + +*Single Responsibility* + +The most clear example of the single responsibility principle is that shapes do +not draw themselves. +This seems like a good idea, and indeed as I was drafting this they did, each shape +has a `draw` method that could draw itself. + +Instead these methods dispatch to the currently active DrawEngine which contains +all drawing specific code. + +This follows single responsibility, and gains a tangible benefit from it. + +(See `lines.py draw` for a bit more discussion of this.) + +*Open/Closed* + +This principle asks that behaviors are extensible by inheritance, +not requiring modification to the base classes behavior. + +The `Doodle.random()` method demonstrates this, the base random method +randomizes the properties common to all doodles (position, color). + +Derived classes override this (see `Line.random()`, `Circle.random()`, etc.) by calling the base +implementation, and then adding their own random elements. + +(See `lines.py random` for a bit more discussion of this.) + +*Liskov Substitution* + +Anywhere that the underlying code expects a `Drawable` it should be possible +to substitute any child class `Line`, `Rectangle`, etc. + +This is ensured by having all of them define a common `draw` interface, and having +their constructors not take dozens of additional parameters. (See `Line.__init__`.) + +*Interface Segregation* + +This is largely given by counter-example, the principle here states that we must not force child classes to implement methods they do not use. + +A counter-example to this would be if the pre-render, then draw dance that complicates the `Text` interface was extended to all types. + +One could imagine having placed this logic at the `Doodle` class instead of `Line`. +That would mean that `Circle`, `Rectangle`, etc. would need to implement a (likely empty) method to fulfill this. + +*Dependency Inversion* + +Entities should depend on abstractions, not concrete implementations. + +`World` depends on the `Doodle` interface (most explicitly through `draw`), but not on `Circle`, `Font`, etc. + +This ensures adding new drawable `Doodles` only requires deriving a class, not modifying `World`. + +## Design Patterns + +A number of design patterns are utilized in this library. + +I'm open to suggestions for more to add, especially where an interesting feature can demonstrate the utility. + +### Prototype + + + +The `Doodle` class (and override in `Group`) demonstrate the utility of the Prototype pattern. + +Letting these objects specify how they are copied makes the desired behavior possible, the utility of which can be seen in `examples/copies.py`. + +This would often be done via the `__copy__/__deepcopy__` methods, but for simplicity as well as API compatibility this is done in `.copy()` here. + +### Singleton + + + +The `World` class is treated as a Singleton and contains notes on alternate implementations. + +### Strategy & Bridge + + + +The interface created in `draw_engine.py` as well as implementation in `world.py` show the strategy pattern in action. + +While this pattern can sometimes be achieved in a functional way by passing a function around, here the drawing strategy is more complex, and so gets aggregated into a class that allows swapping the entire strategy. + +An alternate implementation of this class could draw to a PDF or web browser instead of Pygame window. + + + +At the moment, the `World` and `PygameDrawEngine` are very tightly coupled, +this make sense because they both require `pygame`. + +This creates a bridge pattern between the two, `World` handles update logic/the generic +form of drawing doodles, but the actual drawing is implemented in the draw engine. + +### Composite + + + +This pattern allows objects to form a tree, usually by tracking child/parent relationships. + +The `Group` class works closely with `Doodle` (both in `doodles.py`) to form a composite. + +Groups of doodles can be moved/colored/etc. together by having the `Group` delegate method calls to the child elements. + +Since `Group` is-a `Doodle`, groups of groups of groups can be created, allowing trees of any shape or size. + +### Template Method + + + +The core behavior of an object is implemented in the `Doodle` class which +has methods for positioning, coloring, etc. + +The `update()` and `draw()` methods are templates, that if overriden, can +further refine the behavior + +See `balls.py` for an example of the `update` method being used to demonstrate +the template method pattern. + +### Command + + + +The command pattern turns an intention into a packet of information about the +intended operation, so execution can be deferred. + +Note that in all of the examples, the drawing does not happen when the object +is created, the creation of an object indicates that there will be some future +operation performed, based on the properties of the command class. + +This allows our chained interface to work since + +`Circle().move(20, 20).color(255, 0, 0)` does not immediately draw a circle when `move` is called, instead it is informing future draws to include that position. This avoids a non-red circle from being drawn, since the subsequent call to `color(255, 0, 0)` to +also run before the command is executed (via the `draw` method). + +### Flyweight Cache + + + +Fonts need to be loaded once and are loaded in to shared memory +(currently in `PygameDrawEngine`) instead of having each `Text` object +load the same font. + +This is done since it is common to use the same font multiple times in an +application, so having each text object load its own instance of a font would +be immensely wasteful. + +This is the most complex part of the code currently, but you can see the logic in the font methods on the DrawEngine and you'll see it is mostly a dictionary mapping +parameters to created objects. + +### Others + +Some other patterns under consideration would be Observer, Factor, and Visitor. + +All of these might have interesting applications in making art this way. (PRs welcome :)) + ## Rationales -I will document the reason that certain decisions were made, particularly when they were not my first thought. +A few more rationales for reason that certain decisions were made, particularly when they were not my first thought. -If any decisions are unclear, I consider that a bug, please file a corresponding GitHub issue. +If any decisions elsewhere in the code are unclear and undocumented, I consider that a bug, please file a GitHub issue. ### Mutability @@ -39,56 +212,3 @@ This complicates things a bit, since you can't write `self.x(100)` and use `self The unorthodox decision was made to use `x(100)` as a setter function, and use `.x_val` as the property name. This is primarily because setting properties is more common than getting properties in doodles. - -## Design Patterns - -TODO: flesh this out with more notes - -A number of design patterns are utilized in this library. -I'm open to suggestions for more to add, especially where an interesting feature can demonstrate the utility. - -### Factory - -TODO: No factory yet surprisingly, should be easy to add one to add shapes, but can consider other options. - -### Prototype - -The `Doodle` class (and an override in `Group`) demonstrate the utility of the Prototype pattern. - -Letting these objects specify how they are copied makes the desired behavior possible, the utility of which can be seen in `examples/copies.py`. - -This would often be done via the `__copy__/__deepcopy__` methods, but for simplicity as well as API compatibility this is done in `.copy()` here. - -### Singleton - -The `World` class is treated as a Singleton and contains notes on alternate implementations. - -### Bridge / Strategy - -TODO: the planned Renderer class will demonstrate these - -### Composite - -The Group class (along with Doodle) forms a composite. - -### Command - -The structure of the Doodle object is a command class. -It stores the information about the action to be performed encapsulated. -It's entire purpose is to provide arguments to a draw* method. - -### Observer - -TODO: room to implement, dynamic properties? - -### Template Method - -The update method, the draw method. - -### Visitor - -TODO: maybe use along with group? - -### Flyweight Cache - -Text's Font Cache is a flyweight diff --git a/src/doodles/draw_engine.py b/src/doodles/draw_engine.py index ca31992..279b7b8 100644 --- a/src/doodles/draw_engine.py +++ b/src/doodles/draw_engine.py @@ -25,8 +25,6 @@ class DrawEngine(abc.ABC): while writing in the first place, and ensure that code needing isolation (such as a library you want to avoid tight coupling to) only is added to a specific class or module. - - TODO: this got behind the current impl """ @abc.abstractmethod def init(self): diff --git a/src/doodles/examples/balls.py b/src/doodles/examples/balls.py index 0b4db70..6b32ead 100644 --- a/src/doodles/examples/balls.py +++ b/src/doodles/examples/balls.py @@ -21,6 +21,10 @@ class Ball(Circle): self.speed = 9 + random.random() * 5 def update(self): + """ + This update method loops the balls around the screen + when they fall off the bottom. (A raindrop like effect.) + """ self.move(0, self.speed) if self.world_y > world.HEIGHT + 20: self.move(0, -world.HEIGHT - 20) @@ -33,6 +37,9 @@ class GravityBall(Circle): self.speed = random.random() * 10 def update(self): + """ + This update method simulates acceleration and a bounce. + """ self.speed += self.accel self.move(0, self.speed) if self.world_y > world.HEIGHT - 10: