Copying data is wasteful, mutating data is dangerous
pythonspeed.com
pythonspeed.com
Interior mutablility:
A, immutable, points to B.
B, immutable, points to C.
C, mutable, points to D.
D, immutable, points to E.
Even though A and B are immutable -- B points to C mutably, so you can modify its contents. Some "interior" part of A (and of B) is mutable. You can't change which C is pointed to by B (because B is immutable, or rather we only have an immutable reference to it), but it's open season on the contents of C.
What this article describes:
We have 2 operations.
We are passed in Array A.
We can either:
- Apply both operations to A, modifying it even though the "owner" may not want this.
- Copy before each operation (or as part of them), which leaves A untouched, but requires triple the memory usage.
- Cooy before (or as part of) the first operation only. The second operation can be applied in-place on the copy. This leaves A untouched and only requires double the memory.
They're totally different concepts. I don't know what you'd call the second concept -- I think the important part is that A remains untouched, so... "Avoiding side effects while reducing memory usage", or "How to make performant pure functions" ?
Avoiding unintentional or unexpected side effects is a good thing. Pure functions are great. Do whatever you can do keep your visualization helper thing pure and avoid bugs.
Then, once it works, if you have performance issues, do some profiling.
If the profiling shows this helper method as a hotspot, then it's worth optimizing.
At that point, you'd want to follow standard Python, numpy, and pandas optimization strategies -- which I'm not familiar with.
Aimless optimization like this is a waste of time. Good program structure (data is available when you need it without tonnes of calls), the right choice of data structures and algorithms (hashmap vs list), the right choice of underlying tools (Julia vs Python) and targeted optimization (profiling) are the ways to go.
>> Many NumPy APIs include an out keyword argument, allowing you to write the results to an existing array, often including the original one.
I'd wager that saving memory or memory bandwidth is the impetus for expanding the API to include this option.
So it's suggesting to use these techniques when you already know that you are working with a large chunk of data, either because it was obvious from the beginning or because profiling highlighted it. In scientific computing it is common to store gigabytes of data in a single array. In fact it is common that even the compromise-solution suggested in this article, which leads to 2x memory instead of 3x, would be too much extra overhead.
But the compromise solution is nice if you can afford 2x memory overhead, so I upvoted the article.
"Premature optimization" is not an excuse to write obviously bad code. Fixing this simple thing now is much faster, and simpler, than digging a big hole, jumping in it, and then hoping you can find something to pull yourself back out later.
In the same way it's important to scatter about asserts and verify parameter arguments do in fact conform to the documented contract, it's also important to always keep an eye out for openly wasteful code.
No, that's the point. It's really not. If your "openly wasteful code" does its job well and is readable and maintainable, it DOES NOT MATTER that it is "wasteful".
If, on the other hand, the wasteful code causes your program to execute in 60 minutes instead of 10 seconds every time you run it, then it does matter and you should optimize it.
Why is this such a difficult concept?
This article is not advocating premature optimization as Knuth was discussing nor does the article's advice result in the evil that Knuth was warning against.
If you have some other evidence or paper justifying why we can ignore obvious inefficiencies please provide a source. That's a rather radical claim to make, though, which may be why you find people struggle with the concept.
I think you mean that B points to D mutably because C is mutable. The pointing from B to C is locked in place because of the immutability of B. However, C is free to change it's pointers to some other D.
struct S {
a: Cell<u64>
}
struct T {
b: u64
}
To mutate field `b` on `T`, we need a mutable object or reference. However, we can mutate field `a` on `S` via an immutable reference. Typically this is used to encapsulate some kind of interior state that isn't visible outside of the struct's methods, and helps in certain situations where using a mutable reference might cause some hair pulling due to the borrow checkerSay the compiler discovers an optimization like "we don't have to copy this giant array". Now either you care or you don't. If you don't care, it was wasted work. If you do care, now you have some fragile, implicit set of preconditions to maintain the optimization. How do you document these, how do you test it?
Instead, in a high performance language, we should:
1. Make the language do the algorithmic heavy lifting. Rust's annoyingly explicit affine type system enables its deterministic memory management and various safety guarantees. Its generics are explicitly monomorphic, which enable aggressive inlining. etc.
2. The compiler's optimizer sweeps up just the details. Register allocation, constant propagation, inlining, all the classics. But not inter-procedural stream-fusing tail-calling Haskell-heroism.
The idea is to make the important optimizations explicit, and allow the compiler to make things faster on the margins and avoid the obviously wasted work.
Best-effort is fine if it's run an ecosystem where code changes are tested before deployment, which is everywhere performance matters.
48362/37277295;
That's a no-op. (Some rational value you're not using - it's not even assigned to a variable name).
The compiler might warn you about it.
But do you think it should be allowed to just remove it - not even create the op?
Regardless of what the standard says, I'd expect the answer to be "sure."
What do you think?
It does feel a bit closer to Clojure's transients which I guess it's what it's referring to.
However there are also a number of differences with Clojure:
* Clojure can not completely prevents the caller modifying callee data (at least using "normal" clojure collections, which are persistent)
* creating and reifying transients is O(1)
* the operation is a fairly trivial mapping of #(/ (- % low) (- high low)), which is likely what you'd have done in Clojure
> If a pure function mutates some local data in order to produce an immutable return value, is that ok?
The idea in Clojure is that data is immutable by default but there are function-local mutation APIs specifically for optimization. The contract between caller and callee is still the same, a function never mutates your data, but transients provide improved performance and memory characteristics invisible to the caller.
Sadly by the time I realised the screwup I couldn't edit the comment anymore (one of the really annoying things about HN alongside stripping random characters from comments and the half-assed markup).
On the surface that can seem a bit stupid, but the practical outcome is that compilers can determine when an array's contents can be mutated in place rather than overwritten.
A variable you read over and over can be thought of theoretically as one you duplicate, then read one copy of. Since the act of reading the copy destroys it, a compiler implements this by merely reading the value directly.
Passing an array into a subroutine however means the compiler either copies it, or forbids further access after passing it in. If it's the latter, the memory space is available for re-use inside the subroutine.
Rust uses affine type theory for its borrow system to provide memory safety. Function language research is looking at linear and affine types for a variety of applications including performance.
Also, that doesn't seem true for branching structures, but I guess technically in an immutable language loops iterations are recursive functions.
With Rust-style interior mutability, any code accessing the value will see that it was mutated, but here the caller still sees a purely functional call, since the mutation is hidden.
Whilst 2n array alloc is better than 3n, both are creating short lived memory objects. Do that in a hot code path and suddenly you've got tremendous garbage pressure your garbage collector is going to choke on.
One can optimise this by calculating results into a cached array, but that creates opportunities for bugs (results of previous caches persisting). I would dearly love to see a solution that allows me to code in a functional style without sacrificing performance.
I suspect if you look at how they do things you'd find some domain-specific design patterns.
Mutability is always (at least as current computer architectures are concerned) going to be faster in a general sense.
In the context of arrays, it helps when programmers use higher-order combinators/functions, e.g. maps.
There is a mutable way to do a map, and an immutable way, and which should be used in the compiled program depends on analysis of where the variable is used.
Golang still uses one global heap; it just allows for stack based allocation in situations it can do it (avoiding putting stuff on the global heap), so copies _can_ be cheap re: GC.
You clear the buffer or capture it inside an optional type. When you're done with the buffer you can reset it to the default state to be used again.
Most of the issues are logical bugs though, not flaws with the pattern. You just need to reason about the computation that you're doing to preallocate exactly how much data you need outside your hot loops.
You can read some audio source code if you want, this is an extremely common pattern. Preallocate for your computation up front by exactly how much you need (and often with cache-friendly data structures) and don't get crafty reusing buffers where you don't need to.
Uniqueness types would make mutation easier to track: you'd still need to write an imperative style, but less to worry about.
I think the "functional-style" stuff in this area uses compilers (i.e. accelerate or futhark) that can analyze whether copying is necessary because the programmer uses various high-level functional combinators. I think J does something like this as well (?)
Uniqueness types are only necessary if you want to make the in-place update a static guarantee (which can be crucial if the code is going to run in an environment where allocation is not possible).
Disclaimer : I have no experience with game development whatsoever !
[0] HTTP requests, video frames, etc
If the code is isolated to some specific logical module, like the game AI, you can bury it in a private helper method that at least hides the details from callers.
I have no idea whether python or any other language supports this out of the box, but I would expect there are libraries that do this, and if not, it shouldn't be too hard to write this yourself. The main thing is probably that you need to separate the normalisation parameters from the normalisation itself, and after that, you can simply do something like (in javascript):
array.forEach(e => visualise(normalise(e, min, max)));
Of course visualisation is probably going to involve a new datastructure that's going to hold this data in some way, so there's still going to be another copy of that array living somewhere. That's unavoidable.That way if you mess up your algorithm and attempt to access the old version of an object from a stale reference, you at least get an exception.
If you use a struct it makes the pointers fat but there's no allocation.
In the example of the blog post, you can reduce the resulting data to the following: - The original array (already exists) - The minimum (Integer, not GCed)¹ - The maximum (Integer, not GCed)¹ - A normalize(int, int, int):int function (can be re-used)²
None of the above give the GC any additional work, but passing them around is way more annoying than just making a normalized copy of the array; and abstraction into a class that encapsulates all of them would mean you have at least one GCed object again, which you don't want.
¹ I don't know how exactly this works in C#, but in Lua integer values aren't garbage-collected, as copying them around directly is no more expensive than copying a reference, and they're immutable anyway. I assume it's the same or at least very similar in C#.
² Instinctively, I want to turn this into a closure that just takes one value and closes the min and max values, but that'd be another GCed object, which we don't want in hot loops.
E.g. you have a function that mutates and one that doesn't, you can't accidentally use the mutated array since the borrow checker won't allow you to use it afterwards.
But if you still want to use the mutated version you have to explicitly return the mutable reference to the caller, making it immediately obvious what you're dealing with.
Might also cause some other weird side effects. Some older versions of C# (or more specifically the .net CLR) had some issues with the heap space for large objects when it got used by external code. I believe the reason was that data allocated by external libraries had to be placed sequencially on the heap due to some marshalling constraints (or maybe the LOH always allocates sequentially?). After a while the heap got fragmented and you could get into a situation where all memory checks told you that you can still allocate another large chunk but the subsequent allocation crashed in a not so graceful manner since the memory wasn't available in sequence.
Took quite a time to even understand what was happening. I didn't really follow up to how the CLR-developers solved this issue. This was ages ago and has now been fixed. Certainly an interesting problem.
C# has this awesome using statement so you can at least indirectly influence when garbage collection occurs and ensures objects are as short lived as possible.
I don't think there is a golden way to Rome. Personally I think keeping the life-time of any object as short as possible is still the way to go and it helped with our problem. The GC of C# is aggressive towards young object that it assumes to be short lived. But I would never recommend to write code with the GC in mind until it starts to be an issue. Which might be right at the start for game developing, but otherwise I don't really care too muchabout keeping temporary copies.
Some disposal actions will dereference objects (this.thing = null) and thus add garbage and therefore garbage pressure for a GC run to clean up, so it is an indirect influence, but it is very indirect, and not what `using` and `IDisposable` are meant to do.
You shouldn't rely on `using` for GC management, and making things that don't manage resources (such as file handles, sockets, etc) `IDisposable` isn't a good way to ensure they are short-lived (and doesn't add any additional signal to the GC that the objects are short-lived; some `IDisposable` objects are incredibly long-lived).
Better tips to managing short-lived objects are to keep them as small as possible, avoid unnecessary references to/from other objects, avoid accidental captures into lambda closures, and consider if stack allocation makes sense for your scenario.
One way to alleviate the problem is to use naming convention.
normalize(a) # mutates in place. note: no return value.
b = normalized(a) # no mutation. returns a new instance. note the adjective.
Python does this in the standard library a.sort()
b = sorted(a)I think this is something that is a slightly less obvious when dealing with classes. I see stuff like this in academic code all the time:
class SomeDataObj:
def __init__(self, data):
self.data = data
def normalize(self):
self.data = normalize(self.data)
I'd much prefer for there to be variable `self.data_normalized` which is initialized to None or even better a getter `getNormalizedData()` that computes/caches a normalized variable.a = a.sort or a.sort!
[1] https://docs.julialang.org/en/v1/base/collections/#Base.sum!
Both Julia and Ruby got this from Scheme and it might be even older than that.
The other two major ones are the number of core ways to the the same thing and the slowness of the interpreter. The first is a matter of taste. The second is a technical issue with the interpreter mostly independent of the language and/or a question of fitness for specific projects.
The solution was to dictate that the API is copying. But also provide ways to allow the user to manage their own memory.
e.g.
a = tensor.New(tensor.WithShape(2,2), tensor.Of(Float64))
b, err := a.Apply(sigmoid) // copies
c, err := a.Apply(sigmoid, tensor.WithReuse(a)) // mutates
Docs: https://godoc.org/gorgonia.org/tensor#Dense.ApplyI spent far too much time yesterday figuring out why nothing was being replaced, when I called replace on a string in Javascript
https://wiki.haskell.org/Monad/ST
It allows you mutate arrays and other stuff "on the inside" like this article suggests, but it's also able to wrap up the operation so it appears pure from the outside, so the caller doesn't have to guess or rely on naming conventions to figure out the mutability.
It's your choice on whether you want to do the copy on the way in ('thawing'), on the way out ('freezing'), or both, by using the 'in-place' variants of thaw and freeze.
why is that a problem?
You could imagine some hypothetical language in which all objects are immutable, as far as the programmer is concerned - however the compiler is allowed to reuse objects behind the scenes if there is provably no way that a side-effect could be introduced.
E.g., in such a language, the first variant would be the only legal form to write the normalizing function:
low = array1.min()
high = array1.max()
array2 = array1 - low
array3 = array2 / (high - low)
return array3
However, this could be safely turned into the following during compilation: low = array1.min()
high = array1.max()
array2 = array1 - low
array2 /= (high - low)
return array2
... because array2 is not visible outside the function and therefore could not cause side-effects.I don’t know the state of NumPy on PyPy since I don’t use PyPy.
I'm not sure if it's doing inlining before the laziness analysis, which would be ideal.
[1]: https://pythran.readthedocs.io/en/latest/INTERNAL.html#lazyn...
I was doing something similar for SQLAlchemy recently. SQLAlchemy is all about capturing Python operators and operands into data structures that represent intent which can be invoked later.
edit: here is a demonstration: https://gist.github.com/zzzeek/caa4a7ed94f326fbbc031acecb9d7...
edit edit: it is actually possible with numpy by using the Dask library: https://dask.org/
i can say for my side of things thunking is a super huge performance gain as it allows for quick gathering of expression intent and then the computation side on the other side of a cache.
Accelerate is a nice example of an array library with a JIT backend.
There are functional languages that do this. Like many other optimizations, you get less than your intuition might naturally expect, but it does work.
A trivial case is "tail call optimization", which most immediately involves not creating a literal in-memory stack record for recursive invocations but also can easily involve the compiler re-using the memory for the arguments. IIRC Erlang will do that as much as it can in tight recursive calls.
Erlang/BEAM fits your hypothetical to some degree. Erlang the language has immutable variables; however, BEAM, the virtual machine, has mutable registers. Whether or not a given code sample results in copy or mutation depends on what information is available to the compiler, of course.
As for the overall problem: always choose immutability and then make it faster by first taking advantage of optimization patterns that immutability allows you to use safely. In a case like this, doing the normalization lazily is what springs to mind.
If memory allocation and de-allocation is an issue, use more explicit memory management. Though by experience it is rarely the actual problem. There's basically no chance that it is an issue here, Python interpreters aren't stupid enough to give back memory to the OS immediately after a variable falls out of scope.
Reason being that in our group we have a ton of normalization code that looks exactly like the 'bad' example from this post. As we use relatively large datasets (4D MRI images), memory is a bit scarce...
Theoretically, you can make every API mutate data. If the user didn't want to mutate data, they can explicitly make a copy first. Whereas if there only exists an immutable API to return new objects, then it's hard or impossible to get the benefits of mutating in place.
As far as I know, only Rust, C++, and C give support at the type-checking level to denote which functions will modify the interior of their arguments. This way, you can distinguish behaviors easily - for example in Rust:
impl Vector {
// The method reads this current vector and returns a new one.
pub fn normalize(&self) -> Vector { ... }
// The method reads and modifies this current vector in place.
pub fn normalize(&mut self) { ... }
}
The article gave this subtly flawed example in Python: def normalize_in_place(array: numpy.ndarray):
low = array.min()
high = array.max()
array -= low
array /= high - low
def visualize(array: numpy.ndarray):
normalize_in_place(array)
plot_graph(array)
data = generate_data()
if DEBUG_MODE:
visualize(data)
do_something(data)
In Rust, it would be a type error because visualize() takes a reference but normalize_in_place() takes a mutable reference: fn normalize_in_place(array: &mut numpy::ndarray) {
let low = array.min();
let high = array.max();
array -= low;
array /= high - low;
}
fn visualize(array: &numpy::ndarray) {
normalize_in_place(array); // ERROR
plot_graph(array);
}
let data: numpy::ndarray = generate_data();
if DEBUG_MODE {
visualize(&data);
}
do_something(&data);1. C and C++ allow casting away constness, which may or may not be UB depending how the parameter is defined
2. Swift also provides this ability to an extent: struct parameters have to be flagged `inout` to be mutable
I'm sure there are others.
We must have very different notions of "virtually never": https://github.com/search?q=const_cast&type=Code
> has to be done very explicitly.
That doesn't make it visible to the caller.
All strings have a reference count. When you change a string, and the ref count is not one, it is copied before the change. If the ref count is one, it is not copied
you don't get much benefit from persistent structures if every element of an array is modified, like in the examples in the post.
1. Immutable first. 2. Data structures should use partial copying [1]. 3. Data structures should be lazy by default. 4. When you've profiled a bottleneck, and identified memory allocation and copying as the bottleneck culprit, then use a mutable data structure internal to the operation (copy your lazy immutable data structure once into the mutable one, taking only the portion of your lazy structure that you need, operate on the mutable structure in place, then return an immutable, lazy copy of that structure out of your operation.
This will isolate the need for mutation reasoning to performance critical parts of your application/library, and let you still reason about the rest of your program with referential integrity, while avoiding performance bottlenecks.
Never use a mutable structure in a mutable reference at the same time. You don't need both.
1. https://www.google.com/url?sa=t&source=web&rct=j&url=https:/...
// Definitely doesn't modify 'array', because 'array' is copied.
vector<int> Normalize(vector<int> array);
// Almost certainly doesn't modify 'array'.
vector<int> Normalize(const vector<int>& array);
// Probably *does* modify 'array' (otherwise author would have used
// const reference instead of pointer.)
void Normalize(vector<int>* array);
In Python every function uses the 3rd approach, so you have to signal to the user in some other way whether the argument will be modified or not. In Ruby and some other languages the convention is to append "!" to the function name, but in Python most people seem to just mention it in the docstring.However, I don’t know if there is a good reason why the mutating versions are methods and the non-mutating ones are standalone functions.
In addition, I just finished reading the Rust book last night, and I don't believe this is what interior mutability is (at least as used in Rust).
1. Validate emails with recaptcha (which is why you see that thing in the corner.) 2. no email validation at all, so random people get spammed with confirma-you-want-to-subscribe emails.
I am not super-happy with having to make this choice :(
btw, I think it's a design flaw in python that `a += b` is not always equivalent to `a = a + b`.
edit: I might have misunderstood your question. If you meant "why it's not equivalent" please see the falkaer's answer.
Yes. For example for std::string `a = a + b;` will (at least notionally) create a temporary string and then use the assignment operator. `a += b;` will not do this.
>>> a = ([0],) # a tuple (immutable) containing a list (mutable)
>>> a[0] = a[0] + [1]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
>>> a
([0],)
>>> a[0] += [1]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
>>> a
([0, 1],)
Though to be fair, technically nothing prevents "+" from mutating either operator (it'd just get you tarred and feathered).EDIT: Tried it myself. Sure enough, it throws the exception, but still adds the item. This seems like a bug.
EDIT Part 2: I understand now. `a[0] += [1]` is internally translated to `a[0] = a[0].__iadd__([1])`. The right-hand side is interpreted, mutating a[0] in-place as expected. But then the re-assignment happens, which throws the exception since you're trying to re-assign a value in a tuple, which is immutable.
If you are using a value type, `__iadd__`[1] can return a new value. So:
x = 5
y = x
x += 3
y is not x and y == 5
But if you're using a reference type, it is allowed to mutate the reference and return the original reference. x = [1, 2]
y = x
x += [3, 4]
y is x and y == [1, 2, 3, 4]
[1]: https://docs.python.org/3/reference/datamodel.html#object.__...I like passing in pre-allocated containers to house the results of my computation. That way I have not only predictable memory consumption, but I can also avoid costly allocation (and de-allocation) in tight loops. Having worked with Go a bit, there were times when I used this (like passing in byte slices to readers), but oftentimes you couldn't just tell a function not to allocate (which it did for safety), because you and only you knew it'd be safe to modify the structure in place.
The bottom line is that people should not be "always copy/always use pure functions" or "always use mutable structures, because performance!!!", but be aware of what the upsides and downsides of each are.
def normalize(data, inplace=False):
if not inplace:
data = data.copy()
...
or: def normalize(data, out=None):
if out is None:
out = data.copy()
...
Please, do follow this pattern for any libraries you release, having value-semantics-by-default to prevent shooting yourself in the foot + opt-in in-place mutation option for better memory performance is the right thing, and it's quite easy too with Python, Numpy and Pandas!So you have the functions 'filter' and 'filter!', 'sort' and 'sort!' etc.
List[T] is one of the classic immutable data structures, and a List[T] cannot have its contents modified. But within the prependAll method, it can create a mutable List factory (ListBuffer) to do its work building the new list that it will return.
So, if you follow this (contrived) example in your IDE, calling this:
val f = List(4, 5, 6).prependedAll[Int](Array(1, 2, 3))
will take you inside StrictOptimizedSeqOps, which does this: override def prependedAll[B >: A](prefix: IterableOnce[B]): CC[B] = {
val b = iterableFactory.newBuilder[B]
b ++= prefix
b ++= this
b.result()
}
The mutable data structure doesn't escape the function, so to the caller it remains referentially transparent. A = normalized(X*b - M*N*k); //creates a pipeline
A.calculate(); //calculates all the stages destructively on the new copyFor eg:
chain(my_large_array).op1().op2().op3().result()
Using this, the chain() start could copy the input data once, but thereafter do a series of in place mutations in op1, op2 and op3 to improve performance.
Do numpy APIs provide such facilities?
For optimizing complex access patterns you'd need a language like Halide.
The idea of immutable data structure is just like strings in Java or other modern languages: it seems like a reference type but works like a value/primitive type. It maintains a constant pool that all "foo"s in the same virtual machine points to the same reference. For immutable data structures, they work in the same way.
On top of that, there's more underlying sharing mechanism. For List(1,2,3), it's actually might sharing the same List(1,2) with List(1,2,4) to minimize the waste.
Ideally you'd have a compiler that could perform optimizing magic to detect when the copy isn't needed, but that's not going to happen in CPython.
In this case, you could create the function normalized() for the 80% case, and normalize_in_place() for users who need to optimize this path (choosing to take on the extra responsibilities that come with it).
Also it compiles to Python nicely.
Of course, maybe the author just chose to limit this blog post to this one solution, but it wouldn't have hurt to at least mention some further optimizations :)
¹ And some O(1) data like the min and max values of the Array
https://en.wikipedia.org/wiki/Copy-on-write
So basically every buffer can be made immutable and then only copied when written to, using something like refcounts and diff trees to internally track mutations from an immutable parent buffer. Languages like Clojure due this to manage state. I believe the Redux also does this internally, although I've only studied it and haven't had a chance to use it professionally.
In practice, this looks like a language where everything is value-based instead of reference-based.
So like, in C# where you pass objects by reference, a COW language passes everything by const value. Then an imperative language statically analyzes the code to detect mutation (my own theory is that this can never be accomplished with 100% certainty, at least not with a simple implementation). So basically buffers act like Git instead of byte arrays, creating a new snapshot with every mutation.
Or functional languages can disallow mutation altogether, or reduce the code into its most minimal representation and handle mutation at special breaks in execution (like monads). I'm grossly oversimplifying all of this and probably using the wrong terminology, but am more than 50% confident that I can derive what I'm talking about, so will just go with it hah.
I think that the complex imperative implementation I'm talking about is probably Rust. The simple functional implementation would look like a pure immutable Lisp running within the Actor model, handling state only through IO, and dropping monads altogether. The catch being that complex functional code might have so many breaks in execution that it would practically be happening on every single line and begin to look like an imperative language with all its flaws. This is the primary reason why I shy away from impure functional languages like Haskell, because I don't think they have solved this core issue of mutation clearly enough (not to mention, I think functional language syntax is generally write-only and not readable by others or yourself in 6 months hahah). As far as I'm concerned, this is still an open problem.
In another life, I want to make an imperative language that's primarily immutable, that uses higher order functions (or better yet, statically analyzes code for side effects and converts foreach loops into higher order functions automagically), and transpiles to a purely immutable Lisp. It would be associative array-based and look like Javascript. The idea being that we could write code in the human-readable imperative style and let the computer distill it down to its pure functional equivalent.
This is rather tangential, and sorry to hijack your message to get on my soapbox, but I and many others who program in Haskell and a dynamic language (Python in my case) find that Haskell code we've written is far easier to come back to than code we've written in the dynamic language.
Here we are, folks pulling their hairs for decades wrt how to most efficiently trans/compile higher-order FP idioms to minimal-overhead imperative --- and you want to go the other way around!
Kidding aside, the "functional sub-language" of cell-lang.net has a feature like this, functions are pure but you get the imperative sugars (loops, branches) while preserving purity guarantees like referential transparency.
https://redux.js.org/recipes/structuring-reducers/immutable-...
<shameless self promotion> I'm working on a language for this. Right now it uses LLVM to compile to native and uses HAMT (see Clojure) to store data. It looks similar to Javascript. https://github.com/Floydlang/floyd
There are no references in Floyd, only values. This opens possibilities of great performance without complex/risk analysis of ptrs and aliasing.
The next phase is to create an intermediate AST (equivalent to your idea about compile-to-LISP) that lets programmer/tool tweak how the data structures map to the HW.
I'd love to pick your brain about these ideas!