Tiny Objects
Hello, loves!
Received wisdom and personal experience tell me to cover built-in collections. Here’s why.
Back in the day, my betters would rabbit on about “never” using build-in collections directly, saying that it was “always” better to write a little class holding the collection we wanted. I myself have learned that lesson time and time again. It is my fashion to learn things over and over, because I tend to make the same mistakes over and over.
In favor of the built-in collection, especially in Python, we have a strong argument: laziness. Almost anything you want to do with a collection, Python has a custom-made object or function to do the job. And, as we’ll see in a moment, even the straightforward things seem to invite “just” using a built-in collection. And, as always, the word “just” has a lot hiding behind it.
Let’s get to the case. Yesterday we were working on improving our notation for dealing with textures, so that instead of some horrendous path name, we could just say ‘lever/1.png’ and the right texture would be found. Although I had not know it going in, I found that arcade allows defining a “resource handle”, and we wound up with this code:
class ContentView:
error_texture = ':resources:images/items/star.png'
def __init__(self, cell, content_item, resources, scale=0.5):
self.error = False
self.cell = cell
self.item = content_item
self.sprite = Sprite()
for resource in resources:
try:
texture = arcade.load_texture(':ron:'+resource)
except (FileNotFoundError, AttributeError):
self.error = True
texture = arcade.load_texture(self.error_texture)
self.sprite.append_texture(texture)
assert not self.error
self.sprite.set_texture(0)
self.sprite.visible = False
self.sprite.scale = scale_texture(self.sprite.textures[-1], scale)
The new bit is just the arcade.load_texture(':ron:'+resource), because over in DungeonView, we did this:
def setup_assets(self):
arcade_resources = ':resources:images/items/'
arcade_resources = arcade.resources.resolve(arcade_resources)
my_resources = '/Users/ron/Desktop/DungeonTiles/png/objects/'
arcade.resources.add_resource_handle('ron',arcade_resources)
arcade.resources.add_resource_handle('ron',my_resources)
That defines a “handle” ‘:ron:’ that directs arcade to search those two folder for file names and short paths like lever/1.png. The result is that all arcades built-in textures, and all of my purchased ones, can be easily found.
So far so good. Our plan included caching the textures. The code above loads the texture every time we use it, so if we put a dozen chests in the dungeon, we’ll read the file a dozen times. Tacky.
So yesterday, while you were away, I did this:
class ContentView:
error_texture = arcade.load_texture(':resources:images/items/star.png')
known_textures: dict[str, arcade.Texture] = dict()
def __init__(self, cell, content_item, resources, scale=0.5):
self.error = False
self.cell = cell
self.item = content_item
self.sprite = Sprite()
for resource in resources:
texture = self.get_texture(resource)
self.sprite.append_texture(texture)
self.sprite.set_texture(0)
self.sprite.visible = False
self.sprite.scale = scale_texture(self.sprite.textures[-1], scale)
def get_texture(self, resource):
if resource in self.known_textures:
return self.known_textures[resource]
try:
texture = arcade.load_texture(':ron:' + resource)
self.known_textures[resource] = texture
except (FileNotFoundError, AttributeError):
self.error = True
texture = self.error_texture
return texture
When we create a new ContentView, we pass it the resource names that it should have, and, looping over get_texture, we append them to the item’s sprite. In get_+texture, we just check the class-level dictionary known_textures, returning what we find if it is there, and otherwise loading it and putting it in the dictionary.
Pretty standard little cache of name->texture. It took less time to do than it just took to explain it.
So, I don’t know why, I was thinking about what I’d done and some voice said something to me about covering native collections, and I started to answer why this case was so simple that the guideline didn’t apply, when I realized that, well actually, a little object of our might be better. So I decided to find out. So let’s find out.
There are only four methods in ContentView other than __init__: get_texture, illuminate, remove, and set_state. The latter three refer to things we do at run time, we light up the object, we animate it, and we can remove it when it is destroyed or given to Dot.
The method get_texture isn’t like that. And it is as long as the other three methods combined.
The Plan
To resolve this tension, we’re going to create a very small object, TextureCache, and use it. I think we’ll just create it where the current dict is, in the class variable known_resources.
I’m not sure how good our tests are for this class, so we’ll start by putting None into the known_textures just to see if anything breaks. If not, we’ll code something that does.
Two tests fail:
def test_creation(self):
resource = 'keyRed.png'
factory = ContentFactory()
item = factory.decor(name='key', resource=resource, scale=0.5)
cell = Cell(0,0)
view = ContentView(cell, item, item.resources)
assert view.sprite.visible == False
def test_content_view_uses_ron_handle(self):
layout = DungeonLayout()
dungeon = Dungeon(layout)
dv = DungeonView(dungeon, testing=True) # inits handle
resource = 'Skeleton1.png'
content = ContentFactory().decor(name='test', resource=resource, scale=0.5)
cv = ContentView(Cell(5,5), content, content.resources, content.scale)
assert not cv.error
That’s good. But I think we’d like to TDD this tiny class anyway, trivial though it is. Why? Because I want to be sure it’s really caching. The code we have now could have a defect in it that caused it never to find anything, and we’d never realize that it was reading a file every time.
class TextureSaver:
def __init__(self):
pass
class TestTextureSaver:
def test_exists(self):
cache = TextureSaver()
I decided to name it TextureSaver. We’ll discuss why below. Green so far, let’s commit: working on TextureSaver.
I figure this test will do for us:
def test_saves(self):
DungeonView.setup_assets()
cache = TextureSaver()
r1 = 'lever/1.png'
r2 = 'lever/2.png'
t1 = cache.get(r1)
t2 = cache.get(r2)
assert len(cache) == 2
t3 = cache.get(r1)
assert t3 == t1
assert len(cache) == 2
We’ll need some code:
class TextureSaver:
error_texture = arcade.load_texture(':resources:images/items/star.png')
def __init__(self):
self._cache:dict[str, arcade.Texture] = dict()
self.error = False
def __len__(self):
return len(self._cache)
def get(self, resource_name:str) -> arcade.Texture:
if resource_name in self._cache:
return self._cache[resource_name]
try:
texture = arcade.load_texture(':ron:' + resource_name)
self._cache[resource_name] = texture
except (FileNotFoundError, AttributeError):
self.error = True
texture = self.error_texture
return texture
Test passes. No real surprise, we basically just moved the code over. Commit.
But now in ContentView:
class ContentView:
textures = TextureSaver()
def __init__(self, cell, content_item, resources, scale=0.5):
self.error = False
self.cell = cell
self.item = content_item
self.sprite = Sprite()
for resource in resources:
texture = self.textures.get(resource)
self.sprite.append_texture(texture)
self.sprite.set_texture(0)
self.sprite.visible = False
self.sprite.scale = scale_texture(self.sprite.textures[-1], scale)
We can remove the error texture, and the get method we used to have. Tests all green. Commit.
So this is already good, because now ContentView doesn’t know anything about how to cache textures.
Let’s rename that class to TextureProvider. And we might rename it again.
class ContentView:
textures = TextureProvider()
def __init__(self, cell, content_item, resources, scale=0.5):
self.error = False
self.cell = cell
self.item = content_item
self.sprite = Sprite()
for resource in resources:
texture = self.textures.get(resource)
self.sprite.append_texture(texture)
self.sprite.set_texture(0)
self.sprite.visible = False
self.sprite.scale = scale_texture(self.sprite.textures[-1], scale)
Why are we pulling textures out of that one object and stuffing them into another? Let’s have TextureProvider do that job:
class ContentView:
textures = TextureProvider()
def __init__(self, cell, content_item, resources, scale=0.5):
self.error = False
self.cell = cell
self.item = content_item
self.sprite = Sprite()
self.textures.load_sprite(self.sprite, resources)
self.sprite.set_texture(0)
self.sprite.visible = False
self.sprite.scale = scale_texture(self.sprite.textures[-1], scale)
And tests break, but then:
class TextureProvider:
def load_sprite(self, sprite, resource_names):
for resource_name in resource_names:
texture = self.get(resource_name)
sprite.append_texture(texture)
Why do we bother to create the sprite and pass it in? Let’s have the provider do that too.
class ContentView:
textures = TextureProvider()
def __init__(self, cell, content_item, resources, scale=0.5):
self.error = False
self.cell = cell
self.item = content_item
self.sprite = self.textures.load_sprite(resources)
self.sprite.set_texture(0)
self.sprite.visible = False
self.sprite.scale = scale_texture(self.sprite.textures[-1], scale)
And:
def load_sprite(self, resource_names):
sprite = Sprite()
for resource_name in resource_names:
texture = self.get(resource_name)
sprite.append_texture(texture)
return sprite
Commit. Let’s review the whole TextureProvider class:
class TextureProvider:
error_texture = arcade.load_texture(':resources:images/items/star.png')
def __init__(self):
self._cache:dict[str, arcade.Texture] = dict()
self.error = False
def __len__(self):
return len(self._cache)
def load_sprite(self, resource_names):
sprite = Sprite()
for resource_name in resource_names:
texture = self.get(resource_name)
sprite.append_texture(texture)
return sprite
def get(self, resource_name:str) -> arcade.Texture:
if resource_name in self._cache:
return self._cache[resource_name]
try:
texture = arcade.load_texture(':ron:' + resource_name)
self._cache[resource_name] = texture
except (FileNotFoundError, AttributeError):
self.error = True
texture = self.error_texture
return texture
Now it seems to me that the get method is essentially private now, as our only user now uses load_sprite. We may have other users later but this tiny class only has one user other than its tests. So we’ll make it private until we need it, if we ever do. Commit again.
What is the name of this class now? Is it still TextureProvider? Or is it a SpriteLoader? TexturedSpriteMaker? SpritePainter? SpriteTexturator? LordHighTexturizerOfSprites?
I’m not sure. We’ll sit with this name for a while. Let’s assess what we’ve done.
Reflection
We have simplified ContentView and reduced its responsibilities. It used to have two classes of behavior, managing sprites at run time, and creating a sprite with multiple textures. It now has just the former group. It is down from 4 concrete methods to 3. It has one fewer class variables. It orders out to get its sprite instead of building one itself. It’s much simpler.
The TextureProvider is a new class, and it is 27 lines long, while we have “only” removed 15 lines from ContentView. We have a net increase in code of about a dozen lines!!!
TextureProvider encapsulates a concept that we didn’t have in the code before, loading a Sprite, which was formerly represented by a loop in one method and another method that did the texture getting bits. So it adds to clarity.
The flow of things is interesting.
At first, we were just covering the cache dictionary with a little object, which was sufficient to move the nasty get method out of ContentView, where it was clearly out of place. But having done that, it slowly became clear that the new object could be more helpful, first by adding a texture to a sprite for us, and then we saw that it could provide the whole Sprite.
And here is what may be the most important thing: this tiny new object is easy to test. We don’t have to set up layouts and dungeons and whatnot. We just create it and it’s ready to test.
I’ve been whining about the difficulty of testing things in this program. If I hadn’t written every line myself I’d be raging at whatever fool made it so hard to test. This experience of creating a cover for a collection making things easier to understand and much easier to test … this experience that I have had innumerable times over many years … revitalizes my attention to such things. If this high sticks, maybe we’ll look for other places to make things better with tiny objects.
Or maybe I’ll just forget and fall back into old bad habits. It could happen.
Summary
A tiny object improves the code that uses it, and creates opportunities for adding small bits of capability that makes things better. And it’s easy to test! Yummy!
See you next time!
Postscript
I was going to tell you why TextureCache got renamed: There is an arcade class named TextureCache. It’s not the same as what we did today, but I didn’t want o hide the name, in case we ever need the arcade one.