Closest thing I could think of is !ismutable(arg) || throw(error("arg must be immutable."))
. Not sure if it’ll help your example, though, that looks like restricting variables again.
This runs into the problem that, e.g., Set
is immutable, while String
is mutable, even though the observed behaviours are the opposite.
I correct it.
It’s possible to protect the fields of a mutable struct via similar technique. Here’s a prototype.
import Base: propertynames, getproperty, setproperty!
struct ReadOnlyStruct{T}
parent::T
end
const ROS = ReadOnlyStruct
propertynames(ros::ROS{T}) where T = propertynames(getfield(ros, :parent))
getproperty(ros::ROS{T}, name::Symbol) where T = getproperty(getfield(ros, :parent), name)
setproperty!(ros::ROS, name::Symbol, x) = error("Cannot set property of ReadOnlyStruct")
mutable struct Foo
x::Int
end
f!(a::Foo, b::ROS{Foo}) = a.x = 3
g!(a::Foo, b::ROS{Foo}) = b.x = 3 # violates read only contract
Demonstration:
julia> foo = Foo(5)
Foo(5)
julia> bar = Foo(6)
Foo(6)
julia> foo.x = 3
3
julia> foo
Foo(3)
julia> f!(foo, bar |> ROS)
3
julia> g!(foo, bar |> ROS)
ERROR: Cannot set property of ReadOnlyStruct
Stacktrace:
[1] error(s::String)
@ Base ./error.jl:33
[2] setproperty!(ros::ReadOnlyStruct{Foo}, name::Symbol, x::Int64)
@ Main ./REPL[9]:1
[3] g!(a::Foo, b::ReadOnlyStruct{Foo})
@ Main ./REPL[23]:1
julia> propertynames(foo)
(:x,)
julia> propertynames(foo_ro)
(:x,)
julia> hasproperty(foo, :x)
true
julia> hasproperty(foo_ro, :x)
true
For convenience only, one could then also define
f!(a::Foo, b::Foo) = f!(a, ROS(b))
g!(a::Foo, b::Foo) = g!(a, ROS(b))
julia> f!(foo, bar)
3
julia> g!(foo, bar)
ERROR: Cannot set property of ReadOnlyStruct
...
Someone could probably combine this into the @immutable
macro.
An important concept here is that
- assigning to a property (
a_struct.property = value
) is just a call tosetproperty!(a_struct, :property, value)
- assigning to an index (
array[idx] = value
) is just a call tosetindex!(array, idx, value)
julia> @code_lowered f!(foo, bar|> ROS)
CodeInfo(
1 ─ Base.setproperty!(a, :x, 3)
└── return 3
)
julia> @code_lowered g!(foo, bar|> ROS)
CodeInfo(
1 ─ Base.setproperty!(b, :x, 3)
└── return 3
)
julia> h!(v) = v[1] = 5
h! (generic function with 1 method)
julia> @code_lowered h!(1:3)
CodeInfo(
1 ─ Base.setindex!(v, 5, 1)
└── return 5
)
Making the array or struct virtually “immutable” is thus just a matter of overriding the “set” methods for the wrapper type. Essentially what we are doing here is leveraging the power of multiple dispatch to implement a form of immutable arguments. This is wonderful since this avoids having to add additional keywords to Julia.
Earlier you suggested that the macro, @immutable
should be part of Base
Julia or the standard library. I disagree since this functionality can exist as an outside package. Julia has a minimalist design philosophy to its Base
and standard libraries. If functionality can exist outside the standard libraries, then it should. A free standing package is much easier to iterate and evolve than if it were a standard library. There are many examples of packages that were once part of Base
or a standard library which have since be spun out for this reason.
While more extensive language changes may not be possible now for Julia 1.x, it’s possible that they could be part of Julia 2.x or another major version iteration. Still the preference is to prototype new functionality outside in a package first to establish what needs to be done and how it may work.
Wow tyvm for your time!
As was said before a couple of times:
Not the many ways of using a function is the problem, the lack of an enforced way of describing the side effects a function can have looking at its definition is.
Nothing in Julia can stop a determined user from thwarting one’s attempts to simulate an object as immutable:
julia> instanceFoo = Foo(0)
Foo(0)
julia> immutableFoo = ReadOnlyStruct(instanceFoo)
ReadOnlyStruct{Foo}(Foo(0))
julia> getfield(immutableFoo,:parent).x = 1
1
julia> immutableFoo
ReadOnlyStruct{Foo}(Foo(1))
julia> instanceFoo
Foo(1)
It just isn’t supported by the language and its semantics. I have grossly violated the ReadOnlyStruct
’s internals here, but the point stands. Personally, I consider the ability to do this a feature, although one that needs to be exercised with extraordinary caution and only in very rare cases.
But it seems that the original poster’s needs and wants in this regard are not supported by Julia, nor will they be in the near or medium future, and they will perhaps be better-served by a different language.
BTW, also in C++ the const
does not guarantee that r
will remain unchanged. Change
to
void f(const vector<double> &r) {
const double *p = &r[0];
const_cast<double*>(p)[0] = 2.0;
}
which will happily compile a function modifying the first element of r
.
Generally immutable
, i.e. impossible to change which is partially supported by Julia, but not for arrays, and const
are different, as for example discussed here
I recommend taking a look at the effect analysis work that Jeff outlined in his State of Julia 2022 talk at the 10 minute mark. This is mostly being spearheaded by @aviatesk as far as I understand.
See https://github.com/JuliaLang/julia/pull/46198 and related pull requests for more details. Besides the Base.@assume_effects
macro there is also the Base.@pure
macro that may be of interest.
But there is no way to automatically delegate dispatch to the wrapped type, so bar(::ROS{Foo})
won’t work, limiting the usefulness of this approach.
If we could automatically delegate all calls to the wrapped type, though…
The designer of a library/function is not in charge to fool people but to help and inform.
This post is not about how we can hack in C++ is about how to inform about intended behaviour and side effects in a function declaration in Julia. That was the scope of the example.
ReadOnlyStruct
isn’t as substitutive as ReadOnlyArray
, though. ReadOnlyArray
is an AbstractArray
that implements the entire AbstractArray
interface minus the mutating setindex!
by forwarding to the parent AbstractArray
. Methods building on the AbstractArray
interface that work on the parent will immediately work on the ReadOnlyArray
. There isn’t any one interface for mutable struct
s that ReadOnlyStruct
can utilize like that e.g. you can’t immediately getindex
a ReadOnlyStruct{Dict}
. Besides, a immutable struct
can be used to implement a mutable type that doesn’t reassign its fields e.g Set
, OffsetArray
.
I think you can say the same thing about Julia it just relies on documentation to tell the user of functions what the function does
Indeed, that’s the reason I started this thread, because the language doesn’t encourage/helps/forces this desired behaviour. This should not be coding guideliness / good practices should be forced and checked by the compiler.
I think what we’ve established is that there are very few languages that actually offer any real hard guarantees about mutability (because most languages offer some “unsafe” way to cast things into other things etc) so in essence these const adornations and similar are largely documentary in nature with a little bit of additional help for the developer of the function to remind them when they’ve done something they shouldn’t have done such as assign to a variable relatively innocently just forgetting that they were not supposed to.
So the real question is what exactly do you want to get from Julia from these proposed ideas? You want it to throw errors that the developer of a function be reminded that they’ve done something wrong? If so we’ve got macros in this thread which helped with that.
If you want to somehow communicate to the user of the function Julia’s preferred method has been to use documentation strings. Those documentation strings are just something that languages like c++ simply don’t have because c++ has no interactive repl so there’s no clear way to query the “runtime” for what a function does. There are some IDE type aids but Julia has those as well.
In Julia if the function ends in exclamation point this is a big sign that something may be changed. To find out what you simply type ? functionname
and you get it’s documentation which should tell you what things can change and what things don’t so for the user I don’t see that we have any issue except that people could write bad documentation, but people could write bad documentation in any language, and people could fail to adorn consts and etc.
Yes we had. I think realistically only Sing# a dialect of C# aspires to do this. But it doesn’t matter that we don’t have to defend that some malicious library implementer will cast something in implementation. We should care that expected behaviour is achieved. For example, in my case, I cast away constantness in some cached objects to load cached data from the disk. This doesn’t mean that I’m cheating casting but that I’m accelerating a calculation that was already done. that cast is done to set some flags in the wrapper about caching not the main expected behaviour. Are many examples why sometimes, is desired to cast away constatness that are not interested to discuss here because all this line of discussion about trickery, malicious behaviour, strength of languages are deeply out of my intended scope.
Yes, but what do you want Julia to do? We have established that it’s possible to create code which aids the implementer in avoiding accidental mutation, and we have established that the normal method for communicating mutation behavior is documentation strings and a ! flag to let the user know there is expected mutation of some sort.
What else do you actually want Julia to do? I don’t really understand.
That’s OK if you don’t understand. Read the initial post again.
Fine, going back to the first post:
So we’ve established that there is a way to accomplish this… someone wrote a macro to do it elsewhere in this thread.
And we’ve established that the Julian way to do this is to provide a docstring that specifies the behavior of the function and what it mutates.
If you don’t like these things, can you say exactly what it is you’d like julia to do instead?
For example, I could imagine you wanting an error right after typing something like this:
function foo(a,b)
a[1] = 1
b[2] = 2
end
Because there’s no ! in the function name…
or right after something like this:
function foo!(a,const b)
a[1] = 1
b[2] = 2
end
because b was declared “const”
or something like that?
That’s certainly a valid opinion, one that is implemented in some languages. But I think people have laid out some big reasons why it’s not in Julia, which is your original question. Let me try to summarize the ones I’ve run across, I’ll rename your desired const
as Cpp_const
so nobody confuses it with Julia’s current const
:
-
Like Python and very unlike C/C++, a Julia variable is not tied to an instance; an instance can be assigned to many variables, and a variable can change its instance (even the type) through reassignment. Is an instance allowed to be mutated if it assigned to a variable declared
Cpp_const
and another variable that isn’t? What if it is cyclically unassigned then reassigned to theCpp_const
variable, does it make sense for it to mutate then? -
To enforce no attempts at mutation, one approach is to prove all used methods are non-mutating. However, Julia is not a statically typed, AOT compiled language. Arguments in methods often aren’t annotated with concrete types. So the compiler doesn’t have the necessary type information to even know what methods are used until a function is called at runtime with live input instances. This system gives us a lot of flexibility in composing code while only compiling what we need, but the method check happens way too late to smoothly enforce nonmutation.
-
Another approach is to somehow freeze the instance assigned to the
Cpp_const
ed variable. So what if it has a mutable type, just tell the compiler to not change any of its bits! That’s simple enough, right? Wrong, a Julia type often don’t “own” all of its data bits. An immutable struct with a mutable field doesn’t actually contain that mutable instance, just a pointer to it. Two different immutable struct instances can contain the same mutable instance by both having a field with the same pointer value. What happens when one instance isCpp_const
ed and the other is being mutated? Is that mutable field’s instance frozen for both? It’s not so different from the separation of pointers and their values in C++; here’s a read about how theirconst
-ness are separate. -
Given how Julia treats its variables (see point 1) and how Julia doesn’t have pointers and references everywhere, Julia has a way to control mutability and immutability: as a property of a type. It’s usually dictated by the type definition
struct
/primitive
vsmutable struct
, but there were many exceptions pointed out. The only foolproof way is the documented description and methods of the type; for example, aSet
is astruct
, but it has apush!
method that is unambiguous mutation. Some mutable types will have immutable counterparts e.g.Dict
andImmutableDict
. It’s possible to make wrapper types to implement immutable counterparts in little code, but there’s limitations. TheReadOnlyArray
is a pretty good way to wrapAbstractArray
s into an immutable substitute, but it won’t work for everything e.g. methods specialized to other wrappers likeOffsetArray
won’t work onReadOnlyArray
.
There is not yet a way this is what we established. For neither problem.
What macro we have that works?
What is mutable here: g!(a, b, c) ?
How this should be writen using that macro to assure a user that a and c are changed, for example, and how the compiler forces a implementer to do so? I still don’t understand how a mutable type (one that has mutable fields) will behave using that macro.
And we’ve established that the Julian way to do this is to provide a docstring that specifies the behavior of the function and what it mutates.
When the “Julian way” is not the strong way relying and not the language and compiler to do this but conventions and comments. I dont feel this is good enough solution by a large margin.