> It's also not difficult to test as long as you write it to be testable. It may be more verbose & cumbersome but it's not actually difficult. That means you provide hooks testing the singleton implementation to bypass the singleton requirement but in all other cases it acts like a singleton.
At that point you're adding complexity that has a real risk of bringing in bugs in the non-test case. Nothing is impossible to test if you try hard enough, but the more costly testing is, the less you'll end up doing.
> As an example, consider Android JNI. The environment variable is very cumbersome to deal with in background threads & to properly detach it on thread death. It also requires you to keep track of the JavaVM & pipe it throughout your program's data flow where it might be needed. It's doable but it's conceptually simpler to maintain the JavaVM object in a global singleton and have the JNIEnv in a thread-local singleton with all the resource acquisition done at the right time. It's still perfectly testable.
Not convinced - to my mind the conceptually simple thing is for every function to be passed everything it uses. If you instead embed the assumption that there's a single global JavaVM that could be touched from anywhere, then that adds complexity to potentially everything, and any test you write might go wrong (or silently start going wrong in the future) if the pattern of which functions use the JavaVM changes (or else you treat every single test as a JavaVM test, and have the overhead that goes with that). For some codebases that might be a legitimate assumption, just as there are some cases where pervasive mutable state really does reflect what's going on at the business level, but it's certainly not something I'd introduce lightly.
> If you instead embed the assumption that there's a single global JavaVM that could be touched from anywhere, then that adds complexity to potentially everything, and any test you write might go wrong (or silently start going wrong in the future) if the pattern of which functions use the JavaVM changes (or else you treat every single test as a JavaVM test, and have the overhead that goes with that)
Not sure I follow. If you expect any code to invoke JNI then you are still responsible for explicitly initializing the singleton within the JNI_OnLoad callback. If you don't the API I have will crash so definitely not a silent failure. There's no external calling pattern to this API that can change to break the way this thing works. As for why this is needed it has to do with the arcane properties of JNI:
1. Whatever native thread you use JNI on, the JNIEnv must be explicitly attached (Java does this automatically for you when jumping from Java->native as part of the callback signature).
2. Attaching/detaching native threads is a super expensive operation. You ideally only want to do it once.
3. If you don't detach a native thread before it exits your code will likely hang
4. If you detach prematurely you can get memory corruption accessing dangling local references.
5. It's not unreasonable to write code where you have a cross-platform layer that then invokes a function that needs JNI.
If you're avoiding all global state you only have the following options:
A. Attach/detach the thread around every set of JNI operations. This stops scaling really quick & gets super-complicated for writing error-free composable code (literally manifests as the problem you're concerned about with code flow changes resulting in silent bugs).
B. Anytime you might need to create a native thread, you need to pass the JNIEnv to attach it. If the native thread is in cross-platform code suddenly you're carrying a 2 callback function pointers + state as a magic invocation as the first thing to do on a new thread creation & the last thing to remember to do just before thread exit. Also you have to suddenly carry through that opaque state to any code that may be invoking callbacks that require JNI on that platform. This hurts readability & risks not being type-safe.
At the end of the day you're actually also lying to yourself and trying to fit a square peg in a round hole. JNI is defined to use global state implicitly throughout its API - there's defined to be 1 global JavaVM single instance. Early on in Java days JNI was in theory designed to allow multiple JVMs in 1 process but that has long been abandoned (the API was designed poorly & in practice it's difficult to properly manage multiple JVMs in 1 process correctly with weird errors manifesting). This isn't going to be resurrected. In fact, although not implemented on Android, there's a way to globally, at any point in your program, retrieve the JVM for the process.
In principle we're in agreement that singletons & globals shouldn't be undertaken lightly but there are use-cases for it. It's fine if you're not convinced.
> A. Attach/detach the thread around every set of JNI operations. This stops scaling really quick & gets super-complicated for writing error-free composable code (literally manifests as the problem you're concerned about with code flow changes resulting in silent bugs).
Sounds like a monad would be a perfect fit, assuming your native language is capable of that. That's how I work with e.g. JPA sessions, which are intended to be bound to single threads.
> At the end of the day you're actually also lying to yourself and trying to fit a square peg in a round hole. JNI is defined to use global state implicitly throughout its API - there's defined to be 1 global JavaVM single instance.
Of course if you're using an API that's defined in terms of globals/singletons then you'll be forced to make at least some use of globals/singletons, but I wouldn't say that's a case of singletons being "useful" as such. And if you're making extensive use of such a library, then I'd look to encapsulate it behind an interface that offers access to it in a more controlled way (using something along the lines of https://github.com/tpolecat/tiny-world).
At that point you're adding complexity that has a real risk of bringing in bugs in the non-test case. Nothing is impossible to test if you try hard enough, but the more costly testing is, the less you'll end up doing.
> As an example, consider Android JNI. The environment variable is very cumbersome to deal with in background threads & to properly detach it on thread death. It also requires you to keep track of the JavaVM & pipe it throughout your program's data flow where it might be needed. It's doable but it's conceptually simpler to maintain the JavaVM object in a global singleton and have the JNIEnv in a thread-local singleton with all the resource acquisition done at the right time. It's still perfectly testable.
Not convinced - to my mind the conceptually simple thing is for every function to be passed everything it uses. If you instead embed the assumption that there's a single global JavaVM that could be touched from anywhere, then that adds complexity to potentially everything, and any test you write might go wrong (or silently start going wrong in the future) if the pattern of which functions use the JavaVM changes (or else you treat every single test as a JavaVM test, and have the overhead that goes with that). For some codebases that might be a legitimate assumption, just as there are some cases where pervasive mutable state really does reflect what's going on at the business level, but it's certainly not something I'd introduce lightly.