"For Historical Reasons": Go String and Int Conversion
Damilola Israel Oluwole and I have started learning Go, and decided to play around with the language to write a toy implementation of Run-length encoding. While working on this little project we ran into a corner case with Go type conversion, which turns out to be documented in the Go spec but neither of us were aware of.
Run-length encoding is a simple lossless text compression algorithm which
encodes each sequence of n identical characters c into the substring “cn”.
For example, “Hello” is encoded as “H1e1l2o1”, and “AAAAAAAAAAH” is encoded as
“A10H1”. Obviously in the worst case, a string with no adjacent identical
characters, an RLE encoding of a string of length n requires length 2n.
However, in the best case, a string which consists of a single sequence of a
single character, the encoded length scales as the square root log of the
original length. (Thank you to Charles Eckman for the correction!) Our
implementation can be found on Github: https://github.com/dmoerner/go-rle.
But in this blog post I want to briefly cover something that tripped us up. We store the result in a rune slice, and then keep track of the last character written and the sequential count of such counters. We then load a new character into a buffer, and if it’s distinct from the current sequence, we append the count and then the new character to the result slice, and restart the count. Here is a naive way to do this:
if buffer != last {
result = append(result, rune(count)) // critical line
result = append(result, buffer)
last = buffer
count = 1
}
However, this does not work, and nor should we expect it to. In Go, a rune is
an integer encoding a Unicode code point. Our count
variable is not a
representation of a Unicode code point, it’s an integer. Converting a count
representing 65 letters to a rune with rune()
will result in the Unicode code
point 0x41
, the letter ‘A’, which is not our intention.
Fortunately we realized this quite quickly, and I thought that we could solve it by first converting the count into a string literal, and then converting that into a proper rune slice. This can then be appended using a spread:
result = append(result, rune[](string(count))...)
However, this produces the same result! What’s appended is the Unicode code
point represented by count
. What’s worse, we were mostly testing with small
test cases. It turns out that the lowest Unicode Characters in the single
digits are all control
characters like “End
of Text” (U+0003). If you try to print them out following a standard debugging
procedure of littering your code with print statements, nothing is printed and
your debugging is not going very well.
This really stumped us, including with Googling, until we finally came across a
hint: To use fmt.Sprintf
to convert the integer to a string instead:
countString := fmt.Sprintf("%d", count)
result = append(result, []rune(countString)...)
This worked, but we didn’t understand why. It wasn’t even easy to search for an
answer; for example, the Go builtin docs do not list a
string()
function but only the type. Thanks to some users on IRC for noting
that the answer lies in the Go
spec:
Finally, for historical reasons, an integer value may be converted to a string type. This form of conversion yields a string containing the (possibly multi-byte) UTF-8 representation of the Unicode code point with the given integer value. Values outside the range of valid Unicode code points are converted to “\uFFFD”. […Examples Omitted…] Note: This form of conversion may eventually be removed from the language. The go vet tool flags certain integer-to-string conversions as potential errors. Library functions such as utf8.AppendRune or utf8.EncodeRune should be used instead.
“For historical reasons”, string(int)
behaves the same as rune(int)
. In
fact, from what I understand (although I’d like to learn more about this),
tools like string()
are not actually Go functions at all but primitives built
into the language.
The first moral of this story is to always read the spec. Although Go has excellent documentation, it’s organized around Go modules. Something low-level like this is documented in the specification itself.
The second moral of this story is that the language server protocol
gopls is not a complete
replacement for go vet. The two are
complementary. As the spec notes, go vet
would have caught our error
immediately and suggested the solution, but neither of thought to run it.
The third moral of this story is to rethink what a “simple” test case looks like. We thought that we could debug the problem by focusing on the most simple test cases like “a”. But this meant we were only looking at the Unicode Start of Heading character, which looks like nothing at all! If we had built up a much larger set of test strings that got out of the Unicode control characters, our debugging would have likely gone faster.