RxCpp and copy operations
I’ve decided to investigate rxcpp library and get some better understanding of what is going on behind the scene with copy/move operations the library will do for the emitted objects.
For that I’ve written a simple hello world like app, just to get the ball rolling:
#include <rxcpp/rx.hpp>
#include <iostream>
int main()
{
auto o = rxcpp::observable<>::from<int>(1,2,3);
o.subscribe([](int item)
{
std::cout << item << std::endl;
});
return 0;
}
When compiling and running (assuming rxcpp
sources are in the include dirs and C++11 features set is ON), we get this output:
1
2
3
Ok, nothing surprising, knowing reactive source and subscription, that’s pretty much expected - an reactive equivalent of iterating over fixed set of values and printing them out.
Objects
Off course with primitive types like int
there isn’t much to look after,
but let’s switch to custom classes and let’s start to emit some objects ar the run-time.
#include <rxcpp/rx.hpp>
#include <iostream>
struct Foo
{
int value;
};
int main()
{
auto o = rxcpp::observable<>::from<int>(Foo{1}, Foo{2}, Foo{3});
o.subscribe([](Foo item)
{
std::cout << item.value << std::endl;
});
return 0;
}
We’ll get the same result, right?
Right. At this point everything looks fairly simple, since we’re having an reactive equivalent of simple iterating over collection of Foo
objects.
Now, let’s have a peek to what is happening between the point of where we’ve instantiated
our Foo
objects (1..3) to the point where we’re printing their values to the stdout.
To do so, let’s introduce:
- a regular parametrised constructor
- a copy constructor
- a move constructor
- a destructor
and let’s make each of them printing something meaningful to the console.
Out Foo
class will look like this now:
struct Foo
{
int value;
Foo(int initial) : value (initial) { std::cout << "Foo: constructed from int " << initial << std::endl; }
Foo(const Foo& other) : value(other.value) { std::cout << "Foo: COPY constructed from other Foo with " << other.value << std::endl; }
Foo(Foo&& other) : value(other.value) { std::cout << "Foo: MOVE constructor for Foo with " << other.value <<std::endl; }
~Foo() { std::cout << "Foo: object with " << value << " destroyed " << std::endl; }
};
When we build and run our example, we get this:
Foo: constructed from int 3
Foo: constructed from int 2
Foo: constructed from int 1
Foo: COPY constructed from other Foo with 3
Foo: COPY constructed from other Foo with 2
Foo: COPY constructed from other Foo with 1
Foo: COPY constructed from other Foo with 1
Foo: COPY constructed from other Foo with 2
Foo: COPY constructed from other Foo with 3
Foo: MOVE constructor for Foo with 1
Foo: MOVE constructor for Foo with 2
Foo: MOVE constructor for Foo with 3
Foo: MOVE constructor for Foo with 1
Foo: MOVE constructor for Foo with 2
Foo: MOVE constructor for Foo with 3
Foo: MOVE constructor for Foo with 1
Foo: MOVE constructor for Foo with 2
Foo: MOVE constructor for Foo with 3
Foo: MOVE constructor for Foo with 1
Foo: MOVE constructor for Foo with 2
Foo: MOVE constructor for Foo with 3
Foo: object with 3 destroyed
Foo: object with 2 destroyed
Foo: object with 1 destroyed
Foo: MOVE constructor for Foo with 1
Foo: MOVE constructor for Foo with 2
Foo: MOVE constructor for Foo with 3
Foo: MOVE constructor for Foo with 1
Foo: MOVE constructor for Foo with 2
Foo: MOVE constructor for Foo with 3
Foo: object with 3 destroyed
Foo: object with 2 destroyed
Foo: object with 1 destroyed
Foo: object with 3 destroyed
Foo: object with 2 destroyed
Foo: object with 1 destroyed
Foo: object with 3 destroyed
Foo: object with 2 destroyed
Foo: object with 1 destroyed
Foo: MOVE constructor for Foo with 1
Foo: MOVE constructor for Foo with 2
Foo: MOVE constructor for Foo with 3
Foo: object with 3 destroyed
Foo: object with 2 destroyed
Foo: object with 1 destroyed
Foo: object with 3 destroyed
Foo: object with 2 destroyed
Foo: object with 1 destroyed
Foo: object with 3 destroyed
Foo: object with 2 destroyed
Foo: object with 1 destroyed
Foo: MOVE constructor for Foo with 1
Foo: MOVE constructor for Foo with 2
Foo: MOVE constructor for Foo with 3
Foo: object with 3 destroyed
Foo: object with 2 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 2 destroyed
Foo: object with 3 destroyed
Foo: MOVE constructor for Foo with 1
Foo: MOVE constructor for Foo with 2
Foo: MOVE constructor for Foo with 3
Foo: object with 3 destroyed
Foo: object with 2 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 2 destroyed
Foo: object with 3 destroyed
Foo: COPY constructed from other Foo with 1
Foo: COPY constructed from other Foo with 2
Foo: COPY constructed from other Foo with 3
Foo: COPY constructed from other Foo with 1
Foo: COPY constructed from other Foo with 2
Foo: COPY constructed from other Foo with 3
Foo: COPY constructed from other Foo with 1
Foo: COPY constructed from other Foo with 2
Foo: COPY constructed from other Foo with 3
Foo: COPY constructed from other Foo with 1
Foo: COPY constructed from other Foo with 2
Foo: COPY constructed from other Foo with 3
Foo: COPY constructed from other Foo with 1
Foo: COPY constructed from other Foo with 2
Foo: COPY constructed from other Foo with 3
Foo: object with 3 destroyed
Foo: object with 2 destroyed
Foo: object with 1 destroyed
Foo: object with 3 destroyed
Foo: object with 2 destroyed
Foo: object with 1 destroyed
Foo: COPY constructed from other Foo with 1
Foo: COPY constructed from other Foo with 2
Foo: COPY constructed from other Foo with 3
Foo: object with 3 destroyed
Foo: object with 2 destroyed
Foo: object with 1 destroyed
Foo: COPY constructed from other Foo with 1
Foo: COPY constructed from other Foo with 2
Foo: COPY constructed from other Foo with 3
Foo: COPY constructed from other Foo with 1
Foo: COPY constructed from other Foo with 2
Foo: COPY constructed from other Foo with 3
Foo: COPY constructed from other Foo with 1
Foo: COPY constructed from other Foo with 2
Foo: COPY constructed from other Foo with 3
Foo: object with 3 destroyed
Foo: object with 2 destroyed
Foo: object with 1 destroyed
Foo: object with 3 destroyed
Foo: object with 2 destroyed
Foo: object with 1 destroyed
Foo: COPY constructed from other Foo with 1
Foo: MOVE constructor for Foo with 1
1
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: COPY constructed from other Foo with 2
Foo: MOVE constructor for Foo with 2
2
Foo: object with 2 destroyed
Foo: object with 2 destroyed
Foo: COPY constructed from other Foo with 3
Foo: MOVE constructor for Foo with 3
3
Foo: object with 3 destroyed
Foo: object with 3 destroyed
Foo: object with 3 destroyed
Foo: object with 2 destroyed
Foo: object with 1 destroyed
Foo: object with 3 destroyed
Foo: object with 2 destroyed
Foo: object with 1 destroyed
Foo: object with 3 destroyed
Foo: object with 2 destroyed
Foo: object with 1 destroyed
Foo: object with 3 destroyed
Foo: object with 2 destroyed
Foo: object with 1 destroyed
Foo: object with 3 destroyed
Foo: object with 2 destroyed
Foo: object with 1 destroyed
Sweet lord!!!
Surprised? Well, I was.
It looks like there are plenty of things to start worrying about - or at least to think about when making use of rxcpp
.
Things are probably acceptable when the objects passed around are as simple as our Foo
is for the moment, but just imagine,
what could be the consequences when the objects are large (take a lot of memory) and when constructing/copying and/or destroying is a long and costful operation.
Hey, it’s C++ with its object passing by value strategy. You’ve seen that before…
Before we analyse of what’s going on further down the road, we can already put a first conclusion here.
When you want to do reactive programming in C++ with rxcpp
with concrete objects passed by, then you should always check whether you are prepared for a rather massive and not always clearly foreseeable amount of copying, construction and destructions going on, and make sure your classes are designed for that.
Let’s now simplify our example to having just one object emitted and let’s use our Foo
tracing abilities to check what is happening on a minimum scale.
Our observable will emit just one Foo
instance:
auto o = rxcpp::observable<>::from<int>(Foo{1});
This gives us the following trace to the stdout:
Foo: constructed from int 1
Foo: COPY constructed from other Foo with 1
Foo: COPY constructed from other Foo with 1
Foo: MOVE constructor for Foo with 1
Foo: MOVE constructor for Foo with 1
Foo: MOVE constructor for Foo with 1
Foo: MOVE constructor for Foo with 1
Foo: object with 1 destroyed
Foo: MOVE constructor for Foo with 1
Foo: MOVE constructor for Foo with 1
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: MOVE constructor for Foo with 1
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: MOVE constructor for Foo with 1
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: MOVE constructor for Foo with 1
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: COPY constructed from other Foo with 1
Foo: COPY constructed from other Foo with 1
Foo: COPY constructed from other Foo with 1
Foo: COPY constructed from other Foo with 1
Foo: COPY constructed from other Foo with 1
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: COPY constructed from other Foo with 1
Foo: object with 1 destroyed
Foo: COPY constructed from other Foo with 1
Foo: COPY constructed from other Foo with 1
Foo: COPY constructed from other Foo with 1
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: COPY constructed from other Foo with 1
Foo: MOVE constructor for Foo with 1
1
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Ok, easier to follow now.
First interesting bit is that after 1
has been put to the std::cout
in the subscriber’s lambda, we still later see 7 instances of Foo
destroyed. Wow. Sounds like a lot to me.
To be fair, our subscriber uses plain Foo
as an argument, so at least one copy is due to argument passing by value there. Let’s change the subsriber’s lambda to use const reference, and see how that changes things.
o.subscribe([](const Foo& item)
{
std::cout << item.value << std::endl;
});
As expected, we get this:
...
1
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
OK, we’ve saved one, where we could.
Let’s see how that the picture with one instance being emitted changes after we introduce some additional
operators between the from
observable source and the subscriber.
Let’s add take(1)
, which shuld be logicall invariant here, and let’s see what happens.
With:
auto o = rxcpp::observable<>::from<Foo>(Foo{1})
.take(1);
We’re getting this:
...
1
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Ok, so now, after adding just one operator we’re having +3 Foo
instances living somewhere in the pipeline.
Let’s continue with an extra filter operator (this time also using const reference in the lambda, to make sure we don’t introduce extra copying on argument passing there):
auto o = rxcpp::observable<>::from<Foo>(Foo{1})
.take(1)
.filter([](const Foo& foo){ return true; });
The results are:
...
1
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Ok, this resulted with +1 extra instance. Let’s give another logical invariant operation: skip(0)
to the process:
auto o = rxcpp::observable<>::from<Foo>(Foo{1})
.take(1)
.filter([](const Foo& foo){ return true; })
.skip(0);
results are:
1
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Foo: object with 1 destroyed
Now, +3 extra instances.
Hmm. The next conclusion emerges.
It looks like it is quite hard to predict the exact influence on number of copings/moves that happen when introducing additional reactive operators.
Shared pointers
We could avoid all those problematic behaviours by dropping by-value passing and by switching to shared pointers instead.
Let’s change the code to use dynamically allocated objects and std::shared_ptr
to control it’s ownership and lifetime.
#include <rxcpp/rx.hpp>
#include <iostream>
#include <memory>
struct Foo
{
int value;
Foo(int initial) : value (initial) { std::cout << "Foo: constructed from int " << initial << std::endl; }
Foo(const Foo& other) : value(other.value) { std::cout << "Foo: COPY constructed from other Foo with " << other.value << std::endl; }
Foo(Foo&& other) : value(other.value) { std::cout << "Foo: MOVE constructor for Foo with " << other.value <<std::endl; }
~Foo() { std::cout << "Foo: object with " << value << " destroyed " << std::endl; }
};
int main()
{
auto o = rxcpp::observable<>::from<std::shared_ptr<Foo>>(std::make_shared<Foo>(1),std::make_shared<Foo>(2),std::make_shared<Foo>(3))
.take(3)
.filter([](std::shared_ptr<Foo> foo){ return true; })
.skip(0);
o.subscribe([](std::shared_ptr<Foo> item)
{
std::cout << item->value << std::endl;
});
return 0;
}
Ok, iterating over three objects dyanamically created gives the following trace now:
Foo: constructed from int 3
Foo: constructed from int 2
Foo: constructed from int 1
1
2
3
Foo: object with 3 destroyed
Foo: object with 2 destroyed
Foo: object with 1 destroyed
How beautiful! And actually as executed, since all copying/moves are happening now on the smart pointer objects rather than the pointee (Foo instance).
One more thing, when looking to the trace above (as well as the ones from the section before), you see that the iteration 1..3 is happening in the expected order, but the object construction is in reverse (Foo(3)
is constructed first).
That is because of how Observable::from
source is implemented. It is a variadic template function and I expect that it’s been calling recursively template specialisations, unwinding the tail element first at each recursion level.
Surprisingly, destruction sequence is not a perfect mirror of it. I would personally find more intuitive if objects constructed first are released as last, but that’s not the case here. Could that be a problem in a real-life scenario? Probably not.
Ok, time for even deeper investigation.
Let’s imaging we add another map
operator that converts from Foo
to a new type of object - Bar
somewhere in the pipeline.
Let’s add Bar
class that can be instantiated from Foo
and carry on it’s integer value. The example code would look like this:
#include <rxcpp/rx.hpp>
#include <iostream>
#include <memory>
struct Foo
{
int value;
Foo(int initial) : value (initial) { std::cout << "Foo: constructed from int " << initial << std::endl; }
Foo(const Foo& other) : value(other.value) { std::cout << "Foo: COPY constructed from other Foo with " << other.value << std::endl; }
Foo(Foo&& other) : value(other.value) { std::cout << "Foo: MOVE constructor for Foo with " << other.value <<std::endl; }
~Foo() { std::cout << "Foo: object with " << value << " destroyed " << std::endl; }
};
struct Bar
{
int value;
Bar(const Foo& src) : value (src.value) { std::cout << "Bar: constructed from Foo with " << src.value << std::endl; }
Bar(const Bar& other) : value(other.value) { std::cout << "Bar: COPY constructed from other Bar with " << other.value << std::endl; }
Bar(Bar&& other) : value(other.value) { std::cout << "Bar: MOVE constructor for Bar with " << other.value <<std::endl; }
~Bar() { std::cout << "Bar: object with " << value << " destroyed " << std::endl; }
};
int main()
{
auto o = rxcpp::observable<>::from<std::shared_ptr<Foo>>(std::make_shared<Foo>(1),std::make_shared<Foo>(2),std::make_shared<Foo>(3))
.take(3)
.filter([](std::shared_ptr<Foo> foo){ return true; })
.map([](std::shared_ptr<Foo> foo){ return std::make_shared<Bar>(*foo); })
.skip(0);
o.subscribe([](std::shared_ptr<Bar> item)
{
std::cout << item->value << std::endl;
});
return 0;
}
The results of this program execution is:
Foo: constructed from int 3
Foo: constructed from int 2
Foo: constructed from int 1
Bar: constructed from Foo with 1
1
Bar: object with 1 destroyed
Bar: constructed from Foo with 2
2
Bar: object with 2 destroyed
Bar: constructed from Foo with 3
3
Bar: object with 3 destroyed
Foo: object with 3 destroyed
Foo: object with 2 destroyed
Foo: object with 1 destroyed
The interesting bit is that the Bar objects have short lifetime, only from the moment when they are constructed in the pipeline (map
operator) until the point when they have been consumed by the subscriber. That is nice and well expected, since reactive programming is indeed about processing data as the they arrive, one at a time.
A bit surprise on that the Foo objects live longer, until entire processing is complete. This might be explain by probably having an internal list of shared pointers the Observable::from
implementation is keeping for it’s lifetime.
Perhaps rxcpp
could be improved in this place, so that when particular elements are emitted, elements can be removed from that inner list and destructors of Foo
are called immediately after, e.g. like this:
Foo: constructed from int 3
Foo: constructed from int 2
Foo: constructed from int 1
Bar: constructed from Foo with 1
1
Bar: object with 1 destroyed
Foo: object with 1 destroyed <-- expected here
Bar: constructed from Foo with 2
2
Bar: object with 2 destroyed
Foo: object with 2 destroyed <-- expected here
Bar: constructed from Foo with 3
3
Bar: object with 3 destroyed
Foo: object with 3 destroyed <!-- no change
That would give IMO more natural behaviour (as for reactive data processing), with as little kept in memory at a time as necessary to proceed.
Final conclusion
Use dynamic allocation and smart pointers when using rxcpp
to process non-primitive data types, unless you really have a good reason and time to investigate exact impact on your classes lifetime, resource consumption to do it differently.