6

Context: I'm working on an open source project to solve a problem that comes up in ad-tech and social media data mining: indexing boolean expression trees, and matching them against incoming documents. The expression trees that come in from the user need to go through some transformations before they can be inserted into the index. This can be an O(2^N) operation, that eventually generates a binary rule that's very quick to add.

I want to encourage users to cache this binary form in case they want to add it again (ie next time their application starts up, or if it's going to be distributed across several nodes). They would need to cache it in an external data store, since the library itself runs entirely in memory.

Right now there are two methods of adding a rule from the Java API:

// Alternative (A): two-step method
byte[] rule = RuleDB.treeToBinary(...);   // static method to encode it (potentially slow)
db.addRuleBinary(rule);                   // actually add it (quick)

// Alternative (B): do both at once
db.addRuleTree(...);

Version (B) is almost the same as A. The only difference is that it makes an extra copy of the buffer to pass it back to Java (where it will be GCed), and requires another JNI call. These costs are small compared to the cost of converting a rule, but if you're adding 2 million rules and aren't able to cache these intermediate buffers, then they're still real costs.

I'm wondering whether I should provide (B) at all. It is potentially slightly faster, and a bit more convenient. However, I also want to encourage users to use alternative (A) where possible. Alone, (A) doesn't do anything special, but it strongly suggests the rule can be cached so the user can make their own conclusions without reading documentation (which, let's be honest, they probably won't).

2
  • Would some users want to use the byte [] rule, returned in the first step of (A) except to pass it as a parameter to the second step?
    – Darius X.
    Dec 23, 2018 at 12:59
  • 1
    @DariusX. Not directly, but ideally they would cache it in a database, and pass it again next time the app starts up, or share with other instances of the app. Dec 23, 2018 at 13:02

3 Answers 3

2

For APIs that tackle a lot of disparate concerns from interop to performance to versioning for backwards binary compatibility and so forth, I find it substantially less painful in the long run (and have the scars to show for it) to omit such convenience and utility functions from the "raw", exported API itself in favor of a smaller legacy to maintain.

The last thing I want to do is find that I need to extend the versioning and ABI struggles to functions exported for the sole purpose of making the library more convenient to use. It's difficult enough to maintain such library interface designs for decades without trying to make them as easy to use as possible in their "native/raw" form by adding (and exporting) all kinds of additional functions to those interfaces for the sake of convenience. Instead I favor minimalism there and maximum breathing room to minimize the probability of deprecation and all sorts of extra hurdles imposed on backwards compatibility at the cost of convenience to users of said library in its "raw" form.

Wrapper on Top

That said, I'm not suggesting to make the library easier to implement/maintain at the cost of actually being more difficult to use. What I suggest in such cases is to create a wrapper sort of library on top which tackles convenient usage in a target language as its fundamental concern. In many cases where I favor that kind of wrapper approach, I actually discourage using the "raw", say, C API in favor of the wrappers.

And emphasis on target language, because if your API is a C API, for example, as might be required for FFIs or JNIs and things of this sort, and you're using it from other languages like Java, it's never going to be all that convenient, or idiomatic, or safe to use in its native C form no matter how hard you try, while any effort to do so could add serious grief in the long run as far as maintenance of legacy code. So this wrapper library tackling convenient, safe, idiomatic usage is something I'd suggest to write in Java in that case, while keeping your C library as minimalist and as easy to implement/maintain/version as possible so as to minimize the difficulties of, say, versioning interfaces and preserving backwards compatibility.

Maintaining the Wrappers

This might raise the question, wouldn't we still have to maintain the wrappers on top the exported API? Yes we do to some extent, but they don't have backwards ABI-level compatibility concerns since they can be internally linked to the user's binaries. So we have much more breathing room to change those wrappers without breaking stuff built ten years ago against older versions of the wrappers.

With the "raw" API that exports its functions across dylibs/shared libs, unless backwards compatibility is not a concern, you have to keep whatever functions in there in their original form for as long as backwards compatibility is preserved, even that silly function that someone added 12 years ago that is causing so much grief to keep in there because its signature requires that the implementation utilizes global variables. So there I find it much less painful to keep that exported, versioned API as tiny and as simplistic as possible with little to no concern about convenient usage.

Performance

Now specifically when performance concerns are high with an API, I would also suggest, sometimes, to even almost deliberately design very inconvenient-to-use functions in order to minimize their probability of requiring changes, because otherwise the performance concerns could multiply the reasons for change. As a specific example, consider this function in the OpenGL API:

// glVertexAttribPointer — define an array of generic vertex attribute data
void glVertexAttribPointer(GLuint index,
                           GLint size,
                           GLenum type,
                           GLboolean normalized,
                           GLsizei stride,
                           const GLvoid* pointer);

Gross! First they've abandoned type safety in favor of just accepting a void pointer to the vertex data with a type parameter that specifies what type of data/format we're using (ex: GL_UNSIGNED_INT_2_10_10_10_REV).

Second there's a normalized parameter just for fixed-point fields that specifies whether their values should be normalized when accessed by the shader or not, which isn't even relevant for all types.

There's also a stride parameter which allows people to pass in data that isn't tightly packed and potentially interleaved with other data, but that also throws a wrench into type safety and makes the function just skip over bits and bytes to read to get from one field to the next.

And from the standpoint of general SE, it's arguably a poorly-designed function, and not even convenient to use from a C point of view. But I'd say it's quite well-designed when you consider the performance and backwards compatibility requirements of OpenGL, because it has few reasons to ever need to be changed. If they want to support a new vertex data format, like half-floats, they don't need to change the function signature or introduce new functions. They can merely introduce a new constant, like GL_HALF_FLOAT. If it is more efficient to normalize such data as required in the function rather than outside, that base is covered with the normalized parameter. If they find it more efficient in some cases to pass all this vertex data in interleaved, as opposed to a packed format, then the stride parameter handles that case without requiring changes or new functions to be introduced.

So it's designed in a way so as to minimize the probability of requiring future changes while happily sacrificing convenience for the user, which imposes the fewest obstacles when it comes to maintaining backwards compatibility. So in cases where both performance and backwards compatibility are sufficiently critical requirements (two very tricky ones to adhere to, and especially when combined together), I actually recommend designing functions this way so as to minimize the probability of requiring changes at the cost of convenience, safety, whatever you have to sacrifice to minimize the probability of API changes (which can be enormously costly and constantly tempt us to abandon backwards compatibility), because you can regain whatever you sacrificed in order to achieve this quality by merely wrapping the exported functions, and those wrappers are much cheaper to change than the exported API.

7

Well, that is actually not uncommon.

Let's take a well-known bignum-library, the GNU MP Bignum Library.

It has high-level functions and types for integer (mpz_@@), rational (mpq_@@) and floating-point (mpf_@@) arithmetic.
Also, some miscelaneous functions.
That's the high-level interface.

In addition, it has low-level functions (mpn_@@) which only do number-crunching. Neither setup, nor memory-management, nor any other curlicues.
That's the low-level interface, which is used by the rest.

You can make equivalent observations about nearly every GUI-library, as another example.

Building a new level of abstraction, which allows more convenient use, on a lower level is quite common, and often dipping into the lower level is explicitly supported for full performance and ability when the abstraction might hinder either.
Having to re-invent the wheel just to be allowed to paint it red would be quite a hassle.

1
  • 4
    "Having to re-invent the wheel just to be allowed to paint it red would be quite a hassle." I really love this quote Dec 27, 2018 at 21:42
2

Do provide the low-level alternative.

You may also consider supporting it more clearly and robustly, by replacing the intermediate byte[] with a CompiledRule class.

The scenario reminds me of the Java regex package java.util.regex, which transforms regular expressions from strings to the Pattern class.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.