why is locale stuff even in sprintf? What things get localized? Dates? Line endings?
I'm probably dumb but one of things that bugs me with various libraries is when someone has made the decision to do something high level at a low level. For example, localizing inside sprintf,
An another example might be an unzip library (read a zip file). Ideally the library should be small IMO. The simplest might you you pass it a bucket of bytes. If you want to make it flexible then you pass it some abstract interface (or 1-2 functions + void* userdata) so you can supply a "read(byteOffset, length)". You can then provide, outside of the library, streamed files, stream networking, etc...
But, bad libraries (bad IMO) will instead provide like 12 overrides "unzip(void* bytes), unzip(const char* filename), unzip(socket), unzip(url)" and end up including the world in their library. This kind of "try to do everything" is extremely common in npm libraries :( I don't need your library to include command line parsing! If you want to make a tool, make a library, then make a separate tool that uses that library. Keep the 2 separated so users of the library don't need dependencies that only the tool needs. (probably the most common npm example but there are lots of others)
Really surprised something as low-level as sprintf needs locale. Even streams I'd expect maybe a Date object would but not the stream itself.
> why is locale stuff even in sprintf? What things get localized
Decimal places in floating point numbers is the primary standard thing (some locales use `.` as a decimal seperator and `,` as a thousands seperator... some locales swap interpretations!) Nonstandard extensions may also do things like accept unicode strings, wide unicode strings, etc. which may need to be re-encoded to whatever the ambient locale-specified narrow encoding is (UTF16 => windows-1251?).
In the modern era of sprintf being exiled to some low level internal thing, I agree it makes little sense - causing more bugs than it fixes - but in the days of using it for a lot of heavy lifting of user-facing data, it was an understandable target for this kind of treatment.
Yeah, as a french I often have to set my LANG to C because of how many buggy apps that use sprintf without fixing a locale exist (making save files at best unshareable with the world at worse unreadable again)
The overhead is completely unnecessary in far too many cases. So what they should have done is kept sprintf to its traditional behavior and invented a new, slower alternative for those fairly unusual cases that need locale sensitivity. Locales tend to be all or nothing and extremely inefficient to make general use of anyway because too many things are controlled by the same setting.
Standards committees should not carelessly sit down and devise things that destroy performance by a factor of ten or more, especially when it is entirely unnecessary and where they do not keep or provide simple, fast alternatives for the most common cases where the format is known and has nothing to do with a preferred locale. What they did instead was a massive energy and time wasting imposition of unhelpful semantics on functions that did not have or need any such thing instead of a tailored let's only do this in special cases with special functions approach.
I am very curious to know why you guys are forgetting std::to_string. It is available since c++11. This and then str.c_ctr() should make the trick in most cases.
sprintf/setlocale date back to at least C89, and probably even earlier - over three decades ago, this wasn't the performance and multithreading hazard it is today, because people simply weren't multithreading. Global state was the practice de jure - even reasonably so, compared to drowning everything in a sea of redundant parameterization on monochrome 80x25 terminals, in an era of smaller programs, when global state was easier to reason about.
The C89 standards committee wasn't "careless" here - it was a product of the times. Of course, when modern APIs and standards committees duplicate sprintf's issues, it's far more fustrating - even when justified in the light of "backwards compatability".
The worst part is that there is no way to turn off the locale sensitivity.
This is bad for not only performance, but correctness. If you are writing a JSON serializer and you use sprintf() to format float/double values, someone can make you produce incorrect output by setting the locale to something that uses "," as a decimal separator.
And there is no way to turn this off!
This is one moment where the difference between an application language and a system language seems clear. An application language could maybe assume that string formatting is for showing to a user. A systems language should assume that you are implementing protocols, and that mere user preference should not change your output.
The old way to turn it off would be setlocale() in your library before and after sprintf, but this is a disaster for threads, and so now there's the _l functions.
Well, you can replace setlocale() with your own setlocale that uses dlsym() to look up the "real" set locale, and your setlocale can just call the real setlocale with params (LC_ALL, "C").
That turns it off pretty well. Even if other libs call setlocale (looking at you, gtk), they'll get your version.
Kind of like a bug in PHP where identifiers with i in them didn't work properly if the locale was Turkish since PHP managed its case insensitivity by uppercasing the identifiers, but used a locale-dependent format in which, if the locale specified Turkish, would uppercase i to İ rather than I.
Chrome had a bug in how css animation values would get serialized, where you'd end up with "5,43" instead of "5.43" in many European locales.
It took surprisingly long to track down, partially due to this all being dependent on various ambient Information (env vars, browser config, arguments passed, what exact function is called, etc).
> bugs me with various libraries is when someone has made the decision to do something high level at a low level. For example, localizing inside sprintf
I'm more surprised that sprintf is seen as low level. It formats and outputs strings, just by that token the problem space is enormous. Localization is just a small part.
People are fast to forget the good old days where sending specific user strings to people would crash their browser or os. "Plain" text is not simple.
The worst libraries _only_ accept a file path or URL for unzip.
I've seen audio libraries where you can only provide a file path for playback. Guess you're screwed if you wanted to get a Vorbis file out of a tarball, decode it silently faster than real-time, and send it over the network...
Selective untar into a FIFO pipe. Not optimal in this case but you can work around the limitation with some extra work. I'd rather do this than expose some 0-day due to odd combination of rarely used libraries.
The only thing they're going to do is open it as an ifstream. Zero value is gained over taking an istream instead; initializing it is one bit of extra code for the user, in exchange for being able to pull in data from anywhere. Embedding it in the binary might be rare, but how about stdin? Or for that matter the Internet?
Recently I tried to make a Zig program that parses and formats some format containing numbers quickly, and I discovered that (after getting rid of all the places where one read function would repeatedly call another function that handles arbitrary-sized reads asking for 1 byte) I was spending a lot of time parsing UTF-8 strings to determine their length. This was surprising, because I didn't write any code that's concerned with that sort of thing! It turns out that any time you ask for a formatted value to be padded, the stdlib fmt routines will helpfully format the value, then determine its number of UTF-8 codepoints, then pad it with whatever padding byte you supplied until the number of padding bytes plus the number of UTF-8 codepoints in the formatted value is at least the width you asked for. It will do this even if you were trying to pad a number with zeroes. So now we're compiling against our own stdlib with one line changed to not parse UTF-8 strings when you ask it to format numbers.
Lucky you, in Europe I have plenty of fun working in projects where the files being parsed for ETL are written in the local language, or not, while the host OS might be English, or not.
I get your point about bloated NPM libraries but I think it's also ironic that NPM is the only registry with some of the _smallest_ packages which are equally as annoying. left-pad, ansi-yellow, ansi-red (yes, packages for a single console color with over 200k monthly downloads), is-odd, is-even (which of course, depends on is-odd and inverts it), etc.
To be fair a lot of those super tiny packages like the ones you listed were created by the same person. They have over 800 repositories on GitHub and hundreds of (probably too) small published npm packages. There's been discussion of that person and their possible motivations on HN before.
Unfortunately, it takes experience for people to realize that they're not such a good idea. A better design is the component approach. As you suggest, a zip library should not be doing file I/O.
Why should the country I'm in and the language that I'm currently speaking determine if I want periods or commas as thousands separators? Or if I want dates to be presented in one of dozens of insane formats instead of RFC 3339/ISO 8061?
Because that's what you grew up, learned, and have used forever?
Let's ask it another way - why would the country you are in and the language you are currently speaking NOT decide those things (which are defined by said languages.)
What are we doing right now to each other besides banging out some symbols that have a shared meaning?
>why would the country you are in and the language you are currently speaking NOT decide those things (which are defined by said languages.)
Because these have no relation to how a particular piece of content should be formatted. The fundamental mistake made by many of these legacy APIs with respect to localization is the assumption that the locale should be determined based on some property of the user, which is reflected in some OS or application-wide setting that applies to all content. This only works as a rough approximation in the small minority of cases where the user is a member of a cultural sphere / bubble where exposure to multiple languages is a rare exception, like the united states. In the rest of the world, it is an everyday occurrance for multiple languages to exist side by side, including within individual web pages, documents, spreadsheets, and other content, which is why the only reasonable solution is the one where a locale is a property of a piece of content in its most atomic form, not of the user.
I grew up with dd/mm/yy, comma as decimal separators, dot as thousands separator, but:
1. The fact that I grew up with those formats holds little weight for me. At this point, most of the literature I read and content I consume doesn't come from my own country. And why should it? when I have access to books, movies, websites from all over the world, and my country makes up for a tiny fraction of that. With the massification of international remote work this tendency will only increase.
2. Even when working with people from my own country, I can agree with them to use formats different from the ones we grew up with. In fact, everytime we type a floating point literal in any programming language, we do it without following the rules we grew up with.
3. In the 21st century, in the context of globalization, massification of the Internet and widespread access to computing, to keep doing these things differently by country/language makes no sense, specially when there are already good international standards that we can follow. It's not that hard to learn, either. We even accept English as the de facto language of programming and software development, and that's a full natural language that we have to spend years learning.
4. In my opinion, yyyy-mm-dd and dot as decimal separator are simply better for practical reasons. However, if we collectively decide that other formats are the "international standard" then I'd follow whatever rule we decide, as long as it's not too bad.
Can you imagine if instead of using SI units, each country used their own special units of measurement? We are definitely better off with SI, standarization makes everything so much easier. I can talk to someone from Japan about meters and kilograms and they will understand without any ambiguity.
The trouble with this is it essentially reduces all differences to "biggest player wins". Because it almost never matters what symbol or representation you use for these things, your personal preference is largely based on what you grew up with.
I could resign myself to Americans* being allowed to pick the global date format to save a few cycles on an operation, but frankly I don't want to. It's not that important to me, and not having aspects, albeit minor aspects, of my culture steamrolled in the name of pointless efficiently is at least a little bit important to me.
Differences create friction yes, but I'm alright with that. I would prefer a little friction to grey uniformity. You're free to think differently, but you're not the speaker for everybody else.
* I should clarify that most Americans don't seem to want this either, this isn't a jab, it's just that if we did standardise everything their choices would probably be the ones which won out.
I'm not OP but a related point is that locale-aware formatting can be offputting if your application is not actually translated into the language. At a previous job we delivered an English-language product that used locale-aware number formatting. Our German customers asked us specifically to use English number formatting, both because it was a technical programming product and because the application was entirely in English. German number formatting with English text was undesirable to them, and they didn't expect us to translate into German. They saw it as an American product and that carried into their expectations about formatting. We went into the code and explicitly specified the American English locale for all string formatting, and they were happy.
Out of sheer idiosyncrasy, I've developed the habit of formatting dates as dd.mm.yyyy rather than the more typical US mm/dd/yy (and long dates as dd mmm yyyy). Setting that as my date format in OS X for a long time caused the paper size to default to A4 even though everything else was set with US settings. Somewhere along the line, that particular "feature" was removed, although I couldn't say exactly when.
So dates in the US are not RFC 3339, they're MM/DD/YYYY drunk-endian. And units are all US Customary, not SI. If you want something else, you need a locale other than en.US, and if you want other things (like spell check) to match US conventions you end up needing to create a custom locale.
Having locales by itself makes sense, you after all want to map real world information into the digital realm and for that to work you have to stick with whatever real world conventions are already established. Keep in mind that computers are often just used as better typewriters and a lot of information exchange still happens on paper. Moving from paper to fully digital is a process that takes decades, so you can't just decide that digital uses '.' instead of ',' as all your printouts will be wrong.
The part that doesn't make sense in C and other languages is that locales are forced on you as global state. And not just by default, they don't even provide locale-free alternatives, you have to modify the global state and reset it after every use, which is cumbersome, slow and error prone. C++ added the locale-free `std::to_chars()`, so things are slightly improving at least, but it's still an ugly and largely unnecessary mess.
I find this annoying as well because none of the premade locales match my preference. Why can't I set my prefences, just load the defaults from a local.
Maybe I'll have to write my own locale file. That doesn't sound fun.
Ditto this. One person, one device, one language, one locale is a terrible assumption.
My Android phone is set to a language that doesn't match the country I'm in, and it hyphenates all the local phone numbers wrong. This is beyond stupid. The phone knows which country the numbers belong to. I would expect it to format each number according to the conventions of the country they belong to, not according to the language I've selected. I'm not doing anything fancy like RTL, either.
Can you output Eastern-Arabic numerals (e.g. ٠١٢٣٤٥٦٧٨٩١٠) with sprintf? My gf sometimes uses them and its confusing as fuck for me. Especially as the numbers are then written RtL.
Europe got decimal digits with their own characters via the arabs, true, but we use shapes derived from the original source, the Devanagari digits, not the shapes the Arabs developed due to their writing system/technology. South Asian languages other than Urdu are LTR.
You can see the parallels here (clipped from Wikipedia):
C/C++ locales are a trashfire. The path to enlightenment is to not use them and discard all libraries which think they can get away with calling setlocale (which a few do, but is more or less a given when we're talking about GUIs).
> obviously you should not use sprintf, you should use C++ iostreams
One thing I like of iostreams is that the standard syntax supports passing the file object in depth. If you have a custom type and want to print it, you just implement operator<<(), get a reference to the std::ostream and do what you want with it. Using printf you either have to first write to a temporary string and then print it (which requires more memory and more time to go through it, if the representation of the object is big) or break the flow anyway with something like:
printf("The object is ");
print_object(object, stdout);
printf("\n");
Does fmt support printing a custom type without breaking the format string and without using a temporary string?
fmt does this. For a given object, you specialize the “formatter” class. The formatter class parses the format string and stores the contents of the formatter string. It then writes out the formatted string to an output iterator.
fmt has a “buffer” class which is used as an interface between formatters and their two primary use cases, which are formatting to strings and formatting to files. A buffer is an abstract base class which exposes a pointer to a region of memory and has a virtual function to flush the output (sort of). When you call fmt::format or fmt::print, you’re getting std::back_insert_iterator for a buffer.
I would describe this as “surgical usage of a virtual function” because it is used exactly in a place where it is not called often (you mostly write to a buffer, and flush it less often) and its use reduces the number of template instantiations in your project. That said, the ergonomics for custom formatters is not great.
It may look like a lot to implement, but it worked flawlessly for me several years ago for a simple case, just by copying and pasting the example code there.
Iostreams aside: You're absolutely right with the trashfire.
I live in a country where the elders of the language decided that we don't format floating point numbers with a decimal point but use a decimal komma instead.
So PI is not 3.14 but 3,14 instead.
Just imagine what pain you have to go through if you want to parse a .csv file written by an application that "tried to do everything right and use locale".
> Just imagine what pain you have to go through if you want to parse a .csv file written by an application that "tried to do everything right and use locale".
Microsoft Excel will helpfully use semicolons as field separators in this case, making parsing CSVs that came out of it in an unknown locale situation even more fun!
Here in Switzerland it's even worse because it depends on where in Switzerland you're formatting the number (French parts have different rules from German parts) and whether you're formatting a number as a plain number or a monetary value.
if it's money, you use the . as the decimal separator everywhere.
if it's just a number, in the French parts, it's , in the German parts the .
Note that some languages don't follow the "break at thousand, million.." pattern, most notably south asian languages that break at the lakh and crore boundaries.
I think the only reasonable solution is to completely deprecate functions like setlocale(). Software should just use _l() functions if they want something localised.
FWIW, the tests show that sprintf_l is no faster than sprintf, if you're passing around the same locale object in different threads. So it can help, but it's not an automatic win unless you make sure that each thread keeps a separate locale object.
The point is if you make sprintf locale-unaware, then your locale-independent calls like sprintf("hello %s", "world") won't be affected by all the locale-locking nonsense that is causing the scaling issue. That's the "win".
sprintf_l can be as slow as before if you pass in the same global locale object.
The whole thing is a botch. Sadly, his happens time to time in many standards. More in software than hardware standards in my experience — I wonder why.
Snapshots are state that has very, very clear boundary conditions. There are lots of relatively sane systems that only allow modification in the interstitial. Of course as scale goes up, finding “before B but after A” breaks down, but we have whole systems running critical infrastructure based on Communicating Sequential Processes, which is the closest we’ve gotten to solving this problem.
Excuse the lack of context here, I haven't dealt much with locale's in C, and I'm probably showing my ignorance.
Why would you ever mutate a locale object? Is that the common way to change locales in C? Wouldn't it make more sense to have locale objects be roughly immutable? It doesn't seem like they should have any real reason to change very often in a typical use-case. I would think any given person only has a small (1-3 or so) number of locale's they use on any regular basis.
Are locale objects being mutated really common enough that you need a mutex to protect against accidentally rendering something in the wrong locale?
I would guess that there's just nothing in the standard which _prevents_ people from changing the locale. So if you want a conforming implementation, you need your implementation to work if the programmer changed the locale object directly.
EDIT: Nevermind, sprintf_l isn't part of the C standard, so really they could be implemented however the authors chose.
I ran into sprintf's dependence on locale recently when trying to use it in a WebAssembly module. Since I was compiling for the browser, I wanted to not depend on locale. Even setting aside the locale stuff, the wasi sdk still wanted to pull in file-related things like read/write/seek. I just wanted to do formatted print to a pre-existing buffer.
I ended up using nanoprintf — it's a single header file and in the public domain.
> And no, usual Internet advice of “MSVC sucks, use Clang”
Given that this talks about a problem in the Microsoft standard library, wouldn't the usual internet advice be "use clang, and also llvm's libc++"? If clang just compiles the same slow code as msvc, it won't magically make it fast.
It's honestly not really true on windows. Since for the longest time the msvc ABI was unstable people are used to interop through C APIs, or rebuilding everything. I personally build all my stuff with libc++ and it works fine.
If you also want to use libraries that are built against MS STL, then you can't really use libc++. Of course if you can build everything with libc++, that's fine. That's how Chromium is built, AFAIK.
That depends, on windows multiple standard libraries (even libc) can cohabit in the same process (but you have to be careful to, say, not free something in a different dll than the one which allocated it). If you are careful with that and aren't exchanging standard library types across library boundaries there won't be issued.
I went and filed this with Apple, radar #9930566, just referencing the blog post. I wouldn't assume the engineering team responsible for this code at Apple is reacting to HN posts so I figure it's still worth reporting.
Nows a good time to rant about Apple's entirely opaque bug tracking process. Every bug you file is private. The only way to know if a bug has already been filed is to file a new issue and see if they close it as a duplicate or not.
It’s on the front page of HN; I think it’s a safe bet that Radars have been filed — though I’m not sure what can be done about this. The fact that they were using os_unfair_lock indicates that someone has looked at the relevant code and tried to make it efficient; the default is a pthread_mutex_t.
fmt is a must if you can get away with using it. On smaller systems you should try strf, as it produces very small memory footprint and doesn't blow up your binaries. It is 5x smaller than fmt in footprint.
Locale in library functions in general is an example of solving problems at the wrong abstraction level. It also breaks the fundamental idea of how shell tools are meant to be usable in unix. If they don't have stable outputs, then they become unusable.
(We had to create a set of locale-resistant and consistent versions of shell tools for a system that made heavy use of shell tools to process large amounts of data across thousands of machines. All you needed was one misconfigured locale on one machine and the result would be chaos).
You pay the cost every time rather than when you actually care about it. And when you really don't want locale to interfere it still comes back to haunt you if you don't pay special attention to it. (Remember how Python had locale-dependent XML-RPC that made sure two machines with different ways of formatting floats behaved?).
I wonder if the original export code that lead to this investigation was actually correct? It sounds like sprintf() was being called without an explicit locale. This can be fine if Blender does a top-level setlocale(), but can also be subtly and horrible unfine otherwise...
Sounds like there's plenty of opportunity for library-level improvements here (as well as application-level workarounds), but certainly the sprintf_l(..., locale, ...) being slow is the most surprising to me, and likely the easiest to fix.
> Given that this is an Apple operating system, we might know it has a snprintf_l function which takes an explicit locale, and hope that this would make it scale. Just pass NULL which means “use C locale”:
Right. My point is that the change from sprintf() to sprintf(..., NULL, ...) is a semantic-modifying change. For the purpose of understanding what's going on, that's fine. For the purpose of optimizing software, that's scary. And even scarier is that it seems somewhat more likely that the version under test is more correct than the version as shipped.
> Technically, there are no bugs anywhere above - all the functions work correctly
They were all correct. But yeah, scary how primitives can result in such poor performance. FWIW, they ended up using {fmt}: https://developer.blender.org/D13998
The point parent poster os making, is that previously, unless set_locale was being called by blender, the resulting export of a blender object was locale dependent. The change from sprintf(..) to sprintf(..., NULL), would then actually change the behavior (not just performance) of the program.
I've recently implemented the secure variant without any locale support. Took me a day.
Now just the secure scanf family is missing, and this needs locale support unfortunately.
Particulars aside, the pathology is exactly what I’d expect from low level manually managed memory languages. Oh you want a multithreaded abstraction without a VM or GC and you want it to just work? It’s going to do a lot of extra work or it’s gonna be the next “logging failboat” or both.
WSL2, afaik, runs modified Linux kernel (maintained by MS) on a modified Hyper-V. I would hardly call that a VM in traditional sense because it might introduce differences in behavior. However it is true that WSL2 uses virtualization technologies, so in that sense it is a VM. But calling it "a real Linux in a VM" is a stretch imho.
Reading this article motivated me to take a closer look at printf/iostreams alternatives, and I have to say, the mentioned {fmt} library finally made me switch, so thanks for that!
> So given all this knowledge, presumably, if each thread used a physically different locale object and snprintf_l, then it would scale fine. And it does:
The author provides several graphs in which what appears to be the total execution time stays constant as the number of threads varies, and this is described as "good scaling".
How do I know what's being graphed is the total execution time?
> Converting two million numbers into strings takes 100 milliseconds when one CPU core is doing it. When all eight “performance” cores are doing it, it takes 1.8 seconds, or 18 times as long.
This corresponds to a curve where "one core" takes the value 100 and "8 cores" takes the value 1866.
But isn't constant execution time as we increase from one thread to eight threads terrible scaling? What's happening here?
If it takes 10 seconds to compute something once on one core, and it still takes 10 seconds to compute it eight times on eight cores, you have achieved perfect scaling. They're performing 8x as much work for the 8-core test. That is: they are converting 16 million numbers, two million per core. I think maybe you're thinking they're running the same amount of work for the 1- and 8-core tests, but that's not what this test is doing.
It is a slightly confusing way of presenting it though.. would’ve been a bit clearer IMO if the graphs were showing “time taken to complete 2 million conversions” with the graphs sloping downward as cores increase in the good cases (scaling) and going upward (pathological / lack of scaling) for the bad cases..
This is much easier to read. Determining if a line is perfectly flat is easy. Determining if a line is perfectly 'c/x' (plotting time for same total work) is really hard.
Plotting 1/runtime makes interpreting the actual meaning of a single point much harder. So that is also out of the question.
I took this approach to mean "threads don't affect eachother's performance". Which is easily seen to be equivalent to perfect scaling.
> Which is easily seen to be equivalent to perfect scaling.
Why? If the whole can be less than the sum of the parts, it can also be greater than the sum of the parts. Maybe two threads can do double the work in 150% of the time. But that would make for a funny definition of "perfect".
> If the whole can be less than the sum of the parts, it can also be greater than the sum of the parts
It can't be. That's not possible with CPU cores. It can be less but it can't be more.
Here's a proof: you can always timeshare two threads on a single core. If two threads can do 2x the work in 1.5x the time, then you can run that same code on one timeshared core to do 1x the work in 0.75x the time. Thus we have the concept of perfect scaling where double the cores can do, at most, double the work; you can't do better than that because whatever technique you used to achieve it can still be applied back to the single core.
(I'm sure other proofs exist, but the above should be sufficient to show why you can't beat perfect linear scaling.)
The reverse is not true. Just add in a mutex and your perfect scaling is ruined, because some of the CPUs have to wait. The more CPUs you add, the greater the amount of wasted CPU time because the mutex causes one part of the computation to run on a single core.
I don't think author understands what zero cost abstraction means. He does have a point about c++ standard library still being a hit or a miss regarding performance.
With most locking logic, it’s a series of escalations from the most optimistic/polite to least polite solution, and the worst case behavior is when a sequence always goes to the worst case scenario. In these situations, assuming the worst up front, and jumping straight to it or something very similar saves a lot of bargaining that leads to cache pressure and branch prediction.
ETA: It's also quite common in engineering blogs for languages, libraries or frameworks, an entry detailing how in the new version they have made a performance improvement by making the fast case faster, or the predictor more accurate, and then removed option 2 from the decision tree, so that we get a bigger benefit from the happy path and the average case, and as a benefit the system is now simpler as well.
I'm probably dumb but one of things that bugs me with various libraries is when someone has made the decision to do something high level at a low level. For example, localizing inside sprintf,
An another example might be an unzip library (read a zip file). Ideally the library should be small IMO. The simplest might you you pass it a bucket of bytes. If you want to make it flexible then you pass it some abstract interface (or 1-2 functions + void* userdata) so you can supply a "read(byteOffset, length)". You can then provide, outside of the library, streamed files, stream networking, etc...
But, bad libraries (bad IMO) will instead provide like 12 overrides "unzip(void* bytes), unzip(const char* filename), unzip(socket), unzip(url)" and end up including the world in their library. This kind of "try to do everything" is extremely common in npm libraries :( I don't need your library to include command line parsing! If you want to make a tool, make a library, then make a separate tool that uses that library. Keep the 2 separated so users of the library don't need dependencies that only the tool needs. (probably the most common npm example but there are lots of others)
Really surprised something as low-level as sprintf needs locale. Even streams I'd expect maybe a Date object would but not the stream itself.