Julian Way of Using Keyword Arguments

I am implementing a model for the units of a simple silica glass, and in doing so have come across some interesting questions on how to use keyword arguments. Essentially, there are two notations for how to represent the composition of the glass (the input to my function): x, and j.

In order to represent that, here is what I’ve done for one such unit

x(j) = j / (j + 1)
Q4(; j = 0, x = x(j)) = 0 <= x < 1 / 3 ? 1 - 3 * x : 0

It strikes me as odd that with this particular use case, I must specify a default value for j, even if it’s never used except for when a j-value is specified and x is not. Also, it’s possible, though it would error, to attempt to call this function without either j or x. This hints to me that I’m doing something “wrong,” or at least in a not very Julian/idiomatic fashion.

Additionally, when I went to plot this, I found that dot notation for vectorization doesn’t work with keyword arguments (noted in a few places a few years ago, including here: Julia: Broadcasting Functions with Keyword Arguments - Stack Overflow). Instead, I did something like this:

using Plots
xrange = 0:1/1000:2/3
plot(xrange, map(x -> Q4(x = x), xrange))

It gets a little unwieldy, but not terrible, when plotting something like the fraction of units

total(; j = 0, x = x(j)) = Q4(x = x) + Q3(x = x) + Q2(x = x) + Q1(x = x) + Q0(x = x)
plot(xrange, map(x -> Q4(x = x) / total(x = x), xrange))

Is this the preferred way to handle vectorization of functions with keyword arguments, or is there a better way?

A few thoughts

  1. Consider making the arguments positional arguments rather than keyword arguments. You can use a docstring to tell the reader what the arguments mean. This also means you can change the internal API easier.
  2. Re-factor your code so it only takes one input. It’s not obvious why your Q4 function needs two arguments if x is fully determined by j
  3. Use structs. If you find yourself passing a lot of information around which uses the same parameters over and over again, consider putting everything you need for your model in a struct and passing that around instead.
5 Likes

Thanks! I ended implementing most of thoose suggestions

My type system ended up looking like this:

import Base: promote_rule, -
struct j <: AbstractFloat
    value::Float64
end

struct x <: AbstractFloat
    value::Float64
end

# Conversions
j(x::x) = j(x.value / (1 - x.value))
x(j::j) = x(j.value / (j.value + 1))
x(x::x) = x
Float64(x::x) = x.value
Float64(j::j) = j.value

# Operators
-(j1::j, j2::j) = j(j1.value - j2.value)
-(x1::x, x2::x) = x(x1.value - x2.value)

# Promotion rules
promote_rule(::Type{Int64}, ::Type{j}) = Float64
promote_rule(::Type{Float64}, ::Type{j}) = Float64
promote_rule(::Type{Int64}, ::Type{x}) = Float64
promote_rule(::Type{Float64}, ::Type{x}) = Float64;

I’m not particularly happy with that third line under conversions, but it allowed me to write and plot the model like this

Q4(v) = 0 <= x(v) < 1 / 3 ? 1 - 3 * x(v) : 0
range = x.(0:1/1000:2/3)
plot(range, Q4.(range))

I think I could probably fix that with typing the Q4 function and using multiple dispatch, but it’s already looking much better in my opinion.

This is an interesting solution, but I think it’s overkill for what you are trying to accomplish.

The method I mentioned above was really about using structs as storage, not overloading behavior for computation. Say you have a model with parameters k and z, then it’s useful to make a struct

struct MyModel{T, S} where {T<:Real, S<:Real}
    k::T
    z::S
end

Then when you need to work with your model you have

function calculate(x<:Real, y <:Real)
   x + y # computation on reals
end


function work_with_model(m::MyModel)
    return m.k + calculate(m.k, m.z)
end

Importantly, MyModel is not a number-like thing. It’s just a convenient way to store numbers. It’s really just a NamedTuple where I enforce what fields and types it has. This is a totally valid use of structs.

In your example, you are treating j and x as number-like things, which I would bet is unnecessary. There are certainly reasons to define your own object that behaves like a number, like complex numbers, where you want to define your own rules for how algebra works with complex numbers.

But you aren’t doing anything special with your overloading. + and - work just the same as with Floats, so you might as well operate with the values themselves.

Additionally, I think you are confusing functions with structs. With this line

Q4(v) = 0 <= x(v) < 1 / 3 ? 1 - 3 * x(v) : 0

As far as I can tell, x(v) doesn’t do anything special, right? No calculation is performed on the constructor x(v) and x objects behave just like floats. Why not just use the value of v?

I encourage you to keep playing around with Julia’s type system, but I would not that this isn’t a very idiomatic way of doing things. My general advice is

  1. Use structs for storage of parameters to enforce code consistency and make it easier to type.
  2. If you really need a number-like thing that has it’s own behavior that could not be accomplished just by working with the values themselves, then define all the number-like methods you need and conversion methods. But know that is a lot of work (there are a lot of methods) and adding conversions can make code very confusing to read, as it’s not obvious when the a value is a float and when it isn’t.
2 Likes

x(v) makes sure that the composition is reported using the x notation (if it’s using j, it will convert, if it’s x, it will return the same thing. Additionally, if you wanted to, you could pass in a Float64 and that would work as well (because this would construct an x with the value of v).

Yeah, I learned a good deal about the type system as I was writing this, but yeah, a more idiomatic way to do this would be using structs in a different place, as you suggested.

Would a more idiomatic way of using structs here be something like this?

j_to_x(j) = j / (j + 1)
x_to_j(x) = x / (1 - x)

struct Glass
    Q4::Float64
    Q3::Float64
    Q2::Float64
    Q1::Float64
    Q0::Float64
end

function Glass(c; notation = "x")
    if notation == "j"
        glass = Glass(j_to_x(c))
    else
        Q4 = 0 <= c < 1 / 3 ? 1 - 3 * c : 0
        Q3 = 0 < c <= 1 / 3 ? 2 * c : 1 / 3 < c < 1 / 2 ? 2 - 4 * c : 0
        Q2 = 1 / 3 < c <= 1 / 2 ? 3 * c - 1 : 1 / 2 < c < 3 / 5 ? 3 - 5 * c : 0
        Q1 = 1 / 2 < c <= 3 / 5 ? 4 * c - 2 : 3 / 5 < c < 2 / 3 ? 4 - 6 * c : 0
        Q0 = 3 / 5 < c < 2 / 3 ? 5 * c - 3 : 0
        glass = Glass(Q4, Q3, Q2, Q1, Q0)
    end
    return glass
end   

total(g::Glass) = g.Q4 + g.Q3 + g.Q2 + g.Q1 + g.Q0
BO(g::Glass) = (4 * g.Q4 + 3 * g.Q3 + 2 * g.Q2 + g.Q1) / total(g)
NBO(g::Glass) = 4 - BO(g);

Yeah this seems prettio idiomatic. But I don’t know the details of glass simulation or really have much experience with these types of problems. Hopefully someone else can chime in here.

You might be interest in the @unpack macro from Parameters.jl which will make your life a bit easier.

1 Like