Difference between `TextOutputStreamable` and `CustomStringConvertible`?

i’ve got a protocol that looks something like:

public
protocol PlainTextRenderableMarkdown:
    TextOutputStreamable, 
    CustomStringConvertible
{
    var bytecode:MarkdownBytecode { get }
    ...
}

it refines both TextOutputStreamable and CustomStringConvertible, and provides default implementations for both:

extension PlainTextRenderableMarkdown
{
    @inlinable public
    func write(to stream:inout some TextOutputStream)
    {
        stream.write("\(self)")
    }
}
extension PlainTextRenderableMarkdown
{
    public
    var description:String
    {
        do
        {
            var utf8:[UInt8] = []
            try self.write(to: &utf8)
            return .init(decoding: utf8, as: Unicode.UTF8.self)
        }
        catch let error
        {
            return "<\(error)>"
        }
    }
}
extension PlainTextRenderableMarkdown
{
    func write(to utf8:inout [UInt8]) throws
    {
        ...
    }
}

but, is this a good idea in the first place? it is hard to tell what String.init(_:) overload gets called or if string interpolation can fall into infinite recursion (it will). and TextOutputStreamable appears to me quite redundant with CustomStringConvertible.

1 Like

My understanding is that the two protocols offer a tradeoff between simplicity and efficiency.

When your text always follows the same format, you can very easily provide a description property. But when there’s a variable number of pieces (e.g. when writing an array), and then that object is itself interpolated into a longer string, writing to an output stream can reduce the number of (re)allocations required.

Besides that, TextOutputStreamable is more straightforward for use in functions like print(_:to:).

2 Likes

i see, i suppose it is quite pointless if self can only be materialized as a single string. TextOutputStream would be a lot more useful if it were possible to append UTF-8 code units directly to the string, instead of having to accumulate into a temporary [UInt8] buffer and then copy+validate to String storage. sigh…

This is a good question and points to a shortcoming in the design and documentation of these protocols. The key difference between the two should be (but it is not clearly spelled out in the semantic requirements of the protocols) CustomStringConvertible is about a reasonable String representation of an item in the context of the running system (mostly print() function calls), while TextOutputStreamable is about transmitting (streaming) the whole value of the item (which is debatable what it means for reference types).

For simple items such as Int, these two are practically indistinguishable. But it can be very different if we are talking about a large and complex aggregate item. The name of CustomStringConvertible is very unfortunate. The best clue to what it really means actually comes from its sole requirement: description. It is what you get when you print an item. Ideally it will be its value, but as we know, it is not always the case. Actually, it cannot always be the case if we want to keep the output of print() function within reasonable limits. In this case, the representation does not have to be the same as the whole value of the item (whose streamable text value might take up several gigabytes of memory).

On the other hand, if a type claims conformance to TextOutputStreamable, you should have the whole value of the item at the receiving end of the stream and be able to reconstruct the actual value.

In your specific example, I think description and the streamed value do not need to be the same.

I believe TextOutputStreamable should have had a way of incrementally streaming very large values without making us load the whole (potentially multi-gigabyte) text value in the memory just to write it out.

2 Likes