|
Excursion (click to expand): Basics of object construction, value/reference semantics and related best practices in C++ (recommended for C++ beginners)
Objective: Learn how to create a server component with an ice interface and a client component communicating via remote procedure calls (RPC) in ArmarX.
Previous Tutorials: Create a "Hello World!" Component in ArmarX (C++), Understand Distributed Systems
Next Tutorials: Write a Publisher and Subscriber Communicating via Topics (C++)
Reference Code: component_tutorials
In this tutorial, you will implement two components communicating in the server-client paradigm (i.e. using remote procedure calls). For the sake of simplicity, the server will provide a service to generate random numbers.
We will use the following names:
random_number_server
random_number_client
To create a component, you need an ArmarX package. If you followed Create a "Hello World!" Component in ArmarX (C++), you can use the package created there:
If you want to create a new package, refer to Create a "Hello World!" Component in ArmarX (C++) or the armarx-package.html for how to do that.
For this tutorial text, we will assume the ArmarX package is called component_tutorials
.
First, we create and implement the server component. In the root directory of the ArmarXPackage, use armarx-package
to create a new component called random_number_server
:
Try to build the fresh component: Navigate to the build directory of the package and run cmake and make.
Navigate to the directory where the component was created: component_tutorials/source/components/random_number_server/
. Look what is inside:
You may recognize the files from the "Hello World!" tutorial, but let us recap here:
CMakeLists.txt
: The "manifest" file required by CMake to configure the project.Component.cpp
: The C++ source file containing definitions of functions and variables.Component.h
: The C++ header file containing declarations of classes, variables and functions.ComponentInterface.ice
: A strange file we know nothing about.Now, the last point may not be up-to-date anymore! In the previous tutorial (../102_distributed_systems/README.md "Understand Distributed Systems"), you learned about Ice, the communication system used by ArmarX, and Slice, the Specification Language of Ice. The file ComponentInterface.ice
is a file written in Slice: It defines the Ice interface of the component random_number_server
. When we want to customize interface, we need to edit that file.
So, boot up QtCreator and open the project components_tutorial
(if it is not open already from the previous tutorials). Open the file ComponentInterface.ice
. It should look like this:
Let us think about its current content:
We know this line from C++: It is an include guard every header file should have. So Slice may be somehow similar to a c++ Header file?
In Slice, modules are a way group names.
component_tutorials::components::random_number_server
).component_tutorials.components.random_number_server
).module component_tutorials::module components {
is not supported.This is our component's interface and carries the beautiful name ComponentInterface
. As you see, it is declared with the Slice keyword interface
and generally looks like a class. This makes sense, because it is translated to an adequate class in the target languages.
Currently, it does not have any methods, but this will change soon.
Finally, all opened modules are closed.
Now that we understand the basic structure of a Slice/.ice
file, it is time to adapt it to our needs. Let us briefly think about what we want: We want clients to be able to call the server and obtain a random number.
Let us start simple: We will add a function generateRandomNumber()
to the interface which takes no arguments and returns an int
:
Just as regular functions, Ice interface functions can take any number of arguments, including none. The
Request
/Response
scheme that you saw in ../102_distributed_systems/README.md "Understand Distributed Systems" is just a pattern - but one we strongly suggest to use. We will see how and why later on when we add arguments to the function.
Before we continue, try to build the project now.
You should get an error like this:
Wow, that is a lot of error message ... Well, that is just C++, so it cannot be helped. Anyway, what this basically says is:
Our Component
class is abstract, so it cannot be instantiated. But why?
Apparently, the class has at least one "pure virtual" function. Now what does that mean?
In contrast to Java and Python where all functions can be overridden in deriving classes, in C++ such functions must be marked as
virtual
in the base (aka. super-) class.
Pure virtual is C++'s term for an abstract function, i.e. a function without implementation in this class. A class with at least one pure virtual function is called abstract (yes, also in C++). A pure virtual function is marked by a trailing = 0
:
Okay so which pure virtual function causes Component
to be abstract?
Aha! The culprit is virtual ::Ice::Int generateRandomNumber(const ::Ice::Current& current = ::Ice::emptyCurrent) = 0;
, which is part of the class component_tutorials::components::random_number_server::ComponentInterface
! But wait - this is the function we just defined in the ComponentInterface.ice
file.
Let us gather the information we have and digest that ...
We added a function to an interface, but did not implement it yet! Of course the component class is abstract, and we get an error when the framework tries to instantiate it. And that is good: It shows us that we are not finished with our work, but that there is something left to do: Implement the interface function. Okay, so let us do that.
First, open the header file Component.h
. The important lines are:
This line imports the C++ declarations of the Ice interface. This includes the generated interface class component_tutorials::components::random_number_server::ComponentInterface
.
Apart from the base component class armarx::Component
, our class Component
derives from the Ice interface class public component_tutorials::components::random_number_server::ComponentInterface
that was generated from the ComponentInterface.ice
file.
As said, because ComponentInterface
contains the pure virtual function generateRandomNumber()
and Component
does not override and implement it, Component
is virtual as well. We can change that by adding a declaration and definition of generateRandomNumber()
in Component
.
We can use QtCreator to help use here:
class Component :
generateRandomNumber()
from ComponentsInterface
. Make sure that it is checked (it may already be checked by default).Component
.ComponentInterface
). This way, we will get an error if the function actually does not override one (e.g. when the signatures do not match).QtCreator should take your focus back to Component.h
, where it added the function declaration of generateRandomNumber()
at the bottom of the class:
First, move it up a bit to where functions are declared in Component
: Move it below the protected:
section containing the onInitComponent()
and related functions and format it like this:
This is just a convention, but the idea is that we go from general to specific inside the Component
class. Moreover, we could gather more interface functions in this section here.
If you do want to allow another classes deriving from
Component
to override the function again,
add the virtual
keyword in front of the declaration:
However, there is no reason to do that here.
Okay, trying to build now. You should receive an error like this:
The important part is:
Undefined reference is a classical C++ error. It means that the compiler knows how the function looks like (the function is declared in a header), but the linker does not know where the implementation is (i.e. the definition, usually in a source file, is missing). This usually indicates one of these issues:
Can you guess which case we have here?
Solution: We declared the function (non-pure, without the = 0
), so our class is not abstract anymore. But we did not implement the function yet, so the "reference" to it is undefined. So let us add this definition.
Again, we can use QtCreator to help us:
generateRandomNumber()
.If this does not work, add the definition manually in the source file (just underneath all the other method definitions).
The empty function definition looks like this:
It is structured like this:
Ice::Int
is the return type. If you want to see what it actually is, place your cursor on the word Int
part and press F2. This gets you to a symbol's declaration or definition. Doing that will take you to a header file of Ice (Ice/Config.h
) and the following code:
This is a type definition: A way in C++ to define an alias, i.e. another name, for an existing type. So an Ice::Int
is just another name for the primitive type int
.
By the way, the modern way to do a type definition is to use the
using
keyword:using Int = int;Which is a bit more readable because it looks like an assignment.
Component::
is the "namespace" of the function. It is Component::
because the function is defined in the class Component
.generateRandomNumber(...)
is the name of the function and the braces surrounding the argument list.const Ice::Current& current
is the last argument in every Ice interface function (Ice Documentation). Ice uses it to provide some context information. However, it is not used most of the time, so you usually can ignore it.Ice::Int Component::generateRandomNumber(const Ice::Current&)
Ice::emptyCurrent
.{ }
is the function body containing the executed code, which is empty at the moment.If you build now, you get the following error:
As the error says, we are not returning anything yet. It is good to get this error, as the function is not complete yet. For the moment, you can add a
to the function stub. Now your component (or rather, your ArmarX package) should finally build without error. However, we need to come back to add the actual implementation that generates a random number.
In C++, we can generate numbers in the following way:
See cppreference for more information on pseudo-random number generation in C++.
The random engine is an object that we should keep over multiple number generations, so it will generate another randomness each time. However, the distribution is not state-full, so we can instantiate it each time a new. Therefore, we keep the random engine as an attribute of our class, while the distribution will be a local variable in a function.
Here, will use the std::default_random_engine
as randomness generator and std::uniform_int_distribution
as distribution. (std
stands for "standard" is the namespace of the Standard Template Library, aka STL.) On cppreference.com, you can look up which header(s) we must include in order to use them:
Apparently, in order to do random things, we need to include <random>
, so add the following line to the top of Component.h
:
Hint: In QtCreator, you can use F4 to switch between a pair of header and source.
In addition, add the following member variable to the private section of Component
(also in Component.h
):
Take a look at the code below to make sure your code for the random_number_server::Component
class looks the same (some non-important things removed):
Next, switch to Component.cpp
. Before we can use engine
, we should initialize it with a seed. We can do this in onInitComponent()
:
std::random_device
can use the OS and hardware to generate non-deterministic randomness, but it is also more expensive. Therefore, we only use it to initialize our pseudo-random number generator engine
.this->
is optional here, but it makes clear and sure that we are dealing with a member variable here.<details>
In C++, we do **not need to use
new
to create any objects.** For example, the linestd::random_device randomDevice;
does the following:
- Reserve enough space on the stack to hold an instance of
std:random_device
.- Call the constructor of
std::random_device
to constructrandomDevice
at that location.The variable
randomDevice
is kept per value in the function. When the scope of the function is left, the destructor ofstd::random_device
is called and the space is freed. No need to involve the heap here, and if so, it is done bystd::random_device
internally.In C++, it is perfectly common to create a local variable as value in a function, perform some operations on it, then either destroy it or return it per value (not per reference or pointer!). For example:
std::vector<int> squareNumbersUpTo(int max){std::vector<int> squares;{squares.push_back(i * i);}return squares;}Here, an empty
std::vector
(the standard sequence container in C++) containingint
s is constructed as a local variable, filled with square numbers up to a maximum, then returned per value to the caller.This is a bit of a deeper topic, but for C++ beginners it is good to notice the following things:
- In C++, the standard behaviour is per value for everything, i.e. both primitive and complex types. This is different to Java and Python, where everything except primitives is kept and passed per reference.
As a consequence, it is very simple to deep copy objects in C++. Consider the following example:
In the second statement,
b = a
performs a full copy ofa
intob
, as long asFoo
has complete value semantics internally (which should be the default in C++).- In order to avoid unnecessary copies of function arguments, it is perfectly common to pass complex arguments as const reference, which is written as
const Foo&
. (A&
after a type means "reference to that type".) You saw an example of that in the Ice interface functiongenerateRandomNumber(const Ice::Current& current)
.- C++ does not have a garbage collector. If you need pointer semantics, use smart pointers, i.e.
std::unique_ptr
orstd::shared_ptr
, which take care of memory management using a technique called RAII.- (Smart) pointers are necessary to hold polymorphic objects, for example. Copying an instance of a derived class to a variable of a base class can lead to slicing.
- Do not use
new
/delete
or smart pointers to temporarily construct an object - a value is enough most of the time.- Containers like
std::vector
andstd::map
do hold their data on the heap, but take care of it themselves. From an outside perspective, they follow value semantics.- Non-owning raw-pointers (e.g.
Foo* foo
) are okay if they are used as optional references (references cannot be null, but pointers can).- Never use
new
anddelete
to do manual memory management, except when you know what you are doing and that it is the best solution.- Some libraries have their own semantics, though. For example, when using the GUI framework Qt (yes, the same "Qt" as in "QtCreator"), you usually create an object using
new
, then pass it to theWidget
class, which takes ownership of the object (and thus takes care of deleting it when necessary):HBoyLayout* layout = new HBoxLayout(); // Create an owning pointer to an HBoxLayout.widget->setLayout(layout); // Pass the ownership to widget.Okay, back to the topic.
</details>
Finally, we implement the generateRandomNumber()
method by
1
to 10
,engine
to the distribution to sample from it,To test whether our function is doing the correct thing, we can add the following lines to the onInitComponent()
method (after initializing the engine
):
A std::stringstream
can be used to accumulate string output over multiple instructions, then pass it to an ARMARX_*
logging macro as one string.
ARMARX_IMPORTANT
should be used with some consideration: It is meant to stand out and should only be used for, well, important messages. It is okay to use it here for testing purposes, but it should be removed or reduced to a lower level once the component is "released".
Now, build the package, and we are ready to test the server!
Now let's have a look at what our implementation does so far. Make sure the Ice service is running on your local system or in your network and that you can connect to it. This can be done via the following command:
If you get a message like the following,
you first need to start Ice using armarx start
.
Next, start an ArmarX GUI via
In the ArmarX GUI, open the log viewer (LogViewer) if it is not already open. With the log viewer opened, run the server by executing the random_number_server_run
executable in a terminal window from the component_tutorials/build/bin
directory:
An ArmarX application is still just a regular application: You can also start it from the terminal instead of creating a scenario. However, when you want to configure the component in a persistent way, a scenario is the way to go.
The log should show an entry like this:
Or, in text form:
You might ask yourself what all this effort about the Ice interface was about if we are not using it anyway. Well, if you made it this far in the tutorial, you're about to find out!
To call our random number generator via Ice, we need to create another component: the client. This is done in the same way as the server: Navigate to the root folder of the component_tutorials
package, then use the armarx-package tool as follows:
To have QtCreator update the project hierarchy:
The client does not offer any functionality through Ice. Therefore, we don't need to define an Ice interface for it.
First, the client needs to learn about the server's interface (random_number_server::ComponentInterface
). For that, we need to include the interface header in the include section of the random_number_client/Component.h
:
Next, the client needs a way to talk to the server. The client-side representation of another component is a proxy. Using a proxy of the server component, the client can call interface functions of the server, and Ice will take care of the actual communication to the server behind the scenes.
Slice generates type definitions for proxy types alongside the interfaces. If the interface is called MyInterface
, the name of the corresponding proxy type is MyInterfacePrx
. As we need a proxy to the interface random_number_server::ComponentInterface
, the corresponding proxy type is random_number_server::ComponentInterfacePrx
(note the trailing Prx). So we just add a private member to the random_number_client::Component
class in the header file of random_number_client
such as this:
The fully qualified name of the server interface is
component_tutorials::components::random_number_server::ComponentInterface
. Because the client is also in thecomponent_tutorials::components
namespace, we can start qualifying the server type only asrandom_number_server::ComponentInterface
.
Try building now. You will probably get errors like this:
Extracting the most important part yields:
So we have an undefined reference to a function in something related to the random_number_server
namespace. This is not something from the random_number_client
, then, which is a strong hint that this is the other case mentioned above: We are using something from another library, but we do not link it.
Using a function (or any definition in a source file) from another library B
in a library (or executable) A
requires that A
links B
. Think about linking as "importing" the definitions from one library (B
) into another library (A
). In this case, we need to import the definitions of functions related to the Ice interface of random_number_server
into random_number_client
.
Orchestrating the linker is the job of CMake. Therefore, we need to modify the CMakeLists.txt
of random_number_client
. When you open it, it should look like this (some out-commented parts removed):
In ArmarX, libraries to link are specified as DEPENDENCIES
, because if A
links B
, A
depends on B
, and thus B
is a dependency of A
.
Now we just have to find out the name of what we need to link. You can try to find out by looking at the project hierarchy in QtCreator:
The entries with the hammer icon
are targets, which is a CMake term encapsulating things like libraries, executables, and others. Find the file whose contents you want to use, and then check under which target it is listed. This is a good hint at what you need to specify as dependency in CMake.
In this case, we want to use the random_number_server::ComponentInterface
, which is defined in the random_number_server/ComponentInterface.ice
. It is listed under the random_number_server_ice
target. So this is probably what we need.
Add random_number_server_ice
to the DEPENDENCIES
of random_number_client
. Above the line, add a comment # component_tutorials
, so the dependencies are grouped by the packages they are contained in.
When you build now, the undefined reference error should disappear.
One thing is still missing: We have created the proxy to the server, but we did not initialize it yet. In order to actually connect to the server, we need to inform ArmarX about our intentions.
Head over to the source file of the random_number_client
component. Change the body of the function createPropertyDefinitions()
to:
The line def->component(server, ...);
does multiple things:
"random_number_server"
"random_number_server"
by default).server
random_number_server::ComponentInterfacePrx
).onConnectComponent()
is called. onConnectComponent()
is called, the variable server
will be initialized with a valid proxy to the server.server
is during the onConnectComponent()
.onConnectComponent()
(e.g. in the onInitComponent()
).In summary, the line def->component(server, ...)
makes sure that the variable server
is initialized and ready to use once the client reaches its onConnectComponent()
, and allows the user to configure the server's name.
You can also do these things "by hand", i.e. by writing the code yourself. You will see that a lot in older components which were written when the wrapper
def->component()
was not available yet. For reference, the old pattern went like this:
- Define the property in
createPropertyDefinitions()
:Component::createPropertyDefinitions(){...def->defineOptionalProperty("cmp.random_number_server", "random_number_server","Ice object name of the random number server.");...}- Declare that this component is using another component in the
onInitComponent()
:voidComponent::onInitComponent(){usingProxy(getProperty<std::string>("cmp.random_number_server").getValue());}- Obtain the proxy to the other component in the
onConnectComponent()
:voidComponent::onConnectComponent(){server = getProxy<random_number_server::ComponentInterfacePrx>(getProperty<std::string>("cmp.random_number_server").getValue());}As you see, that is a lot of boilerplate code, i.e. code that is almost always the same, for a very common use case. Therefore, we introduced the
def->component()
pattern to support the standard use case, which should get you going in most cases with a single line.If you have more special requirements, though, you can always fall back to the "manual" way of doing things.
Actually, we can make the line even shorter. Have another look at our current code:
This still feels a bit repetitive, does it not? In fact, in a standard setup like this, def->component()
is able to infer the default name and generate a suitable description just from the server
argument (or, more precisely, its type). Thus, we can reduce the call to just:
It does not get much more minimal than that. However, remember that you can always override the defaults if they do not fit your specific use case.
Finally, we are ready to perform the remote procedure call to the server. At this point, that only takes a single line (and one line to print the result):
This performs the remote procedure call to the server (server->generateRandomNumber()
), stores the result in a variable (int randomNumber =
) and logs the result using ARMARX_IMPORTANT
.
Build your package. Afterwards, you will have two applications executing the server and client separately in two processes.
Before we continue, add some more code to illustrate what happens:
onConnectComponent()
to: generateRandomNumber()
to: onInitComponent()
(i.e. the for-loop calling generateRandomNumber()
from the server).You are almost done. Woo! The (almost) only thing remaining now is having a look at what our code does.
As before, start an ArmarX GUI and open the log viewer (if not done already). Now, run the applications random_number_server_run
and random_number_client_run
located in component_tutorials/build/bin/
in two terminals (make sure that the correct ArmarX workspace is active in each terminal):
Watch the log viewer while you are doing that. You should see the following:
or on the terminals:
and
You can see how both the server and the client log their respective messages. But wait: How can the client receive the response before the server has sent it? What magic is this?
Actually, the order in your case may be different. This is a nice difference of how unexpected distributed systems can be: Even the log itself is sent via Ice (a topic, to be precise). It may just have happened that the log messages by the client arrived at the GUI before those by the server did. Because the server operation does not take long, the client almost instantly continues execution. Actually, you can see at the timestamps that all the four log messages were sent in the same millisecond.
Let us change the server-side code to simulate an operation that takes a bit longer to complete:
<ArmarXCore/core/time/Clock.h>
in random_number_server/Component.cpp
: (not the .h
) generateRandomNumber()
(at any point before the return
): Now the server will sleep for 1 second before it sends the response. Compile, and run both applications again. The log should now look like this:
Now this looks much more like we expected:
You can also see from the time stamps that the server operation took 1 second.
Try playing around with this a bit and see how the components behave. For example:
Starting applications from the terminal is nice for quick testing. But would you not say that starting both in a scenario in the GUI is much more comfortable? Luckily, you already know how to do that from the "Hello World!" Tutorial, so we will leave this as a small exercise for you. Do not skip it!
If you need a refreshment, go back to the "Hello World!" Tutorial and see how the scenario was created there. The only difference here is that you need to add two components, the random_number_server
and the random_number_client
. The rest works the same way.
The resulting scenario could look like this:
Good job! You now know how to ...
That is really great!
However, there are still some things we did not cover:
generateRandomNumber()
does not take any arguments yet. How would the client specify the range it would like the random number to be in?So many questions! We will attend to them in future tutorials.
In the next tutorial, you will learn how to Write a Publisher and Subscriber Communicating via Topics (C++).