C++ Nullptr vs Null-Object for potential noop function arguments?

141 views Asked by At

TL;DR : Should we use fn(Interface* pMaybeNull) or fn(Interface& maybeNullObject) -- specifically in the case of "optional" function arguments of a virtual/abstract base class?


Our code base contains various forms of the following pattern:

struct CallbackBase {
  virtual ~CallbackBase() = default;
  virtual void Hello(/*omitted ...*/) = 0;
};
...

void DoTheThing(..., CallbackBase* pOpt) {
  ...
  if (pOpt) { pOpt->Hello(...); }
}

where the usage site would look like:

... {
  auto greet = ...;
  ...
  DoTheThing(..., &greet);
  // or if no callback is required from call site:
  DoTheThing(..., nullptr);
}

It has been proposed that, going forward, we should use a form of the Null-Object-Pattern. like so:

struct NoopCall : public CallbackBase {
  virtual void Hello(/*omitted ...*/) { /*noop*/ }
};

void DoTheThing2(..., CallbackBase& opt) {
  ...
  opt.Hello(...);
}

... {
  NoopCall noop;
  // if no callback is required from call site:
  DoTheThing2(..., noop);
}

Note: Search variations yield lots of results regarding Null-Object (many not in the C++ space), a lot of very basic treatment of pointer vs. references and if you include the word "optional", as-in the parameter is optional, you obviously get a lot of hits regarding std::optional which, afaik, is unsuitable for this virtual interface use case.

I couldn't find a decent comparison of the two variants present here, so here goes:

Given C++17/C++20 and a halfway modern compiler, is there any expected difference in the runtime characteristics of the two approaches? (this factor being just a corollary to the overall design choice.)

The "Null Object" approach certainly "seems" more modern and safer to me -- is there anything in favor of the pointer approach?


Note:

I think it is orthogonal to the question posed, whether it stands as posted, or uses a variant of overloading or default arguments.

That is, the question should be valid, regardless of:

//a

void DoTheThing(arg);

// vs b

void DoTheThing(arg=nullthing);

// vs c

void DoTheThing(arg); // overload1
void DoTheThing(); // overload0 (calling 1 internally)
1

There are 1 answers

0
Martin Ba On

Performance:

I inspected the code on godbolt and while MSVC shows "the obvious", the gcc output is interesting (see below).

// Gist for a MCVE.

"The obvious" is that the version with the Noop object contains an unconditional virtual call to Hello and the pointer version has an additional pointer test, eliding the call if the pointer is null.

So, if the function is "always" called with a valid callback, the pointer version is a pessimization, paying an additional null check.

If the function is "never" called with a valid callback, the NullObject version is a (worse) pessimization, paying a virtual call that does nothing.

However, the object version in the gcc code contains this:

WithObject(int, CallbackBase&):
...
        mov     rax, QWORD PTR [rsi]
...
        mov     rax, QWORD PTR [rax+16]
   (!)  cmp     rax, OFFSET FLAT:NoopCaller::Hello(HelloData const&)
        jne     .L31

.L25:
...

.L31:
        mov     rdi, rsi
        mov     rsi, rsp
        call    rax
        jmp     .L25

And while my understanding of assembly is certainly near non existent, this looks like gcc is comparing the call pointer to the NoopCaller::Hello function, and eliding the call in this case!

Conclusion

In general, the pointer version should produce more optimal code on the micro-level. However, compiler optimizations might make any difference near non-observable.

  • Think about using the pointer version if you have a very hot path where the callback is null.

  • Use the null object version otherwise, as it is arguably safer and more maintainable.