katherinemohr.github.io

C++ Arrow Chaining

I can't believe I got got by the arrow operator

Published April 14, 2026

In my time as a Five Rings intern mentor, I would send out a “C++ Question of the Day” on Slack every morning to give the interns something to think about in their downtime. As the weeks went on, I tried to make them trickier and more corner-caseyI’ll add the more interesting ones as a blog post and link that here at some point. . So, I had considered adding a questoon on arrow chaining, but I felt like the dummy code I was writing seemed too unlikely to happen in practice and ditched it.

Well, nevermind! I got bit by this the other day, so as punishment, I must write about it.

return llvm::TypeSwitch<Operation *, double>(op)
      // simple roofline flop calculation for nested ops
      .Case<linalg::GenericOp, linalg::MapOp, linalg::ReduceOp>(
          [&](auto genericOp) {
            int64_t iters = linalgIters(genericOp);
            double flopsPerIter = 0.0;
            for (Operation &op : genericOp->getBlock()->getOperations()) {
              if (!isa<linalg::YieldOp>(op)) {
                flopsPerIter += estimateFlops(op);
              }
            }
            return flopsPerIter * iters;
          })
      
      // ... more code
      });

Note the following line:

for (Operation &op : genericOp->getBlock()->getOperations()) {

genericOp is clearly a reference, so why didn’t the arrow cause a compilation error?

Turns out, the arrow operator has actually been overloadedLink to source: mlir/include/mlir/IR/OpDefinition.h for non-pointer types here.

// From mlir/include/mlir/IR/OpDefinition.h
class OpState {
public:
  // ...

  /// Shortcut of `->` to access a member of Operation.
  Operation *operator->() const { return state; }
  // ...
};

So, the genericOp->getBlock() call gets desugared to genericOp.operator->()->getBlock(), which, as we see above, ends up calling genericOp.state->getBlock().

In my case, genericOp.state->getBlock() and genericOp.getBlock() were semantically different, leading to runtime errors.


This example actually is fairly straightforward, but you could imagine these chaining together like:

struct C { void foo(); };
struct B { C* operator->() { return &c; } C c; };
struct A { B  operator->() { return b; } B b; };

A a;
a->foo(); 
// expands to: A::operator->() -> returns B (not a pointer)
//             B::operator->() -> returns C* (raw pointer, stop)
//             C::foo() called on that C*