Explicit denotation of variability and immutability

Could you show examples for better understanding this (emphasis are mine)?

“Expressions don’t have types, only values do, and the type that the compiler thinks an expression has is just an implementation detail that generally doesn’t affect the behavior of code. (There are a few corner cases where we allow type inference to leak into visible behavior, but it’s avoided as much as possible.)”

From your words one could think that language behavior is not well defined. I hope that deeper explanation could diminish this suspicion.

1 Like

If you want to read more, you can check out this StackOverflow answer:

4 Likes

Thanks for the detailed reply, @StefanKarpinski.
My proposal has admittedly shortcomings of different sort, but I felt a need for such a discussion when Julia is still in its infancy. So, I hope more critique/support/improvements will appear.

I understand your explanation of dynamic and static types in C++, but here I am mostly interested in the clarity of syntax – not optimisations which would aid the compiler (as @foobar_lv2 might prefer).
Computational code is written and read by humans (not just machines), so clarity, explicity, and being less error-prone should be a goal for the design.

Let’s make an example; consider the following C/C++ snippet:

int foo(const int n, const* const int x, double y)
{
    //... do stuff ...
    return 0;
}

In this function definition, using const modifiers is not just a means for producing optimised machine code; it is an explicit “promise” from the programmer (a human!); in words, it says:

The programmer promises that she will not reassign n or x, and will respect the immutability of x in this part of the code.

Such a syntax will doubtlessly facilitate reasoning about the code for a second programmer who wants to read the code for understanding or debugging it.
Another example from FORTRAN, explicit declaration of pure functions:

pure function square(x)
  real, intent(in) :: x
  real :: square
  square = x * x
end function

In this snippet, it is easy for a compiler to determine the purity of a function, but it adds explicit “promises” from the programmer, which can be verified thoroughly by a compiler.

Such explicitness, imo, reduces a lot of human mistakes in code, while promoting better coding styles (esp., being easy to read and reason about) – hence, I disagree with StefanKarpinski’s statement that “local immutability doesn’t really buy you much”.

I do not see why we shouldn’t have such explicitness in Julia which is meant mainly for modern computational tasks. Julia has already made a decision to make user-defined types immutable by default, and to introduce an explicit keyword (mutable) when mutability is required. As StefanKarpinski mentioned, Julia has also Immutable Arrays – honestly, I do not understand the conceptual difference between a Tuple and an Immutable Array, and how Immutable Array fits into the type hierarchy (regarding StefanKarpinski’s statement, “a value can be mutated or not is a property of its type and that type cannot change…”).

I think, one can systematically generalize the aforementioned schemes to all types, for instance, by a “mutation” of my proposal:

  • Aggregate types – meaning the types which you can add or remove elements – like Arrays, Vectors, Matrices, Dicts, Sets, Graphs, Lists, etc., are mutable by default, but one can “wrap [them] in an immutable wrapper” (as StefanKarpinski mentioned) to use them as immutables in some restricted scope (eg., in the body of a function); e.g.,
x = Array{Int64}(10)   # constant binding to a mutable Array
xm = Immutable(x)      # constant binding to an immutable view of x
  • Non-aggregate types or user-defined types will be immutable by default (as the current standard for struct).

I still believe that one should keep a clear distinction between equality and assignment to markup modifications to the state:
As in part (1) of my proposal, use var and <- (or :=) when dealing with (re-)assignments or modifying mutables, and reserve the equality operator (=) for a constant binding.

The function signature should be also explicit in this regard, as I proposed in my original post.

But who really cares about bindings? You can’t change any data given to you from a caller by changing a binding. This is different to C where you can pass by reference which is not possible in Julia (everything is passed by value).

What is interesting is mutation of data. And for that, in Julia, you would use some immutable data structure or wrapper. It gives the same promise, “you should not really modify this”, but without any guarantees (someone might reach into the wrapper and do bad stuff).

who really cares about bindings?

I think a binding, by =, serves a clear purpose in a computational code; currently, assignment or equality.
It is important to distinguish between the two – so I do care at least! :smiley:
The syntax should be explicit in this respect, I believe. This prevents a lot of human mistakes when you re-writing a mathematical algorithm in code. “You can’t change any data given to you from a caller by changing a binding”, but you might mistakenly change the binding, and hence change the meaning of a “name” which actually represents a mathematical symbol with a certain rôle.

Yet you are right in that “what is interesting is mutation of data”. I don’t see why one should write

function foo(x, y, z)
    #... do a lot of stuff ...
    x[1] = -1
    #... do a lot of stuff ...
end

instead of the explicit way,

function foo(x::Mutable, y, z)
    #... do a lot of stuff ...
    x[1] = -1
    #... do a lot of stuff ...
end

Isn’t reading and reasoning about the explicit version easier for a programmer?

1 Like

But the absence of a ::Mutable (or presence of an ::Immutable) -annotation is only valuable if it is enforced recursively to all calls in the function.

Otherwise you will just have

f(x) = g(x)
g(x::Mutable) = x[1] = 2

f([1,2,3])

which will modify your data even though you called a function that was supposed to have x non-mutable.

How do you plan to enforce this in a dynamic language like Julia?

The way to do this is by changing the object you send in, e.g. instead of sending in [1] you send in ImmutableArray([1]). Objects flow through functions, not variables (bindings) so it is there the const-annotation need to be, i.e. part of the type.

Thanks for providing an example. Now I see.
Yet I deem such a code as bad code, especially for computation.
Reasoning about what it does is not easy. For example, here, one should first learn about the latest Julia’s conventions on function arguments – which might change – and then understand what f would do to an immutable type, or even if the code compiles at all.

I strongly prefer explicitness as I said.

It is similar to the example provided by @Elrod above (see also my reply).
I do not know how to enforce this practically in Julia, yet what I am saying is that such things shall be prevented (or at least warned against) by default. If I promise not to mutate a state in a certain part of the code, I must observe it, and the compiler should be able to warn me if I violate that. Regardless of the programming language or how it would be practically implemented, this is a plausible idea, isn’t it? It provides also more safety – indeed, one could opt for unsafety, if one wishes.

For an interactive session, you might relax some of those strict rules. And remember, eg., that Haskell, a pure functional language, can also be used interactively.

There is a trade-off here: extra annotation would also increase visual/semantic clutter. The more boilerplate, the easier it is to lose track of relevant code. Clarity would suffer because of verbosity.

At the end of the day, all of these trade-offs are subjective and based on experience. Accidentally overwriting a variable in local scope happens, but it is no more insidious than other random typos. IMO the price of this semantic change just to catch this would be too high.

Also, I would like to reiterate that suggesting fundamental design changes “before it’s too late” to a language you have not been using very long is perhaps premature. I am not saying that outsiders don’t have valuable perspectives to offer, but it is very hard to resist the temptation to redesign a language just to make up for features that you are missing from another one and you have not yet learned to use the new one idiomatically. Give Julia some time.

4 Likes

Your stance is quite subjective, I’m afraid. I am not then sure how you’d judge, eg., the following pieces of advice on the official “Julia Performance Tips”:

  • Declare types of keyword arguments
  • Break functions into multiple definitions
  • Write “type-stable” functions
  • Avoid changing the type of a variable

Those doubtlessly lead to more verbose code. Shall we avoid them due to the “clutter”?
Or how do you convince yourself about the default immutability of structs in the current standard?

I am not expecting that Julia be another Haskell. Yet, I believe there is aleady a trend within Julia that can be generalized systematically and allows better computational code.

1 Like

AFAIK some of these are transient workarounds (may be unnecessary as the compiler improves), while the need for type stability is a fundamental consequence of Julia’s design (but even that was relaxed recently). That said, I never said that my own preferences about clutter were “objective” in any sense.

One thing that I would like to emphasize is that I shouldn’t need to. It was a design decision carefully hammered out by people who have been developing and using Julia for years. I have been a Julia user for years now, but I still recognize that many discussions, especially about the compiler, are above my head, and I trust that the core developers are making the right choices. I look at the core language primarily as something to use, then as something to learn a lot from; not primarily as something I should redesign, because I lack the expertise and the perspective.

That said, my understanding is that this particular choice is a nice confluence of good practices (avoid mutation unless you have to), compiler optimizations (given an immutable, it can make assumptions that it otherwise could not). But again, it was not always the default — it was arrived at after a lot of careful discussion that went on for months (see #19157 and the issues linked there), after the language has existed for years.

4 Likes

I agree with you in that default immutability of structs “is a nice confluence of good practices (avoid mutation unless you have to), compiler optimizations (given an immutable, it can make assumptions that it otherwise could not)”, and that “it was arrived at after a lot of careful discussion that went on for months”. So it’s time, imo, to start a serious objective discussion about generalization of that decision; it may take a long while till an agreement is reached, but it is worth it.
It is interesting that you deem “avoid mutation unless you have to” a good practice; this is what I am pushing for in my proposal, but in a general way. :smiley:

And remember, code is not written solely for a compiler to crunch, it has also a human audience – as I have emphasised in my reply to StefanKarpinski and foobar_lv2. Compiler technology is beyond my understanding.

I think that sometimes we, “the laypeople”, have to “nudge” the core developers to make the right choices. Otherwise, history shows how things can go askew.

1 Like

And yet const_cast exists. I would prefer if C++ allowed the compiler to assume const-ness (making modification of consts UB; possibly having a slow debug-mode that compiles in checks for const-ness, e.g. by most brutally mapping a page as read-only (a factor 1000 slowdown for debug is often acceptable)). I have so often been driven crazy by C++ code that declares something as “const” and then goes on to modify private internal state, making the entire thing crash and burn when I try to run it multithreaded. Formulated more brutally: I see no point at all in C++ const; it belongs in doxygen, not syntax. The compiler cannot assume const-ness of consts, and I as a user of a library can neither.

Well, to be honest, ``mutable’’ in julia is syntactic sugar for pass-by-reference. It is just that the type system in julia makes a much stronger distinction between mutable/immutable.

And, as I said, a @const arg would be nice (say, I pass a vector, where I don’t know the size at compile-time; currently there is no way of promising, both to compiler and readers of the source, that I won’t modify the vector). Note that I am not proposing that julia actually enforces this in default-mode; but I am proposing to make modifications UB, and promise that modification of const-annotated things will stay UB for all future (such that I can write a bug report whenever someone modifies private internal state of @const things that prevents multithreading).

Re general discussion by @SepandMeenu

I think one of the most important points to remember is that 1. Julia does not have method types like f: Int->Int; 2. Julia does not support binary compatible types (C++ subclasses; C unions); 3. Julia makes no guarantees that different methods of the same function are related in any way.

(2) and (3) go together: Dispatch must go to the actual type, and Julia must always know the actual type; there is no way of globally saying
getindex_(x::Vector{T}, idx) where {T} = x[idx]::T
because more specific definitions can always violate this property.

As an aside: Unfortunately, the following does currently not work:

reinterpret(NTuple{8,UInt8}, UInt64(4))
ERROR: bitcast: target type not a leaf primitive type

I would very much like a way of getting this to work! (that is: allow reinterpret between arbitrary bitstypes of the same size)

1 Like
  • On C++ const keyword
    Note that I am not defending C++ style – I find that indeed a bad coding practice. Unforunately, the current design of C++ allows many bad coding styles.
    But that does not mean that const is just useless. As I said in my proposal one should not cast a Constant/Immutable to a Variable/Mutable in the corresponding scope; or at least, there should be a clear warning.

  • I did not understand the following (and its relation to my proposal):

Afaik, julia does not attempt to do shortcuts when determining return-types of functions. There is AFAIK no way to express that e.g. a function f: T -> Tuple{T,T}; this might hold for some T and might not hold for others. Hence, type-stability is not a property of your function (as in source-code) but rather a property of the method post specialization on all input types. This severely limits all your attempts at type-stability in your code, (you must rely on all input types behaving sensibly) and makes too verbose annotations moot IMHO.

No, it is not? Passing by reference means that the variable in the function is the same one as the one outside the function. This is not the case in Julia, everything is passed by value, no difference between mutable and immutable types.

Hmm. Maybe I’m confused, but isn’t julia always passing pointer_from_objref for mutables (on the ABI level)? So passing a mutable is syntactic sugar for passing a pointer, just as references in C++ are syntactic sugar for C pointers?

Now I see an interesting discussion between @foobar_lv2 and @kristoffer.carlsson !
Isn’t it better to be explicit about mutation in code syntax, after all, for the sake of mental sanity of later generations :slight_smile: ?

In Julia:

mutable struct M
    v::Int
end

x = M(1)
function f(x::M)
    x = M(2)
    return
end
f(x);
print(x)

will print M(1).

In C++:

#include <stdio.h>

void f(int &x) {
    x = 2;
    return;
}

int main() {
    int x = 1;
    f(x);
    printf("%d", x);
    return 0;
}

will print 2.

C++ can pass by reference, Julia by value (always).

what about this?

mutable struct M
    v::Int
end

x = M(1)

function f(x::M)
    x.v += 1
    return
end

f(x);
print(x)  # prints M(2)

In my proposal, this version of f will produce an error/warning, since x is assumed to be immutable in the function arguments, unless stated explicitly, e.g., via function f(x::mut M) or function(mut x::M).

In your example, x = M(2) is a new binding for a local x inside the function scope, afaiu. It does not tell whether the M-instance is passed by reference or value.