DEV Community

Matthew Faithfull
Matthew Faithfull

Posted on

Code Sfumato

sfumato, (from Italian sfumare, “to tone down” or “to evaporate like smoke”), in painting or drawing, the fine shading that produces soft, imperceptible transitions between colours and tones. It is used most often in connection with the work of Leonardo da Vinci and his followers, who made subtle gradations, without lines or borders, from light to dark areas; the technique was used for a highly illusionistic rendering of facial features and for atmospheric effects - Britannica

Importantly for our purposes this technique was executed by the application of many, thin, translucent layers. Leonardo used paint but I believe the technique applies equally well to the art of software.

This might also be thought of as, 'The power of leaving things out', 'Applied ignorance', 'No code is the best code' or just another positive form of laziness. In software development laziness has long been known to be a good thing. So long it has it's rules.

  • Don't put off till tomorrow what you can put off till next week.
  • If at first you don't succeed, get someone else to do it for you.
  • Cheat at every opportunity.

The rules of laziness are, of course, terrible advice for life but very good advice for writing code.

That's it. That's what I wanted to say. Code Sfumato is the way I write my best code. I guess you want an example though ;<}


Code Sfumato in practice

Occasionally you find yourself needing to write things that really should just exist already and be completely obvious and standard. Then you write them and realize why they're not. One example of this is a simple stream buffer. Easy right?

How big should it be?
How do we put things in it?
How do we get them out again?
What should we do when it fills up?
What kinds of things does it need to hold?
How do we make sure it doesn't leek memory?
What if you can't get the answers to any of these questions?

Suddenly a simple problem that seemed like

auto buffer = std::array< my_struct, MAX_BUFFER_SIZE>();

would solve it, doesn't seem so simple any more. Unless it is in which case just use std::array and move on.

Let's put sfumato and the power of our ignorance to work, lazily of course.

Layer 1: An almost entirely ignorant (and harmless) buffer interface

class Buffer
{
/*solution goes here*/
};
Enter fullscreen mode Exit fullscreen mode

We don't know what kind of things our buffer needs to contain but ultimately we know it's all 1's and 0's organised into bytes. So we don't really need to know what type the things are, just how many bytes it takes to store one of them.

class Buffer
{
protected:
    size_t m_unitSize;
};
Enter fullscreen mode Exit fullscreen mode

We're making an assumption here that all the items in the buffer are the same size. Quite reasonable as, if we need different sized items, we can just use different buffers. However we should remember this assumption because it becomes the first rule for using Buffer. The unit size is fixed and therefore needs to be set before we use the Buffer, so on construction.

class Buffer
{
public:
    Buffer() : m_unitSize(1)
    {
    //...
    }

    Buffer(size_t unitSize /*...*/) : m_unitSize(unitSize)
    {
    //...
    }

    size_t Buffer::GetUnitSize() const
    {
        return m_unitSize;
    }
    
protected:
    size_t m_unitSize;
};
Enter fullscreen mode Exit fullscreen mode

Default construction is nice to maintain if we can and a default unit size of 1 byte seems reasonable. We'll make retrieving the unit size part of the API so clients can easily check against it.
This look pretty solid and difficult to break. Of course in real development we'd have a growing set of unit tests to make sure and most of them would still fail at this point as we don't actually have a working buffer yet.
We know that at some point we're going to have to keep track of how many units of m_unitSize the Buffer owns. What our Capacity is. So let's do that.

class Buffer
{
public:
    Buffer() : m_unitSize(1)
    {
        SetCapacity((size_t)0);
    }

    Buffer(size_t unitSize, size_t itemCount) : 
        m_unitSize(unitSize)
    {
        SetCapacity(itemCount);
    }

    ~Buffer()
    {
        SetCapacity((size_t)0);
    }
    
    size_t GetUnitSize() const
    {
        return m_unitSize;
    }
    
    size_t Capacity(void) const
    {
        return m_allocationCount;
    }    
    
protected:

    virtual void SetCapacity(size_t itemCount)
    {
        m_allocationCount = itemCount;
    }    

    size_t m_unitSize;
    size_t m_allocationCount;//Track item count we have space for.
};
Enter fullscreen mode Exit fullscreen mode

Well that was easy. We were lazy of course and didn't do any actual memory management. Maybe later.
This is still pretty safe and difficult to break. We should however handle copying and assignment as users of Buffer are bound to want to do such things.

class Buffer
{
public:
    Buffer() : m_unitSize(1)
    {
        SetCapacity(0);
    }

    Buffer(size_t unitSize, size_t itemCount) : 
        m_unitSize(unitSize)
    {
        SetCapacity(itemCount);
    }
    
    Buffer(const Buffer& src)
    {
        *this = src;
    }    

    ~Buffer()
    {
        SetCapacity((size_t)0);
    }
    
    Buffer& operator = (const Buffer& src)
    {
        if(&src != this)
        {
            SetUnitSize(src.GetUnitSize());
            SetCapacity(src.Capacity());
        }
        return *this;
    }
    
    size_t GetUnitSize() const
    {
        return m_unitSize;
    }
    
    size_t Capacity(void) const
    {
        return m_allocationCount;
    }    
    
protected:

    virtual void SetCapacity(size_t itemCount)
    {
        m_allocationCount = itemCount;
    }    

    void SetUnitSize(size_t unitSize)
    {
        if(m_allocationCount > 0 )
        {
            SetCapacity((size_t)0);
        }
        m_unitSize = unitSize;
    }
    
    size_t m_unitSize;
    size_t m_allocationCount;//Track item count we have space for.
};
Enter fullscreen mode Exit fullscreen mode

Calling the assignment operator from the copy constructor is sometimes frowned on but we're being lazy so we'll do that and not duplicate code.
Notice we've added a SetUnitSize function which is now called on assignment when we already have a size and an allocation count. That's OK though because we set the allocation count back to zero if we change the unit size. Making sure to do so before we copy the allocation count from the source.
That was tricky. I'm glad we didn't complicate things with actual memory management.

There's no point allowing anyone to read our Buffer until it's been written to so we should add Writing next.
That's an important rule actually. Write always goes ahead of Read or Read always follows Write if you prefer. We can use that to keep our implementation simple.

We'll add two variables for writing. A write beginning and write end on the grounds that for the buffer to be useful we want to able to write to it multiple times without overwriting what's already there but we might want to write more than one item at a time. Given that no one is answering our questions on the requirements for this buffer today, we'll support single and multiple unit write. There's another usage rule. The client will need to tell us how many units it wants to write. This will generate the gap between write_begin and write_end until the client tells us how many units it actually wrote.

class Buffer
{
public:
    Buffer() : m_unitSize(1)
...
    Buffer(size_t unitSize, size_t itemCount) : 
        m_unitSize(unitSize)
...    
    Buffer(const Buffer& src)
...
    ~Buffer()
...    
    Buffer& operator = (const Buffer& src)
    {
        if(&src != this)
        {
            SetUnitSize(src.GetUnitSize());
            SetCapacity(src.Capacity());
            m_writeBegin = src.m_writeBegin;
            m_writeEnd = src.m_writeEnd;        
        }
        return *this;
    }
    
    size_t GetUnitSize() const
...    
    size_t Capacity(void) const
...    
    void Reset(size_t itemCount)
    {
        m_writeBegin = 0;
        m_writeEnd = 0;

        if(itemCount != 0 && itemCount != m_allocationCount)
        {
            SetCapacity(itemCount);
        }
    }    
    
protected:

    virtual void SetCapacity(size_t itemCount)
    {
        m_allocationCount = itemCount;
        m_writeBegin = 0;
        m_writeEnd = 0;      
    }    

    void SetUnitSize(size_t unitSize)
...    
    size_t m_unitSize;
    size_t m_allocationCount;//Track item count we have space for.
    size_t m_writeBegin;
    size_t m_writeEnd;    
};
Enter fullscreen mode Exit fullscreen mode

I've added a convenience API for Reset as that's a thing users might need. If I was truly lazy I might not bother until there's a specific need of course but it modifies m_writeBegin and m_writeEnd so it may as well go in now.

We need a way to know how much space there is left in the buffer to write into before we go doing anything too dramatic like actually letting the user write data.

size_t Buffer::WriteCapacity() const
{
    size_t result = 
        m_allocationCount - 
        static_cast<size_t>(m_writeEnd - m_readBegin);
    return result;
}
Enter fullscreen mode Exit fullscreen mode

This calculation may not make any sense right now. You'll need to trust me for a moment and we'll need to add read variables to Buffer as it turns out WriteCapacity is dependent on how much has already been read.

class Buffer
{
public:
    Buffer() : m_unitSize(1)
...
    Buffer(size_t unitSize, size_t itemCount) : 
        m_unitSize(unitSize)
...    
    Buffer(const Buffer& src)
...
    ~Buffer()
...    
    Buffer& operator = (const Buffer& src)
    {
        if(&src != this)
        {
            SetUnitSize(src.GetUnitSize());
            SetCapacity(src.Capacity());
            m_readBegin = src.m_readBegin;
            m_readEnd = src.m_readEnd;
            m_writeBegin = src.m_writeBegin;
            m_writeEnd = src.m_writeEnd;        
        }
        return *this;
    }
    
    size_t Buffer::WriteCapacity() const
    {
        size_t result = 
            m_allocationCount - 
            static_cast<size_t>(m_writeEnd - m_readBegin);
        return result;
    }

    size_t Buffer::ReadCapacity() const
    {
        size_t result = 
            static_cast<size_t>(m_writeBegin - m_readEnd);
        return result;
    }
    
    size_t GetUnitSize() const
...    
    size_t Capacity(void) const
...    
    void Reset(size_t itemCount)
    {
        m_readBegin = 0;
        m_readEnd = 0;
        m_writeBegin = 0;
        m_writeEnd = 0;

        if(itemCount != 0 && itemCount != m_allocationCount)
        {
            SetCapacity(itemCount);
        }
    }    
    
protected:

    virtual void SetCapacity(size_t itemCount)
    {
        m_allocationCount = itemCount;
        m_readBegin = 0;
        m_readEnd = 0;
        m_writeBegin = 0;
        m_writeEnd = 0;      
    }    

    void SetUnitSize(size_t unitSize)
...    
    size_t m_unitSize;
    size_t m_allocationCount;//Track item count we have space for.
    size_t m_readBegin;
    size_t m_readEnd;
    size_t m_writeBegin;
    size_t m_writeEnd;    
};
Enter fullscreen mode Exit fullscreen mode

Now that we've got ReadCapaciy things might start to make more sense.

size_t Buffer::ReadCapacity() const
{
    size_t result = 
        static_cast<size_t>(m_writeBegin - m_readEnd);
    return result;
}
Enter fullscreen mode Exit fullscreen mode

The data available for reading is in-between where we're going to write next and the end of what we've already read or are reading.

NOTE: This is the key to how the whole thing works so don't skip this bit.

Here's a representation of our buffer

|?-?-read_begin - - - - read_end( # # # #) write_begin - - - write_end?-?-|

The ( # # # #) part in the middle is what's already finished being written but hasn't started being read yet, i.e. the available Read Capacity.

The Write Capacity is the total buffer size less everything that has already been written and hasn't finished being read yet. i.e.

m_allocationCount - (m_writeEnd - m_readBegin);

Just the ?-?- space on the beginning and ?-?- end of the line in the diagram.

The part between read_begin and read_end is being read. The part between write_begin and write_end is being written. Either or both of these might be zero sized.

Hopefully that's super clear. Buffer management should be simple right.

Now that we know how much space is available for reading and writing we're almost ready to let clients write.

virtual size_t Buffer::WriteAcknowledge(size_t& itemCount)
{
    if(itemCount > (m_writeEnd - m_writeBegin))
    {
        itemCount = 
        static_cast<size_t>(m_writeEnd - m_writeBegin);
    }
    m_writeBegin += itemCount;
    m_writeEnd = m_writeBegin;
    return WriteCapacity();
}
Enter fullscreen mode Exit fullscreen mode

This function lets clients tell us how many items they wrote. If they say they wrote more than the available space we disbelieve them.
Then we move the write_begin marker along by the number of items and set the write_end marker to the end of the actually written space as the client may not have written everything between write_begin and write_end this might mean write_end moving back. but it's always >= write_begin.

We'll obviously need the analogous function for reading so we might as well add that in.

size_t Buffer::ReadAcknowledge(size_t& itemCount)
{
    if(itemCount > (m_readEnd - m_readBegin))
    {
        itemCount = 
        static_cast<size_t>(m_readEnd - m_readBegin);
    }
    m_readBegin += itemCount;
    m_readEnd = m_readBegin;
    return ReadCapacity();
}
Enter fullscreen mode Exit fullscreen mode

This allows the client to tell us they're done reading itemCount items.
However they might just want to say they are rejecting the last itemCount items they previously requested to read. They'll still need to acknowledge the ones they do read to move the read_begin point forward.

size_t Buffer::ReadReject(size_t& itemCount)
{
    if(itemCount > (m_readEnd - m_readBegin))
    {
        itemCount = static_cast<size_t>(m_readEnd - m_readBegin);
    }
    m_readEnd -= itemCount;
    return ReadCapacity();
}
Enter fullscreen mode Exit fullscreen mode

We're almost there I promise. Just two more functions on this class to go.
All that's left is functions for the client to Request to Read, i.e. move the read_end point forward and to Request to Write, i.e. move the write_end point forward.

virtual byte* Buffer::WriteRequest(size_t& /*itemCount*/)
{
    return nullptr;
}

virtual byte* Buffer::ReadRequest(size_t& /*itemCount*/)
{
    return nullptr;
}
Enter fullscreen mode Exit fullscreen mode

What happened?
Well we can't actually write these functions because they need to return actual pointers to data. We could complicate the Buffer class by adding real memory handling but that wouldn't be sfumato.
Instead having gradually built up one layer and reached the point where the client facing interface is defined if not entirely implemented, we'll begin another layer.

Layer 2: All the pointer magic in one place

We'll use traditional Object Oriented extension by inheritance. A PODBuffer< pod_t > is a Buffer. We'll add a template parameter as still nobody has told us what goes in this Buffer.

template< class pod_t >
class PODBuffer : public Buffer
{
protected:

    pod_t* m_pAllocation{nullptr};
};
Enter fullscreen mode Exit fullscreen mode

Let's add some standard class furniture and then deal with memory management

template< class pod_t >
class PODBuffer : public Buffer
{
public:

    PODBuffer(size_t itemCount = 0) : 
        Buffer(sizeof(pod_t), itemCount)
    {
        SetCapacity(itemCount);
    }

    PODBuffer(const PODBuffer& src)
    {
        *this = src;
    }

    virtual ~PODBuffer()
    {
        //This is needed. Remind me to write an article
        //about why virtual functions don't work
        //in base class destructors.
        SetCapacity((size_t)0);
    }

    PODBuffer& operator = (const PODBuffer& src)
    {
        if(&src != this)
        {
            Buffer::operator = (src);
            memcpy(
                m_pAllocation, 
                src.m_pAllocation, 
                sizeof(pod_t) * std::min(
                    m_allocationCount, src.m_allocationCount
                )
            );
        }
        return *this;
    }

protected:

    pod_t* m_pAllocation{nullptr};
};
Enter fullscreen mode Exit fullscreen mode

When the pod_t type is determined it's size will be available to become the Buffer unit size. We could add a requires clause using type traits to ensure it's a fixed size type however std::is_pod is deprecated and I'm not sure which one to use in its place. std::is_standard_layout ? Let me know and I'll add it.

When we copy a PODBuffer, now we actually copy the contents as well. Taking care to use only the size of the smaller buffer.
However so far there are no contents. Lets fix that by overriding SetCapacity

virtual void SetCapacity(size_t itemCount)
{
    if(m_allocationCount == itemCount && m_pAllocation != nullptr)
    {
        return;
    }

    delete[] m_pAllocation;
    m_pAllocation = (itemCount > 0) ? 
        new pod_t[itemCount] : nullptr;

    memset(m_pAllocation, 0, sizeof(pod_t) * itemCount);
    Buffer::SetCapacity(itemCount);
}
Enter fullscreen mode Exit fullscreen mode

That's it. One new and one delete. Now those SetCapacity((size_t)0) calls we did earlier don't look so silly. For added security we fill the Buffer with zeros.
Let's override the failed implementation of WriteRequest now with one that returns a valid pointer.

virtual byte* WriteRequest(size_t& itemCount)
{
    pod_t* result = 0;
    if (itemCount == 0 || itemCount > WriteCapacity())
    {
        itemCount = WriteCapacity();
    }

    if (itemCount > 0)
    {
        result = AddressOf(m_writeBegin);
        m_writeEnd += itemCount;//write_end moves here
    }

    return reinterpret_cast<byte*>(result);
}
Enter fullscreen mode Exit fullscreen mode

It would be nice to return a pod_t* here but C++ doesn't do covariant return types so to override the truly ignorant Buffer::WriteRequest we have to return a byte*. The client can safely cast it back as they know what a pod_t is.

Note that a write request is just that. Requesting to write 65 items might get you space to write 5 or 0 if the buffer is full. the client will need to take note of the value of itemCount after the call. This is how many items they are allowed to write.

Requesting to read is very similar but first lets look at AddressOf

pod_t* AddressOf(size_t index)
{
    pod_t* result = nullptr;
    if (m_allocationCount > 0)
    {
        result = m_pAllocation + (index % m_allocationCount);
    }
    return result;
}
Enter fullscreen mode Exit fullscreen mode

This gives an offset into the allocated space in multiples of sizeof(pod_t) which is always within the allocated space.
Remember the + operator here is using pointer arithmetic rules. As m_pAllocation is a pod_t* so it adds in sizeof(pod_t*) sized units. Exactly what we want. ReadRequest uses the same function:

virtual byte* ReadRequest(size_t& itemCount)
{
    pod_t* result = AddressOf(m_readBegin);
    if (itemCount > ReadCapacity())
    {
        itemCount = static_cast<size_t>(ReadCapacity());
    }
    m_readEnd += itemCount;//read_end moves here
    return reinterpret_cast<byte*>(result);
}
Enter fullscreen mode Exit fullscreen mode

You may have wondered how we keep the read_begin, read_end, write_begin and write_end values within the allocated space. The answer is that we don't. We care that Read follows Write and that End is >= Begin but we don't care what the actual numeric values are. (index % m_allocationCount) gives the correct position in the buffer and limiting the Write Capacity to the allocation size is sufficient to ensure that write_end is never more than allocation count units ahead of read_begin so we don't don't tread on our own tail as the output of (index % m_allocationCount) wraps around.

This leaves us with just one problem. When we report Read and Write capacities to the client we don't take that wrap around into account. If the available space is split over the end of the buffer we can't give the client a contiguous block of memory to Read or Write. We could give them two but that would be a really weird interface. Here's most of the memory you asked for and here's the other bit which is usually null.

Instead we'll just behave as if the second part wasn't available. To do this we need to override WriteCapacity and ReadCapacity

virtual size_t WriteCapacity()
{
    size_t result = m_allocationCount - 
        static_cast<size_t>(m_writeEnd - m_readBegin);

    if(AddressOf(m_writeBegin) + result > EndOfBuffer())
    {
        result = EndOfBuffer() - AddressOf(m_writeBegin);
    }
    return result;
}

virtual size_t ReadCapacity()
{
    size_t result = static_cast<size_t>(m_writeBegin - m_readEnd);

    if(AddressOf(m_readEnd) + result > EndOfBuffer())
    {
        result = EndOfBuffer() - AddressOf(m_readEnd);
    }
    return result;
}

pod_t* EndOfBuffer(void)
{
    pod_t* result = nullptr;
    if (m_allocationCount > 0)
    {
        result = m_pAllocation + m_allocationCount;
    }
    return result;
}
Enter fullscreen mode Exit fullscreen mode

That's really it this time. Here's the whole PODBuffer< pod_t > class along with streaming >> operators << to easily insert and extract items.

template< class pod_t >
class PODBuffer : public Buffer
{
public:

    PODBuffer(size_t itemCount = 0) : 
        Buffer(sizeof(pod_t), itemCount), m_pAllocation(nullptr)
    {
        SetCapacity(itemCount);
    }

    PODBuffer(const PODBuffer& src)
    {
        *this = src;
    }

    virtual ~PODBuffer()
    {
        SetCapacity((size_t)0);
    }

    PODBuffer& operator = (const PODBuffer& src)
    {
        if(&src != this)
        {
            Buffer::operator = (src);
            memcpy(m_pAllocation, 
                src.m_pAllocation,
                sizeof(pod_t) * std::min(
                    m_allocationCount, src.m_allocationCount
                )
            );
        }
        return *this;
    }

    PODBuffer& operator << (const pod_t& item)
    {
        size_t count = 1;
        pod_t* pWrite = reinterpret_cast<pod_t*>
            WriteRequest(count));

        if(pWrite && count == 1)
        {
            *pWrite = item;
            WriteAcknowledge(count);
        }
        return *this;
    }

    PODBuffer& operator >> (pod_t& item)
    {
        size_t count = 1;
        pod_t* pRead = reinterpret_cast<pod_t*>
            (ReadRequest(count));

        if(pRead && count == 1)
        {
            item = *pRead;
            ReadAcknowledge(count);
        }

        return *this;
    }

    virtual size_t WriteCapacity()
    {
        size_t result = m_allocationCount - 
            static_cast<size_t>(m_writeEnd - m_readBegin);

        if(AddressOf(m_writeBegin) + result > EndOfBuffer())
        {
            result = EndOfBuffer() - AddressOf(m_writeBegin);
        }
        return result;
    }

    virtual size_t ReadCapacity()
    {
        size_t result = static_cast<size_t>(
            m_writeBegin - m_readEnd);

        if(AddressOf(m_readEnd) + result > EndOfBuffer())
        {
            result = EndOfBuffer() - AddressOf(m_readEnd);
        }
        return result;
    }

    virtual byte* WriteRequest(size_t& itemCount)
    {
        pod_t* pResult = 0;
        if (itemCount == 0 || itemCount > WriteCapacity())
        {
            itemCount = WriteCapacity();
        }

        if (itemCount > 0)
        {
            pResult = AddressOf(m_writeBegin);
            m_writeEnd += itemCount;
        }

        return reinterpret_cast<byte*>(pResult);
    }

    virtual byte* ReadRequest(size_t& itemCount)
    {
        pod_t* pResult = AddressOf(m_readBegin);
        if (itemCount > ReadCapacity())
        {
            itemCount = static_cast<size_t>(ReadCapacity());
        }
        m_readEnd += itemCount;
        return reinterpret_cast<byte*>(pResult);
    }

    virtual void SetCapacity(size_t itemCount)
    {
        if(m_allocationCount == itemCount 
            && m_pAllocation != nullptr)
        {
            return;
        }

        delete[] m_pAllocation;
        m_pAllocation = (itemCount > 0) ? 
            new pod_t[itemCount] : nullptr;

        memset(m_pAllocation, 0, sizeof(pod_t) * itemCount);
        Buffer::SetCapacity(itemCount);
    }

protected:

    pod_t* EndOfBuffer(void)
    {
        pod_t* pResult = nullptr;
        if (m_allocationCount > 0)
        {
            pResult = m_pAllocation + m_allocationCount;
        }
        return pResult;
    }

    pod_t* AddressOf(size_t index)
    {
        pod_t* result = nullptr;
        if (m_allocationCount > 0)
        {
            result = m_pAllocation + (index % m_allocationCount);
        }
        return result;
    }

    pod_t* m_pAllocation;
};
Enter fullscreen mode Exit fullscreen mode

and for completeness (because I'm not really very good at being lazy)

typedef PODBuffer< byte > ByteBuffer;
Enter fullscreen mode Exit fullscreen mode

The two hard things that catch everyone out the first few times doing buffers are the memory management and preventing overruns. It turns out that actual memory management requires just two lines of code.

delete[] m_pAllocation;
m_pAllocation = (itemCount > 0) ? new pod_t[itemCount] : nullptr;
Enter fullscreen mode Exit fullscreen mode

And buffer overruns are terminated forever by just one.

result = m_pAllocation + (index % m_allocationCount);
Enter fullscreen mode Exit fullscreen mode

We used the power of our ignorance of the real requirements to create a semi circular buffer class for any number of units of any fixed sized type. We left it up to the client to decide everything we didn't know and we delayed all the hard parts until they weren't hard or we didn't have to do them at all. We separated the task into layers that deal with different problems. Keeping every step as simple as possible.

#include <cstddef>
#include <cstdint>
#include <cstring>
#include <cassert>
#include <string>
#include "podbuffer.h"

struct test_s
{
    bool b;
    const char* s;
    unsigned long long l;
};

int main()
{
    PODBuffer<test_s> aBuffer(15);
    test_s test{ true, "something", 100000000 };
    aBuffer << test;
    test_s result;
    aBuffer >> result;
    assert(memcmp(&result, &test, sizeof(test_s)) == 0);
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Conclusion:

What are the costs and benefits of this sfumato approach to writing code?

The resulting code is certainly not compact. Although that doesn't matter as much in a compiled language like C++ as it would in Python or JS.
You can end up with many layers and it requires a well organised source tree not to loose track of them.

The performance has not been honed to the last clock cycle and cache line. I generally prefer to leave that to the optimising compiler which knows a great deal more about my hardware than I do. Is this buffer fast though? In practice you can count the nano seconds because they seldom reach 3 digits. Yes it's fast although you're welcome to make it faster and let me know how.

The functions are small and simple. You may have noticed that there's no single function in this article that's more than a handful of lines. This makes writing unit tests as you go along easy. This also makes tracking down bugs and generally understanding the code you wrote 5 years ago, easier.

You get reliable, readable, intentional, testable code with strong separation of concerns. So don't be afraid to write incomplete classes that only do part of the job. As long as the part they do is simple, safe and reliable. You can always add another layer of sfumato to solve the next problem. Abstracting each problem, or set of problems, into it's own thin layer leads to surprisingly pleasing results.


This PODBuffer< pod_t > class, or one very like it, is in use in the general data pipeline library of the QuerySoft Open Runtime. I wrote the first version of it more than a decade ago and it's changed very little. (That's why some of the code style is a bit out of date)

Do you have a favourite class or code snippet that you've been using for years across projects. Is it sfumato code? If you do please consider sharing it.

Here's a snippet of Buffer in real use. It was streaming JSON from a file to a parser at the time but you can't tell by looking.

bool Source::Pull(size_t& unitsRead, size_t unitsToRead)
{
    if(!unitsToRead)
    {
        return true;
    }

    Buffer* buffer = GetBuffer();
    if(buffer)
    {
        byte* space = buffer->WriteRequest(unitsToRead);
        if(!space)
        {
            log::debug("Buffer capacity: {0}, Write capacity: {1}, Read capacity: {2}, Unit size: {3}", 
                buffer->Capacity(), 
                buffer->WriteCapacity(), 
                buffer->ReadCapacity(), 
                buffer->GetUnitSize());
            continuable("Pipeline stall. No space in source buffer.");
            return false;
        }

        size_t bytesRead = ReadBytes(
            space, buffer->GetUnitSize() * unitsToRead);

        if(bytesRead > 0)
        {
            unitsRead = bytesRead / buffer->GetUnitSize();
            buffer->WriteAcknowledge(unitsRead);
            OnReadSuccess(unitsRead);
        }
        else
        {
            OnEndOfData();
        }
        return true;
    }
    else
    {
        return false;
    }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)