Simplest C++ Callback, from SumatraPDF

84 jandeboevrie 65 6/15/2025, 5:26:38 PM blog.kowalczyk.info ↗

Comments (65)

JeanMarcS · 8h ago
Don't know about the code subtilities, but SumatraPDF is a gift for viewing PDF on MS Windows. So big thanks to the author !
Arainach · 3h ago
Out of curiosity, what's your use case for it? Years ago I preferred Sumatra/Foxit to Adobe, but every major browser has supported rendering PDFs for at least a decade and I haven't had needed or wanted a dedicated PDF reader in all that time.
mjmas · 2h ago
Part of why I use SumatraPDF is that it automatically reloads its view when the files change (at least for PDFs, I haven't tested on the other file types it supports).
drewbitt · 1h ago
Not only is it faster in opening than a browser and a separation of concerns (documents get their own app, which I can leave with open tabs), it also opens epub, .cbz, and other formats, so I have it installed on all my Windows machines. I eventually open a book.
Cadwhisker · 2h ago
It's smaller, lighter and much faster than launching a web browser to view a PDF. I can configure it to open a new instance for each PDF which is nice if you need to have several docs open at once. Again, nothing that you can't do with a browser and dragging tabs, but I prefer this.
ternaryoperator · 1h ago
Not the OP, but my use case is epub books, which it handles flawlessly.
_randyr · 7h ago
I'm not a C++ programmer, but I was under the impression that closures in c++ were just classes that overload the function call operator `operator()`. So each closure could also be implemented as a named class. Something like:

    class OnListItemSelected {
        OnListItemSelectedData data;

        void operator()(int selectedIndex) { ... }
    }
Perhaps I'm mistaken in what the author is trying to accomplish though?
OskarS · 7h ago
Indeed, that is exactly the case, lambdas are essentially syntax sugar for doing this.

The one thing the author's solution does which this solution (and lambdas) does not is type erasure: if you want to pass that closure around, you have to use templates, and you can't store different lambdas in the same data structure even if they have the same signature.

You could solve that in your case by making `void operator()` virtual and inheriting (though that means you have to heap-allocate all your lambdas), or use `std::function<>`, which is a generic solution to this problem (which may or may not allocate, if the lambda is small enough, it's usually optimized to be stored inline).

I get where the author is coming from, but this seems very much like an inferior solution to just using `std::function<>`.

usefulcat · 3h ago
> though that means you have to heap-allocate all your lambdas

I think whether or not you have to allocate from the heap depends on the lifetime of the lambda. Virtual methods also work just fine on stack-allocated objects.

spacechild1 · 7h ago
Exactly! And if you need type erasure, you can just store it in a std::function.

> OnListItemSelectedData data;

In this case you can just store the data as member variables. No need for defining an extra class just for the data.

As I've written elsewhere, you can also just use a lambda and forward the captures and arguments to a (member) function. Or if you're old-school, use std::bind.

InfiniteRand · 4h ago
Main issue author had with lambdas is autogenerated names in crash reports
akdev1l · 5h ago
I don’t really understand what problem this is trying to solve and how the solution is better than std::function. (I understand the issue with the crash reports and lambdas being anonymous classes but not sure how the solution improved on this or how std::function has this problem?)

I haven’t used windows in a long time but back in the day I remember installing SumatraPDF to my Pentium 3 system running windows XP and that shit rocked

kjksf · 3h ago
How is Func0 / Func1<T> better than std::function?

Smaller size at runtime (uses less memory).

Smaller generated code.

Faster at runtime.

Faster compilation times.

Smaller implementation.

Implementation that you can understand.

How is it worse?

std::function + lambda with variable capture has better ergonomics i.e. less typing.

akdev1l · 3h ago
I think none of these points are demonstrated in the post hence I fail to visualize it

Also I copy pasted the code from the post and I got this:

test.cpp:70:14: error: assigning to 'void ' from 'func0Ptr' (aka 'void ()(void *)') converts between void pointer and function pointer 70 | res.fn = (func0Ptr)fn;

cryptonector · 2h ago
> test.cpp:70:14: error: assigning to 'void ' from 'func0Ptr' (aka 'void ()(void *)') converts between void pointer and function pointer 70 | res.fn = (func0Ptr)fn;

This warning is stupid. It's part of the "we reserve the right to change the size of function pointers some day so that we can haz closures, so you can't assume that function pointers and data pointers are the same size m'kay?" silliness. And it is silly: because the C and C++ committees will never be able to change the size of function pointers, not backwards-compatibly. It's not that I don't wish they could. It's that they can't.

akdev1l · 2h ago
It’s not a warning, it’s a compile time error and I am not even using -Wall -Werror

I also believe there are platforms where a function pointer and a data pointer are not the same but idk about such esoteric platforms first hand (seems Itanium had that: https://stackoverflow.com/questions/36645660/why-cant-i-cast...)

Though my point was only that this code will not compile as is with whatever clang Apple ships*

I am not really sure how to get it to compile tbqh

Some further research ( https://www.kdab.com/how-to-cast-a-function-pointer-to-a-voi...) suggest it should be done like so:

> auto fptr = &f; void a = reinterpret_cast<void &>(fptr);

edit: I tried with GCC 15 and that compiled successfully

spacechild1 · 28m ago
You can't just keep claiming these things without providing evidence. How much faster? How much smaller? These claims are meaningless without numbers to back it up.
almostgotcaught · 3h ago
Your Func thing is better than std::function the same way a hammer is better than a drill press... ie it's not better because it's not the same thing at all. Yes the hammer can do some of the same things, at a lower complexity, but it can't do all the same things.

What I'm trying to say is being better than x means you can do all the same things as x better. Your thing is not better, it is just different.

mwkaufma · 6h ago
The lengths some go to avoid just using a bog-standard virtual function.
kjksf · 5h ago
I actually used the "virtual function" approach earlier in SumatraPDF.

The problem with that is that for every type of callback you need to create a base class and then create a derived function for every unique use.

That's a lot of classes to write.

Consider this (from memory so please ignore syntax errors, if any):

    class ThreadBase {
       virtual void Run();
       // ...
    }

    class MyThread : ThreadBase {
       MyData* myData;
       void Run() override;
       // ...
    }
    StartThread(new MyThread());
compared to:

    HANDLE StartThread(const Func0&, const char* threadName = nullptr);    
    auto fn = MkFunc0(InstallerThread, &gCliNew);
    StartThread(fn, "InstallerThread");

I would have to create a base class for every unique type of the callback and then for every caller possibly a new class deriving.

This is replaced by Func0 or Func1<T>. No new classes, much less typing. And less typing is better programming ergonomics.

std::function arguably has slightly better ergonomics but higher cost on 3 dimension (runtime, compilation time, understandability).

In retrospect Func0 and Func1 seem trivial but it took me years of trying other approaches to arrive at insight needed to create them.

mwkaufma · 3h ago
>> I would have to create a base class for every unique type of the callback and then for every caller possibly a new class deriving.

An interface declaration is, like, two lines. And a single receiver can implement multiple interfaces. In exchange, the debugger gets a lot more useful. Plus it ensures the lifetime of the "callback" and the "context" are tightly-coupled, so you don't have to worry about intersecting use-after-frees.

mandarax8 · 7h ago
What he shows here is 75% of c++26's std::function_ref. It's mainly missing variadic arguments and doesn't support all types of function objects.

https://github.com/TartanLlama/function_ref/blob/master/incl...

kjksf · 7h ago
I can honestly say that I couldn't write that thing in 100 years.

I can't even read it.

That's the fundamental problem with C++: I've understood pretty much all Go code I ever looked at.

The code like the above is so obtuse that 0.001% of C++ programmers is capable of writing it and 0.01% is capable of understanding it.

Sure, I can treat it as magic but I would rather not.

winocm · 2h ago
mandarax8 · 6h ago
Yeah it's a shame that to go from your idea to something that's 'general' (ie just some arbitrary arguments) you need to write this arcane garbage.
spacechild1 · 7h ago
Do you understand how your compiler works? Shouldn't you be writing assembly instead? You can't understand all internals and that's perfectly fine.

Why do you even care how std::function is implemented? (Unless you are working in very performance critical or otherwise restricted environments.)

kjksf · 6h ago
I've listed several reasons why I decided to write and use this implementation:

  - better call stacks in crash reports
  - smaller and faster at runtime
  - faster compilation because less complicated, less templated code
  - I understand it
So there's more to it that just that one point.

Did I loose useful attributes? Yes. There's no free lunch.

Am I going too far to achieve small, fast code that compiles quickly? Maybe I do.

My code, my rules, my joy.

But philosophically, if you ever wonder why most software today can't start up instantly and ships 100 MB of stuff to show a window: it's because most programmers don't put any thought or effort into keeping things small and fast.

spacechild1 · 6h ago
Oh, I definitely agree with some of your other points, just not the one I argued against.

BTW, I would also contest that your version is faster at runtime. Your data always allocated on the heap. Depending on the size of the data, std::function can utilize small function optimization and store everything in place. This means there is no allocation when setting the callback and also better cache locality when calling it. Don't make performance claims without benchmarking!

Similarly, the smaller memory footprint is not as clear cut: with small function optimization there might be hardly a difference. In some cases, std::function might even be smaller. (Don't forget about memory allocation overhead!)

The only point I will absolutely give you is compilation times. But even there I'm not sure if std::function is your bottleneck. Have you actually measured?

kjksf · 4h ago
That's a fair point. I just looked and out of 35 uses of MkFunc0 only about 3 (related to running a thread) allocate the args.

All others use a pointer to an object that exists anyway. For example, I have a class MyWindow with a button. A click callback would have MyWindow* as an argument because that's the data needed to perform that action. That's the case for all UI widgets and they are majority uses of callbacks.

I could try to get cheeky and implement similar optimization as Func0Fat where I would have inline buffer on N bytes and use it as a backing storage for the struct. But see above for why it's not needed.

As to benchmarking: while I don't disagree that benchmarking is useful, it's not the ace card argument you think it is.

I didn't do any benchmarks and I do no plan to.

Because benchmarking takes time, which I could use writing features.

And because I know things.

I know things because I've been programming, learning, benchmarking for 30 years.

I know that using 16 bytes instead of 64 bytes is faster. And I know that likely it won't be captured by a microbenchmark.

And even if it was, the difference would be miniscule.

So you would say "pfft, I told you it was not worth it for a few nanoseconds".

But I know that if I do many optimizations like that, it'll add up even if each individual optimization seems not worth it.

And that's why SumatraPDF can do PDF, ePub, mobi, cbz/cbr and uses less resources that Windows' start menu.

spacechild1 · 3h ago
First, thanks for providing SumataraPDF as free software! I don't want to disparage your software in any way. I don't really care how it's written as long as it works well - and it does! This is really just about your blog post.

> I just looked and out of 35 uses of MkFunc0 only about 3 (related to running a thread) allocate the args.

In that case, std::function wouldn't allocate either.

> All others use a pointer to an object that exists anyway. For example, I have a class MyWindow with a button. A click callback would have MyWindow* as an argument because that's the data needed to perform that action. That's the case for all UI widgets and they are majority uses of callbacks.

That's what I would have guessed. Either way, I would just use std::bind or a little lambda:

    struct MyWindow { void onButtonClicked(); };

    // old-school: std::bind
    setCallback(std::bind(&MyWindow::onButtonClicked, window));

    // modern: a simple lambda
    setCallback([window]() { window->onButtonClicked(); });
If your app crashes in MyWindow::onButtonClicked, that method would be on the top of the stack trace. IIUC this was your original concern. Most of your other points are just speculation. (The compile time argument technically holds, but I'm not sure to which extend it really shows in practice. Again, I would need some numbers.)

> I know things because I've been programming, learning, benchmarking for 30 years.

Thinking that one "knows things" is dangerous. Things change and what we once learned might have become outdated or even wrong.

> I know that using 16 bytes instead of 64 bytes is faster. And I know that likely it won't be captured by a microbenchmark.

Well, not necessarily. If you don't allocate any capture data, then your solution will win. Otherwise it might actually perform worse. In your blog post, you just claimed that your solution is faster overall, without providing any evidence.

Side note: I'm a bit surprised that std::function takes up 64 bytes in 64-bit MSVC, but I can confirm that it's true! With 64-bit GCC and Clang it's 32 bytes, which I find more reasonable.

> And even if it was, the difference would be miniscule.

That's what I would think as well. Personally, I wouldn't even bother with the performance of a callback function wrapper in a UI application. It just won't make a difference.

> But I know that if I do many optimizations like that, it'll add up even if each individual optimization seems not worth it.

Amdahl's law still holds. You need to optimize the parts that actually matter. It doesn't mean you should be careless, but we need to keep things in perspective. (I would care if this was called hundreds or thousands of times within a few milliseconds, like in a realtime audio application, but this is not the case here.)

To be fair, in your blog post you do concede that std::function has overall better ergonomics, but I still think you are vastly overselling the upsides of your solution.

maleldil · 4h ago
> You can't understand all internals, and that's perfectly fine.

C++ takes this to another level, though. I'm not an expert Go or Rust programmer, but it's much easier to understand the code in their standard libraries than C++.

spacechild1 · 3h ago
Fair enough :) Unfortunately, this is just something one has to accept as a C++ programmer. Should we roll our own std::vector because we can't understand the standard library implemention? The answer is, of course, a firm "no" (unless you have very special requirements).
cherryteastain · 8h ago
Why not just pass around an

    std::pair<void(*)(FuncData*), std;:unique_ptr<FuncData>>
at this stage? This implementation has a bunch of performance and ergonomics issues due to things like not using perfect forwarding for the Func1::Call(T) method, so for anything requiring copying or allocating it'll be a decent bit slower and you'll also be unable to pass anything that's noncopyable like an std::unique_ptr.
kjksf · 8h ago
I don't know fancy C++ so I don't understand your point about perfect forwarding.

But I do know the code I write and you're wrong about performance of Func0 and Func1. Those are 2 machine words and all it takes to construct them or copy them is to set those 2 fields.

There's just no way to make it faster than that, both at runtime or at compile time.

The whole point of this implementation was giving up fancy features of std::function in exchange for code that is small, fast (both runtime and at compilation time) and one that I 100% understand in a way I'll never understand std::function.

cherryteastain · 8h ago
In this function

    void Call(T arg) const {
        if (fn) {
            fn(userData, arg);
        }
    }
Say you pass something like an std::vector<double> of size 1 million into Call. It'll first copy the std::vector<double> at the point you invoke Call, even if you never call fn. Then, if fn is not nullptr, you'll then copy the same vector once more to invoke fn. If you change Call instead to

    void Call(T&& arg) const {
        if (fn) {
            fn(userData, std::forward<T>(arg));
        }
    } 
the copy will not happen at the point Call is invoked. Additionally, if arg is an rvalue, fn will be called by moving instead of copying. Makes a big difference for something like

    std::vector<double> foo();
    void bar(Func1<std::vector<double>> f) {
        auto v = foo();
        f(std::move(v));
    }
OskarS · 7h ago
> But I do know the code I write and you're wrong about performance of Func0 and Func1. Those are 2 machine words and all it takes to construct them or copy them is to set those 2 fields.

You also have to heap allocate your userData, which is something std::function<> avoids (in all standard implementations) if it’s small enough (this is why the sizeof() of std::function is larger than 16 bytes, so that it can optionally store the data inline, similar to the small string optimization). The cost of that heap allocation is not insignificant.

If I were doing this, I might just go the full C route and just use function pointers and an extra ”userData” argument. This seems like an awkward ”middle ground” between C and C++.

not-so-darkstar · 9h ago
kjksf · 8h ago
It's back up now.

Somehow my blog server got overwhelmed and requests started taking tens of seconds. Which is strange because typically it's under 100ms (it's just executing a Go template).

It's not a CPU issues so there must be locking issue I don't understand.

waynecochran · 9h ago
A small kitten dies every time C++ is used like its 1995.

    void (*fn)(void*, T) = nullptr;
tom_ · 6h ago
And another one dies every time you need to step through a call to std::function. Whatever you do, the kittens are never going to escape.
plq · 8h ago
Unless you mutter the magic incantation "C compatibility" while doing it
zabzonk · 8h ago
did nullptr exist in c++ back in 1995 - i can't remember
trealira · 8h ago
Nope, it was introduced in C++11, along with the type std::nullptr_t. Before that, you either used 0 or NULL, which was a macro constant defined to be 0.
spacechild1 · 7h ago
> I’ve used std::function<> and I’ve used lambdas and what pushed me away from them were crash reports.

In danger of pointing out the obvious: std::function does note require lambdas. In fact, it has existed long before lambdas where introduced. If you want to avoid lambdas, just use std::bind to bind arguments to regular member functions or free functions. Or pass a lambda that just forwards the captures and arguments to the actual (member) function. There is no reason for regressing to C-style callback functions with user data.

Kranar · 36m ago
> In fact, it has existed long before lambdas where introduced.

Both std::function<> and lambdas were introduced in C++11.

Furthermore absolutely no one should use std::bind, it's an absolute abomination.

kjksf · 4h ago
I did use bind earlier in SumatraPDF.

There are 2 aspects to this: programmer ergonomics and other (size of code, speed of code, compilation speed, understandability).

Lambdas with variable capture converted to std::function have best ergonomics but at the cost of unnamed, compiler-generated functions that make crash reports hard to read.

My Func0 and Func1<T> approach has similar ergonomics to std::bind. Neither has the problem of potentially crashing in unnamed function but Func0/Func1<T> are better at other (smaller code, faster code, faster compilation).

It's about tradeoffs. I loved the ergonomics of callbacks in C# but I working within limitations of C++ I'm trying to find solutions with attributes important to me.

spacechild1 · 4h ago
> but Func0/Func1<T> are better at other (smaller code, faster code, faster compilation).

I would really question your assumptions about code size, memory usage and runtime performance. See my other comments.

mandarax8 · 7h ago
std::bind is bad for him for the same reasons std::function is bad though
spacechild1 · 7h ago
Why? If the bound (member) function crashes, you should get a perfectly useable crash report. AFAIU his problem was that lambdas are anonymous function objects. This is not the case here, because the actual code resides in a regular (member) function.
dustbunny · 7h ago
Does a stack trace from a crash in a bound function show the line number of where the bind() took place?
spacechild1 · 6h ago
No, but neither does the author's solution.
delusional · 6h ago
Assuming the stack trace is generated by walking up the stack at the time when the crash happened, nothing that works like a C function pointer would ever do that. Assigning a a pointer to a memory location doesn't generate a stack frame, so there's no residual left in the stack that could be walked back.

A simple example. If you were to bind a function pointer in one stack frame, and the immediately return it to the parent stack frame which then invokes that bound pointer, the stack that bound the now called function would literally not exist anymore.

mgaunard · 5h ago
Should have just implemented his own std::function with the simplicity and performance trade-off he wanted.
noomen · 7h ago
I just want to thank SumatraPDF's creator, he literally saved my sanity from the evil that Adobe Acrobat Reader is. He probably saved millions of people thousands of hours of frustration using Acrobat Reader.
jitans · 8h ago
kjksf · 8h ago
One, I didn't know about it.

Two, my main objective is extreme simplicity and understandability of the code.

I explicitly gave up features of std::function for smaller code that I actually understand.

fu2 seems to be "std::function but more features".

commandersaki · 6h ago
I always love this author's writing style, his articles are pure bliss to read.
pjmlp · 7h ago
Another example of NIH, better served by using the standard library.
kjksf · 3h ago
Programming requires making fine-grained implementation decisions.

There are numerous differences between my Func0 and Func1<T> and std::function<>.

Runtime size, runtime performance, compilation speed, understandability of the code, size of the source code, size of the generated code, ergonomics of use.

My solution wins on everything except ergonomics of use.

LLVM has a small vector class.

When asked for comment, pjmlp said: "Another example of NIH, better served by using the standard library".

mdaniel · 8h ago
SumatraPDF is outstanding software. But I'm actually surprised to hear that it seems to be written in C++ ... I dunno, kind of like "by default?" And a blog post hand rolling callback functions using structs and a bunch of pointers seems to double down on: are you sure this language is getting you where you want to go?
kjksf · 8h ago
As opposed to?

Today, if I was starting from scratch, I would try zig or odin or maybe even Go.

But SumatraPDF started 15 years. There was nothing but C++. And a much different C++ that C++ of today.

Plus, while my own code is over 100k lines, external C / C++ libraries are multiple of that so (easy) integration with C / C++ code is a must.

mdaniel · 8h ago
I didn't know how to correctly package my comment as not criticizing, and that's half of why I opened with "is outstanding software." I genuinely believe that, I'm deeply grateful for you releasing SumatraPDF into the world, and it makes my life better. Truly, I am thankful

I hear you about "back in my day," but since as best I can tell it's just your project (that is, not a whole team of 50 engineers who have to collaborate on the codebase) so you are the audience being done a disservice by continuing to battle a language that hates you

As for the interop, yes, since the 70s any language that can't call into a C library is probably DoA but that list isn't the empty set, as you pointed out with the ones you've actually considered. I'd even suspect if you tried Golang it may even bring SumatraPDF to other platforms which would be another huge benefit to your users

kjksf · 8h ago
Don't worry about being nice, 15 years doing open source develops a thick skin.

But you didn't respond: which language should I use?

Go didn't exist when I started SumatraPDF.

And while I write pretty much everything else in Go and love the productivity, it wouldn't be a good fit.

A big reason people like Sumatra is that it's fast and small. 10 MB (of which majority are fonts embedded in the binary) and not 100MB+ of other apps.

Go's "hello world" is 10 MB.

Plus abysmal (on Windows) interop with C / C++ code.

And the reason SumatraPDF is unportable to mac / linux is not the language but the fact that I use all the Windows API I can for the UI.

Any cross-platform UI solution pretty much require using tens of megabytes of someone else's reimplementation of all the UI widgets (Qt, GTK, Flutter) or re-implementing a smaller subset of UI using less code.

sitzkrieg · 8h ago
sumatrapdf not being cross platform is a great feature, maximizing the use of intended platform. win32api is great. thank you for that
mhd · 8h ago
> I'd even suspect if you tried Golang it may even bring SumatraPDF to other platforms which would be another huge benefit to your users

Probably by using a cross-platform toolkit written in C++.