Linker error with static member variable
It’s interesting to see how small snippets of code can cause so much headache.
Consider this minimal example.
struct c{
static const int i = 42;
};
int foo(const int& i) { return i; }
int main(){
return foo(c::i);
}
It won’t compile with GCC or Clang, unless compiling with optimization:
> g++ --std=c++11 main.cpp
/usr/bin/ld: /tmp/ccYtUx0X.o: warning: relocation against '_ZN1c1iE' in read-only section '.text'
/usr/bin/ld: /tmp/ccYtUx0X.o: in function 'main':
main.cpp:(.text+0x12): undefined reference to 'c::i'
/usr/bin/ld: warning: creating DT_TEXTREL in a PIE
collect2: error: ld returned 1 exit status
> g++ --std=c++11 -O1 main.cpp
# compiles without warnings
The error is cryptic for multiple reasons.
If foo takes its argument by value and not const-ref, then the code also compiles
struct c{
static const int i = 42;
};
int foo(int i) { return i; }
int main(){
return foo(c::i);
}
If we use a float
instead of an int
, we will get the following compiler error with GCC: error: 'constexpr' needed for in-class initialization of static data member 'const float c::i' of non-integral type [-fpermissive]
. If we also add constexpr
, the code compiles
struct c{
static constexpr const float i = 42;
};
int foo(const int& i) { return i; }
int main(){
return foo(c::i);
}
But if I change the signature of int foo(const int&)
to int foo(const float&)
, then the issue is there again
struct c{
static const constexpr float i = 42;
};
int foo(const float& i) { return i; }
int main(){
return foo(c::i);
}
So it seems that if there is an implicit conversion between different types, then the code compiles.
The issue also disappears when using a "normal" global variable, ie when using a namespace instead of a struct
for scoping symbols. The static
keyword, in this case, does not make any difference:
namespace c{
const int i = 42;
}
int foo(const int& i) { return i; }
int main(){
return foo(c::i);
}
Is there anything special happening with integral and fundamental types?
Apparently not, the same issue appears when using a user-defined type
struct wrap{int i;};
struct c{
static constexpr const wrap i{42};
};
void foo(const wrap&) { }
int main(){
foo(c::i);
}
Then notice that the following snippet compiles without issues
struct c{
static const int i;
};
const int c::i = 42;
void foo(const int&) { }
int main(){
foo(c::i);
}
and that it is not possible to rewrite this code with non-integral types, as constexpr
requires an initializer where the value is declared.
I mainly analyzed the issue with a C++11 compiler (as it was the scope of the project), but then I decided to see if using a more recent C++ standard would change anything.
Indeed, in C++17, when using constexpr
, the issue disappears, thus
struct wrap{int i;};
struct c{
static constexpr const TYPE i{42};
};
void foo(const TYPE&) { }
int main(){
foo(c::i);
}
compiles both with #define TYPE int
and #define TYPE wrap
with g++ --std=c++17 main.cpp
.
But without constexpr
it does compile only with #define TYPE int
(as in C++11) and with optimizations.
Rule of thumb
I’ve already written down some notes about namespace and classes, but it did not occur to me that in this case, it would make a difference.
This is another reason for using the tool designed to fulfill a particular job instead of using paradigms of other languages.
Also, for defining compile-time constants, use constexpr
. It helps to avoid errors, see the issue with globals, avoid unnecessary overhead, and in this case, when targeting at least C++17, linker errors too.
But it does not explain why the code behaves differently when using struct
, and it also does not offer a solution in those cases we should prefer it over a namespace
.
Putting it all together
The issue does not appear if
-
initialization is not inline
-
there is a conversion
-
using
constexpr
in C++17 (and inline initialization) -
there is no
foo
-
an implicit conversion between the global and parameter required by
foo
happens -
foo
takes a parameter by value
Also note that for initializing inline, int
suffices to be const, but other types (float, user-declared types, …) need to be declared constexpr
.
It seems that static member variables without out-of-class definitions are not real objects contrary to global variables.
I’m unsure where it is exactly stated in the standard and why this difference exists, but GCC and clang act accordingly.
The most explicit reference to this property I could find is the note
Once the static data member has been defined, it exists even if no objects of its class have been created. […]
https://open-std.org/jtc1/sc22/wg21/docs/papers/2011/n3242.pdf
This also explains why the code compiles if the constant is an int
and foo
accepts a float
. If a conversion takes place, then "a temporary object is created".
The fact that the code does compile with optimization is probably a side effect. The compiler might inline the function and completely optimize it away, or just replace the call with the literal value after it sees that the address is not taken.
In the case of a function taking the parameter by value, the fact that the object does not exist is not an issue, as it is not possible to take the address of the global.
Having said that, the following snippets compiles with both GCC and Clang, which seems to contradict what I’ve just said
struct c{
static const int i = 42;
};
int main(){
&c::i;
}
But if the value is just stored somewhere
struct c{
static const int i = 42;
};
int main(){
auto p = &c::i;
}
then it does not compile with undefined reference to 'c::i'
, even if the value is used as much as before.
But when adding constexpr
, why does the code compile since C++17?
inline variables
Since C++17 there are inline variables. Just as with inline functions, the inline keyword is used to avoid ODR-violations.
In this case, the object exists and thus there are no issues.
constexpr
variables are, since C++17 inline
, so I double-checked and
struct wrap{int i;};
struct c{
static inline const TYPE i{42};
};
void foo(const TYPE& i) { }
int main(){
foo(c::i);
}
compiles without constexpr
and both with #define TYPE wrap
and #define TYPE int
.
Why is constexpr
not necessary for int
?
There is one question open, why don’t we need constexpr
for int
in the first example?
This is for historical reasons, as constexpr
did not exist in the first revisions of the C++ language. Without special-casing int
, writing
const int size = 3
void foo(){
int arr[size]{};
}
would not have been possible.
For the record, in C the code is valid and uses a variable-length array (VLA), an array which stack size is determined at runtime. (making sizeof
a runtime operation!).
A VLA has multiple disadvantages, they do not perform well, have a complicated implementation, are a recipe for stack overflows (and thus security hazard), might leak in case of longjmp
, MSVC does not support them, and are deprecated since C17.
In C++ the code is equivalent to
void foo(){
int arr[3]{};
}
just as if size
would have been an integral and not a variable!
In C it is possible to mimic such a feature with an enum or macro
enum{ size = 3 };
// or: #define size 3
void foo(){
int arr[size]{};
}
And note that in both of those cases, there is no object size
as it is not possible to get an address from it.
How to fix the linker error
There are multiple solutions, that only depend loosely on each other. Depending on the context some might not be applicable, and for each possible solution, one has to consider the pro and contra.
The best would be to use a newer standard, at least C++17, and define the variable as constexpr
. If the variable cannot be made constexpr
-
use C++17 and make the variable
constexpr
-
use C++17 and define the variable as
inline
-
use a
namespace
instead of astruct
/class
-
if the object is small, change
foo
to accept it by value -
create a temporary object when the value is passed to
foo
. This can be done by creating a local variable, or with an apparently redundantstatic_cast
. In the case of integral types, it can also be done by doing some otherwise unnecessary operations, like adding0
or use the unary+
operator on an integral type. -
use an
enum
ordefine
-
leave the declaration as-is, and add a definition in the corresponding
.cpp
file. The initialization can be moved to the implementation file, but must be left in the header in case ofconstexpr
.
Do you want to share your opinion? Or is there an error, some parts that are not clear enough?
You can contact me anytime.