Specific function type

Hello, I am coming from js/ts background. Can someone explain how this code would look like in Julia? Thanks :slight_smile:

function sayHello(f: (s: string) => string): string {
  return f("Hello,");
}
sayHello((name: string) => name + "Julia!");

I don’t think you can generally get more specific than Function by default, because the output type and implementation of a function can depend on its input values. However, you can use callable structs to essentially append metadata to a function.

Example:

struct FunctionWrapper
  input_types::Vector{DataType}
  output_type::DataType
  f::Function
end

# Callable struct syntax
function (fw::FunctionWrapper)(args...)
  @assert all(isa.(args, fw.input_types)) 
  output = fw.f(args...)
  @assert output isa fw.output_type
  return output
end

foo(x::Int) = x + 1
foo2 = FunctionWrapper([Int], Int, foo)

julia> foo2(5)
6

julia> foo2(5.0)
ERROR: AssertionError: all(isa.(args, fw.input_types))

This is obviously a lot of extra code so you would only do it in a few cases where you really needed that extra metadata.

4 Likes

I think the simpler, most direct translation is

function sayhello(f::Function)
    return f("Hello, ")
end

sayhello(name -> name * "Julia!")

The extra typing here isn’t something I’d think too hard about tbh

6 Likes

thank you, this is very educational! :+1:

To elaborate, this is a fundamental design limitation/feature/tradeoff. A Julia function has many aspects that don’t lend to establishing even a generic return type. First, a Julia function can have multiple methods, and those do not need to return the same type at all:

foo() = 0
foo(x::Int) = 1.0
foo(x) = zero(x)

Second, each method can be compiled to different code called specializations, based on input type. foo(x) is compiled differently for foo(1.0) and foo(1im) for example. We don’t have direct access to specializations on the level of the language; what specializations are made is an implementation detail up to the compiler, though it’s been fairly consistent across all versions and we have reflection methods to check.

Third, each specialization does not need to have a specific return type. Julia is a dynamically typed language, so while we do like predictable types for the compiler to optimize over, that is something we can leave unknown until runtime.

So, functions don’t really have much going on in their type. Typical named functions will have a singleton type, but some functions like closures do not have singleton types; all of them subtype the abstract Function. If you want something that says a function taking String and outputting String, you need the function, the input type String, and an enforced output type no matter what type the function call ends at. The above FunctionWrapper provides that but in field values, so it’s about as distinguishing as Function to the type system.

There is an existing package FunctionWrappers.jl that parameterizes over the input and output types, attempts type conversions instead of just assertions, and accesses specializations for efficiency. That does put input and output types into the type system so you can lump different function calls sharing those types together, but a downside of accessing specializations is that it won’t adapt to language-level edits like the above example can (you’d have to reinstantiate the wrapper manually) and can’t support all features of a Julia method.

2 Likes

I do understand the dynamic nature of Julia, but my concern is
How do you inform a user that my “function” accepts only arguments of certain types, without comments or additional documentation?
In this particular case, sayHello takes not any Function but (s: string) => string
But I guess this is a tradeoff of dynamic languages. js has the same issue :slight_smile:

I can’t say with any certainty if it’s impossible for a dynamically typed language to have a function type that includes input and output types. But it’s definitely not a priority in the mainstream ones, and Julia in particular lacks them by design. In fact, its multiple dispatch completely divorces the function’s type from the types of the positional arguments (keyword arguments aren’t involved in multimethod dispatch, but the compiler does specialize over their types), considering them equally in a function call. That is why you need function wrappers to add that information, with various shortcomings you wouldn’t find in some statically typed languages. The above FunctionWrapper type distinguishes input/output types in its fields, not the type system, so you can’t do something like sayHello(f: (s: string) => string). FunctionWrappers.jl can provide a type like FunctionWrapper{String, Tuple{String}} to serve that purpose. To be clear, neither mentioned FunctionWrappers are functions because they don’t subtype Function, but they are callable.

1 Like

A wrapper callable approach seems to be exactly what you’re asking for, putting the argument types and/or the return type into a type signature. Apparently there’s no package for this purpose currently registered, so wait a few hours until I create it.

1 Like

I suggest finding a very different name from FunctionWrapper even though the concept is somewhat the same. CallSignature?

1 Like

The working name is EnforcedTypeSignatureCallables.

That’s a tad long. Wouldn’t mind writing such a package name once in an import, but not a type constructor, I’d rename that so fast. My suggestions were for the type, specifically, but even CallSignature seems a bit long. I just don’t know if it can be shorter without losing clarity. CallTypes?

1 Like

The package naming guidelines seem pretty clear:

Err on the side of clarity, even if clarity seems long-winded to you.

  • RandomMatrices is a less ambiguous name than RndMat or RMT, even though the latter are shorter.

On the other hand, it seems like I get complaints about the length of my package names for each of my packages. There also seems to be a disconnect between the guidelines and the actual package registration practice, I feel like the names of many registered packages don’t conform to the guidelines. (Which is probably just a matter of no one enforcing the guidelines, even though anyone could do it.)

Personally, I think the guidelines are in the right, as it’s always possible to import with as, or just assign to a shorter const name, if necessary. The MathOptInterface.jl docs, for example, recommend importing the main module as MOI. There’s also this recent open PR: loading: Don't assume a package is loaded by its own name by Keno · Pull Request #55162 · JuliaLang/julia · GitHub

Not sure how to proceed, TBH. On one side there’s the guidelines, which represent best practice as far as I see; on the other side there’s the community, which seems to strongly prefer short names.

EDIT: I guess I’ll keep the package name as is, but rename the type to CallTypes, as you suggest.

2 Likes

v0 gives you the flexibility to rename, could even take suggestions in an announcement thread and make a poll. The more I look at it, the less I like CallTypes because it’s less about a collection of types, more a TypedCallable. But is that even clear enough, all callables are already typed…more people will probably arrive at something nicer.

1 Like

Maybe “TypeDecorator” ?

1 Like

I have another question, if

 function myFunc(f:: Function)
 end

will it accept callable struct ?

Only if that callable struct is declared as subtyping Function.

Only if said callable struct subtypes Function. If not, you need an argument annotated with the callable struct type or a supertype (all types subtype Any so if the definition had no explicit supertype, that’s it). Named types exist in a tree, and we can’t make ad hoc named supertypes that crosslink branches. For example, it could be nice if every iterable had a supertype, but that’s not possible, so the typical method argument for iterables is simply itr (implicitly itr::Any). Union types can cross branches, but it’s not feasible to track down every iterable in advance to put it into a Union type, especially since Julia is interactive and you can define new iterables.

Nice job!
But I feel like noobie was misunderstood :slight_smile:

let me see if I get it right, the usage of this package would be the next

# Define a function with multiple arguments
function multiply(a::Int, b::Int)::Float64
    return a * b * 1.0
end

# Wrap the function with TypedCallable
typed_multiply = TypedCallable{Tuple{Int, Int}, Float64}(multiply)

# Call the wrapped function
result = typed_multiply(3, 4)

Basically, TypedCallable forces the user to pass a function with a certain signature, but there is no use case for that :slight_smile:

Let’s imagine I am writing a code and I create a function, and I know which params it takes and what it might return, I don’t need a sophisticated wrapper to tell me exact same things I already know.

I was referring to a situation where I have a package/library with a function exposed/exported to user, let’s call it sayHallo now what I want to do is to provide a typed hit to a user, what kind of argument it takes.

for example (not real Julia code)

function sayHello(f:: TypedCallable{Tuple{Int}, String})
  println(f(100))
end

now the user of my package doesn’t see what is inside sayHello and it tries to call it with this function argument, (s::String)::Bool -> length(s) > 5

but Julia detects that sayHello does not have such a method for this type of argument and warns the user about that.
Because I am a noob, I am not sure if (f:: TypedCallable{Tuple{Int}, String})
is a valid type declaration for f, but something is telling me it is not :slight_smile:

Now, this whole discussion is a rhetorical exercise, and in dynamic languages, it makes no sense I would not even bother asking it. Still, I saw a sentence somewhere in Julia’s documentation, that types are helping compiler with a performance.
And after learning that Julia actually, possesses such a sophisticated type system with generics, I was surprised there is no more specific type declaration for function, rather than Function and naturally I wanted to ask about that.

Update::
this is a valid type declaration - (f:: TypedCallable{Tuple{Int}, String})
but it does not allow you to pass a simple function, you still need to wrap it with TypedCallable struct which is a bit overkill

2 Likes

You could do this, for example:

function sayHello(f:: TypedCallable{Tuple{Int},String})
    println(f(100))
end

# fallback
function sayHello(f)
    g = TypedCallable{Tuple{Int},String}(f)
    sayHello(g)
end

This way you have both convenience and some of the safety.

julia> sayHello(n -> string(n))
100
2 Likes