It took me a while to figure out what is really going on here, because the explanation is either incomplete, incorrect, or both.
In fact, the 12-bit values are not being read in the order shown, are they? From what I can suss, the order is actually like this:
┌───────┬───────────┬───────────┐
│ 3 │ 2 │ 1 │
├───┬───┴───────┬───┴───────┬───┤
│ 6 │ 5 │ 4 │ 3 │
├───┴───────┬───┴───────┬───┴───┤
│ 8 │ 7 │ 6 │
└───────────┴───────────┴───────┘
Given that, I’d say your transform algorithm is wildly over-complicated. It actually looks like quite a simple transform:
- Take the low 12 bits.
- Shift right by 12 bits.
- If there less than 12 bits left:
- Take as many bits as are left.
- Read the next 32 bits.
- Take as many bits needed from the low bits of that to get your 12 bits.
- Shift right by as many bits as you took.
- If there is more data, go to 1.
And in code:
auto f(std::vector<std::uint32_t> const& data)
{
constexpr auto bits_to_read = 32;
constexpr auto bits_to_write = 12;
auto output = std::vector<std::uint16_t>{};
auto next = data.begin();
auto const end = data.end();
auto input_word = std::uint32_t{};
auto bits_left = 0;
while (next != end and bits_left != 0)
{
// Case 1: No more bits left, so read the next input word.
if (bits_left == 0)
{
input_word = *next++;
bits_left = bits_to_read;
}
// Case 2: Enough bits left for a 12-bit read.
if (bits_left >= bits_to_write)
{
output.push_back(std::uint16_t(0xFFFu & input_word));
input_word >>= bits_to_write;
bits_left -= bits_to_write;
}
// Case 3: Not enough bits left for a 12-bit read.
else
{
if (next == end)
throw std::runtime_error{"not enough input words"};
// Helper function to compute masks:
auto const mask_for_bits = [](auto bits)
{
switch (bits)
{
case 4:
return 0xFu; // rude!
case 8:
return 0xFFu;
// Don't need any more cases, though should add a default
// just to be safe.
default:
assert(not "should never get here");
}
// Or you could generalize:
// auto mask = 0;
// while (bits--)
// mask = (mask << 1) | 1;
// return mask;
// Or do both.
};
// Write what we have so far.
output.push_back(std::uint16_t(mask_for_bits(bits_left) & input_word));
// Get the next 32-bit input word.
input_word = *next++;
// Get the bits needed from the input word, shift them to the
// right place, and OR them with what we have so far.
auto const bits_needed = bits_to_write - bits_left;
output.back() |= (input_word & mask_for_bits(bits_needed)) << bits_left;
// Shift the input word to remove the bits we just used, and
// update the count of bits left.
input_word >>= bits_needed;
bits_left = bits_to_write - bits_needed;
}
}
return output;
}
Obviously not tested or anything.
Note that the function above is almost perfectly generalized. If you read in a different amount of bits than 32 in each input chunk, or if you want to output something other than 12 bit chunks, you can change those constants. And the guts of the function is based on iterators, which means it could work with anything, not just vectors.
That brings me to main point of my review, which is that it makes no sense for this to be a class. This is clearly a job for a function.
When I look at the usage:
Reader r; //
r.Read12BitWords( stream ); // <-- ACTUAL USAGE !!!
auto rw = r.GetReadWords(); //
That’s just appalling. Three lines to do a simple transform?
(I’m not thrilled with @Martin York’s improvement either:
std::vector<std::uint16_t> data;
Reader reader(data);
reader.read12BitWords(std::begin(stream), std::end(stream));
Much better, but still far from good.)
Here’s what I’d like to see:
transform_32bit_words_to_12bit_words(data, output);
where output
is whatever you want to put the output into. Want it in a vector? Then:
auto rw = std::vector<std::uint16_t>{};
transform_32bit_words_to_12bit_words(data, std::back_inserter{rw});
// Or, to avoid repeated allocations:
auto rw = std::vector<std::uint16_t>((data.size() / 3 ) * 4);
transform_32bit_words_to_12bit_words(data, rw.begin());
The name transform_32bit_words_to_12bit_words
is open for bike-shedding. The point is that when designing an interface for whatever it is you’re trying to do, you should make it as ergonomic as possible.
Here’s an example of the interface I might make for this function:
template <typename I, typename O>
struct transform_32bit_words_to_12bit_words_result
{
[[no_unique_address]] I in;
[[no_unique_address]] O out;
[[no_unique_address]] std::size_t bits_remaining;
constexpr auto complete() const noexcept { return bits_remaining == 0; }
};
template <std::input_iterator I, std::sentinel_for<I> S, std::weakly_incrementable O>
requires unsigned_integer_with_minimum_bit_size<std::ranges::iter_value_t<I>, 32>
constexpr auto transform_32bit_words_to_12bit_words(I first, S last, O out) -> transform_32bit_words_to_12bit_words_result<I, O>;
template <std::ranges::input_range R, std::weakly_incrementable O>
requires unsigned_integer_with_minimum_bit_size<std::ranges::range_value_t<R>, 32>
constexpr auto transform_32bit_words_to_12bit_words(R&& range, O out) -> transform_32bit_words_to_12bit_words_result<std::ranges::borrowed_iterator_t<R>, O>;
bits_remaining != 0
would indicate a partial read. So, you could do:
if (transform_32bit_words_to_12bit_words(data, out).complete())
// a perfect read
else
// report a partial read error
The unsigned_integer_with_minimum_bit_size<T, N>
concept should be self-explanatory. You should probably also constrain the output iterator, but that’s too much to think about for now.
Hell, I might even go the whole hog and make it all completely generic:
template <typename I, typename O>
struct transform_words_result
{
[[no_unique_address]] I in;
[[no_unique_address]] O out;
[[no_unique_address]] std::size_t bits_remaining;
constexpr auto complete() const noexcept { return bits_remaining == 0; }
};
template <
std::size_t InputWordSize,
std::size_t OutputWordSize,
std::input_iterator I,
std::sentinel_for<I> S,
std::weakly_incrementable O
>
requires valid_word_size<InputWordSize>
and valid_word_size<OutputWordSize>
and unsigned_integer_with_minimum_bit_size<std::ranges::iter_value_t<I>, InputWordSize>
constexpr auto transform_words(I first, S last, O out) -> transform_words_result<I, O>
{
using output_type = /* you'll need something to figure this out */;
auto const mask_for_bits = [](auto bits)
{
auto mask = std::ranges::iter_value_t<I>(0);
while (bits--)
mask = (mask << 1) | 1;
return mask;
};
auto input_word = std::ranges::iter_value_t<I>{};
auto bits_left = std::size_t(0);
while (first != end and bits_left != 0)
{
// Case 1: No more bits left, so read the next input word.
if (bits_left == 0)
{
input_word = *first++;
bits_left = InputWordSize;
}
// Case 2: Enough bits left for a read.
if (bits_left >= OutputWordSize)
{
*out++ = output_type(mask_for_bits(OutputWordSize) & input_word);
input_word >>= OutputWordSize;
bits_left -= OutputWordSize;
}
// Case 3: Not enough bits left for a read.
else
{
if (first == end)
break;
// Write what we have so far.
auto current = output_type(mask_for_bits(bits_left) & input_word);
// Get the next input word.
input_word = *first++;
// Get the bits needed from the input word, shift them to the
// right place, and OR them with what we have so far.
auto const bits_needed = OutputWordSize - bits_left;
current |= (input_word & mask_for_bits(bits_needed)) << bits_left;
// Shift the input word to remove the bits we just used, and
// update the count of bits left.
input_word >>= bits_needed;
bits_left = OutputWordSize - bits_needed;
// Write the value.
*out++ = current;
}
}
return { first, out, bits_left };
}
template <
std::size_t InputWordSize,
std::size_t OutputWordSize,
std::ranges::input_range R,
std::weakly_incrementable O
>
requires valid_word_size<InputWordSize>
and valid_word_size<OutputWordSize>
and unsigned_integer_with_minimum_bit_size<std::ranges::range_value_t<R>, 32>
constexpr auto transform_words(R&& range, O out) -> transform_words_result<std::ranges::borrowed_iterator_t<R>, O>
{
return transform_words<InputWordSize, OutputWordSize>(std::ranges::begin(range), std::ranges::end(range), out);
}
Usage:
transform_words<32, 16>(data, output);
I might also add a second function, maybe called transform_words_size
that doesn’t actually do the transform, but rather just computes how many words will be read/written. That will allow you to pre-allocate, or at least know how many outputs will happen. But that’s a bit dodgy, because if you’re not careful, a clumsy user might try to use the size function and then the actual transform function on an input range… which won’t work (you only get a single pass over an input range).
Another, interesting interface option is: instead of an algorithm, make a custom view. Unfortunately, it isn’t possible yet to make custom views (we’ll need C++23 for that, or you can use ranges-v3), but to give a taste of how that might look:
for (auto&& value : data | transform_words_view<32, 16>)
std::cout << value;
Or with a more complex example:
// Clip the transformed data, and invert it:
using namespace std::ranges::views;
auto const clip = [](auto v) { return std::min(v, 100); }
auto const invert = [](auto v) { return 0xFFFu - v; }
for (auto&& value : data | transform_words_view<32, 16> | views::filter(clip) | views::transform(invert))
std::cout << value;
Yet another option is a coroutine generator. You could technically implement that today… but I’d wait until we get a pre-made generator interface in the standard library.
Any of these options are both easier to use, and more flexible than a class that requires two member function calls and an external vector. And most will probably be substantially more efficient even in the same usage scenario (that is, reading from a vector<uint32_t>
, writing to a vector<uint16_t>
).