Empty vs. value types, and vectorisation


#1

I’m quite new to Julia and am currently starting my first “real” project. One of the “innermost” things I have to implement are properties of various liquids, for example the density, as a function of temperature. I’m not sure which is the correct way to implement this in Julia, so thanks for your advice.

The liquids share no common data, so I went with “empty” types:

abstract type Fluid end
struct Water <: Fluid end

Then, define the property functions, e.g.:

density(::Water, th) = 1000.0 - th/100.0 # not physically correct ;-)

Evaluating the properties then is like:

myfluid = Water()
density(myfluid, 273.0)
density.(myfluid, [273.0, 300.0])

But the second call (density.) throws the MethodError that there’s no method for length(::Water).

My questions:

  1. Would using a value type be more appropriate here? E.g.:
struct Fluid{name} end
Fluid(name) = Fluid{name}()

density(::Fluid{:Water}, th) = ...

myfluid = Fluid(:Water)
...
  1. What’s the preferred way of enabling “vectorisation” / broadcasting in such a case? I could define the property functions in a vectorised form, e.g.
    density(::Water, th) = 1000.0 .- th./100.0. This would allow to use them with arrays for th as well, but with the same “not-obviously-broadcasted” function name without trailing dot. Can I somehow tell Julia that ::Water is always scalar? I don’t want to implement all required AbstractArray methods accordingly.

  2. Is there a better way in general to implement this all in Julia?


#2

Your first question should be: Do specific fluids really want to be a type? That is, do you know what fluid you are dealing with at compile time? If you have heterogenous datastructures (mixing e.g. Fluid{:Water} and Fluid{:Oil}), then this won’t perform well.

Regarding the broadcasting, this is a case where closures/anonymous functions could be appropriate:

function foo!(densities, temperatures, fluid)
densities .= (th-> density(fluid, th)).(temperatures)
nothing
end

This snipped is agnostic about whether kinds of fluids are a type or an integer / UInt8 / enum used for lookups in global tables (and I’d guess you can expect to do some refactoring along these lines in the future).


#3

Yes, I do know at compile time what the fluid is. That’s why I wanted to avoid an enum and case-switching at runtime. One detail: the methods for the properties of different fluids are not the same, so I cannot just lookup coefficients.

Thanks for your proposed solution for broadcasting (and doing in-place evaluation at the same time). One can surely do it this way, but overloading the out-of-place but broadcasted density. using this approach is not possible.


#4

The parametric type would be preferable, in my opinion. The reason is, you probably are only dealing with a small number of fluids, so you want this information to be included at compile time.


#5

Yes, definitely! The full interface is documented here: https://docs.julialang.org/en/v1/manual/interfaces/index.html#man-interfaces-broadcasting-1 but I believe all you actually need to do is define: Base.broadcastable(f::Fluid) = Ref(f)

Using a Ref is also the recommended way to force a particular argument (regardless of its type) to behave as a scalar in a broadcasting expression. For example, foo(bar, Ref(baz)) will broadcast over the elements of bar but treat baz as a scalar (even if baz happens to be, for example, an array)


#6

Check out a previous answer of mine on this topic:

Both your approaches (Fluid{:Water} and Water <: Fluid) encode Water into the type-system — and I’m not sure there’s a big semantic difference between the two. I think I’d prefer the abstract/struct one if that’s going to be the way you go… but you may want to consider if you actually need this encoded in the type system in the first place.


#7

Thanks, this actually seems to be the right way to enable broadcasting in this case (since I know that the fluid type is not used in any calculation but solely for dispatching the correct method).

As I mentioned above, in this case, the fluid is known at compile time (it is set as a “global property” of an overall model). I see your point for run-time determined properties; I wouldn’t encode this into types neither.

How would you suggest to do it? Using an enum and case switching in each function? That makes the code a bit less modular: for adding new fluids, one has to add another case in each function, and also to some docstring or some tuple that ca show the user all available fluids. Also, wouldn’t Julia then compile all possible branches at once, whereas with type-specific methods, only the relevant one gets compiled?