File organization
You should have three files:
bitspeak.hpp
, the header containing only the minimally necessary definitionsbitspeak.cpp
, the implementation of the functions defined in the headerbitspeak_test.cpp
, the tests for the implementation
bitspeak.hpp
#ifndef BITSPEAK_HPP
#define BITSPEAK_HPP
#include <string>
namespace bitspeak
{
std::string encode(const std::string &data);
std::string decode(const std::string &text);
}
#endif
This is the simplest definition of the interface that a caller needs. The std::string
class can handle all bytes, therefore it is suitable both for strings of char
, as well as for byte arrays.
bitspeak.cpp
Instead of writing the vows
and cons
in a single line, I rather like them to look like this:
static const std::string vows[32] = {
"a", "e", "i", "o", "u", "w", "y", "ae",
"ai", "ao", "au", "aw", "ay", "ea", "ee", "ei",
"eo", "eu", "ey", "ia", "io", "iu", "oa", "oi",
"ou", "ow", "oy", "ui", "uo", "uy", "we", "wi"
};
static const std::string cons[128] = {
"b", "c", "d", "f", "g", "h", "j", "k",
"l", "m", "n", "p", "q", "r", "s", "t",
"v", "x", "z", "bc", "bl", "br", "bs", "ch",
"chs", "cht", "ck", "cl", "cr", "cs", "ct", "fl",
"fr", "fs", "ft", "gl", "gm", "gr", "gs", "gz",
"hd", "hf", "hr", "hs", "ht", "hv", "jr", "js",
"jt", "kl", "kr", "kt", "lb", "lc", "ld", "lg",
"lm", "ln", "lp", "ls", "lt", "lv", "mb", "mn",
"mp", "mr", "nd", "nk", "nm", "np", "ns", "nt",
"nv", "nz", "pl", "pr", "ps", "pt", "pz", "rb",
"rc", "rch", "rd", "rf", "rg", "rk", "rl", "rm",
"rn", "rp", "rr", "rs", "rt", "rv", "rz", "sb",
"sc", "sch", "sd", "sf", "sh", "sl", "sm", "sn",
"sp", "sq", "sr", "st", "sth", "str", "sv", "sz",
"tch", "th", "tl", "tn", "tr", "ts", "vl", "vr",
"vs", "vz", "xc", "xz", "zd", "zl", "zp", "zt"
};
Grouping them in 8 per line lets the reader quickly check that these are indeed 32 respectively 128 strings.
The implementation of bitspeak::encode
can be done in a single pass without unnecessary copying:
namespace bitspeak {
std::string encode(const std::string &data)
{
std::ostringstream out;
std::size_t len = data.length();
for (std::size_t i = 0; i < len; i += 3) {
uint8_t d0 = data[i + 0];
uint8_t d1 = i + 1 < len ? data[i + 1] : 0;
uint8_t d2 = i + 2 < len ? data[i + 2] : 0;
uint8_t cc0 = (d0 & 0xFE) >> 1;
uint8_t vv0 = ((d0 & 0x01) << 4) | ((d1 & 0xF0) >> 4);
uint8_t cc1 = ((d1 & 0x0F) << 3) | ((d2 & 0xE0) >> 5);
uint8_t vv1 = d2 & 0x1F;
out << cons[cc0];
out << vows[vv0];
out << (i + 1 < len ? cons[cc1] : "?");
out << (i + 2 < len ? vows[vv1] : "?");
}
return out.str();
}
} // end of namespace
I have changed the following things:
- There is a single loop, which processes 3 bytes at a time (note that
i += 3
is used here instead of the usuali++
). - Instead of using
uint32_t
, the code usesstd::size_t
, which is exactly the type returned bystd::string.length()
. - The first block that defines
d0
,d1
andd2
makes sure that the data bytes are interpreted as unsigned. - They are then converted in the same way that you used in the
ca3_to_ca4
function. - The last block appends the consonants and vowels. Even in the last group, the first pair of consonants and vowels is always relevant, since the bits from
d0
influencecc0
andvv0
. The remaining characters may need to be replaced with the padding character. - The class
std::ostringstream
is suitable for efficient appending to a string.
For decoding a string of consonants and vowels into the original data, idiomatic C++ code does not copy much data around. Instead, it uses indexes or iterators into the strings, which are only pointers that can be efficiently passed around.
The basic idea is this:
- find a sequence of consonants
- find a sequence of vowels
- find a sequence of consonants
- find a sequence of vowels
- map the above sequences to indices
- bitshift the indices around to produce the original data
- repeat until the end of the encoded string
The padding needs to be handled specially since it is only allowed at the very end. And this is the code I came up with:
std::string decode(const std::string &text)
{
std::size_t i = 0;
std::size_t len = text.length();
std::ostringstream out;
while (i < len) {
auto cc0start = i;
while (i < len && con_l.find(text[i]) != std::string::npos)
i++;
auto vv0start = i;
while (i < len && vow_l.find(text[i]) != std::string::npos)
i++;
auto cc1start = i;
while (i < len && con_l.find(text[i]) != std::string::npos)
i++;
auto vv1start = i;
while (i < len && vow_l.find(text[i]) != std::string::npos)
i++;
if (i == cc0start)
throw "invalid encoded data (neither consonant nor vowel)";
auto cc0 = text.substr(cc0start, vv0start - cc0start);
auto vv0 = text.substr(vv0start, cc1start - vv0start);
auto cc1 = text.substr(cc1start, vv1start - cc1start);
auto vv1 = text.substr(vv1start, i - vv1start);
auto cons_end = cons + 128;
auto vows_end = vows + 32;
auto cc0bits = std::find(cons, cons_end, cc0) - cons;
auto vv0bits = std::find(vows, vows_end, vv0) - vows;
auto cc1bits = std::find(cons, cons_end, cc1) - cons;
auto vv1bits = std::find(vows, vows_end, vv1) - vows;
if (cc0bits == 128 || vv0bits == 32)
throw "invalid encoded data (unknown letter combination)";
unsigned char d0 = (cc0bits << 1) | ((vv0bits & 0x10) >> 4);
out << d0;
if (cc1bits == 128) {
if (text.substr(i) != "??")
throw "invalid encoded data (padding)";
break;
}
unsigned char d1 = ((vv0bits & 0x0F) << 4) | ((cc1bits & 0x78) >> 3);
out << d1;
if (vv1bits == 32) {
if (text.substr(i) != "?")
throw "invalid encoded data (padding)";
break;
}
unsigned char d2 = ((cc1bits & 0x07) << 5) | (vv1bits & 0x1F);
out << d2;
}
return out.str();
}
I know that the above code violates some best practice rules:
- it contains duplicate code in the
while
loops. I did this since extracting the loops to a separate function would add even more code. - it contains hard-coded numbers, which some readers may consider magic numbers. But since they are powers of 2 and mentioned in the specification, they are not magic, they are only numbers, like the other numbers in the bit-shifting code.
- these numbers are even repeated in several places. I did not make them constants since they will not change anyway. And even if they should do, each
128
in the code has exactly the same meaning, so they can all be changed doing a simple text replacement.
Your is_vowel
function can be shortened a bit.
bool is_vowel(char x) { bool o = false; std::size_t i; for (i = 0; i < vow_l.size(); i++) { if (x == vow_l[i]) { o = true; break; } } return o; }
bool is_vowel(char x) { std::size_t i; for (i = 0; i < vow_l.size(); i++) { if (x == vow_l[i]) { return true; } } return false; }
bool is_vowel(char x) { for (std::size_t i = 0; i < vow_l.size(); i++) { if (x == vow_l[i]) { return true; } } return false; }
bool is_vowel(char x) { return vow_l.find(x) != std::string::npos; }
Instead of using break
and a return value, it is often simpler to just return value
, e.g. in string_find
.