Julia 1.11: Top Features and Important Updates

Steven WhitakerSteven Whitaker
9 min read

A new version of the Julia programming language was just released! Version 1.11 is now the latest stable version of Julia.

This release is a minor release, meaning it includes language enhancements and bug fixes but should also be fully compatible with code written in previous Julia versions (from version 1.0 and onward).

In this post, we will check out some of the features and improvements introduced in this newest Julia version. Read the full post, or click on the links below to jump to the features that interest you.

If you are new to Julia (or just need a refresher), feel free to check out our Julia tutorial series, beginning with how to install Julia and VS Code.

Improved Clarity of Public API with public Keyword

An important aspect of semantic versioning (aka SemVer, which Julia and Julia packages follow) is the public API users have access to. In essence, SemVer states that minor package updates should not break compatibility with existing code that uses the public API. However, other parts of the code are free to change in a minor update. To illustrate:

  • If I have sum([1, 2, 3]) in my code that I wrote in Julia 1.10, it will continue to return 6 in Julia 1.11 because sum is part of Julia's public API. But it could break in Julia 2.0. (Hopefully not, though!)
  • If I have SomePackage._internal_function(0) in my code that I wrote with SomePackage v1.2.0, it might error when SomePackage upgrades to v1.2.1 (because, for example, _internal_function got deleted). Such a change would be allowed because _internal_function is not part of the public API.

So, the question is, how does a user know what the public API of a package is? Historically, there have been some conventions followed:

  • Names that are exported (e.g., export DataFrame) are part of the public API.
  • Unexported names that are prefixed with an underscore (e.g., SomePackage._internal_function) are not part of the public API.

But what about a function like ForwardDiff.gradient? That function is the reason why 99% of users load the ForwardDiff package, but it's not exported! The good news is that it's still part of the public API because, well, ForwardDiff's maintainers say so. Or maybe it's because the documentation says so. Or maybe it's because enough people use it? Sometimes it's not entirely clear.

But now in Julia 1.11, your the code can indicate the public API! This is thanks to a new public keyword. Now, all symbols that are documented with public are part of the public API. (Note that this is in addition to exported symbols, i.e., func would be considered public API with either export func or public func.)

Usage of public is the same as export. For example:

module MyPackage

public add1

"""
Docstring for a public function.
"""
add1(x) = x + 1

"""
Docstring for a private function.
"""
private_add1(x) = x + 1

end

With using MyPackage, no symbols are made available (except MyPackage), e.g., add1 can only be called via MyPackage.add1, because nothing is exported with export. And both MyPackage.add1(1) and MyPackage.private_add1(1) work, even though add1 is public and private_add1 is private. So, the public keyword doesn't change how MyPackage works or is used.

However, the public keyword does change some behaviors. The most notable difference is when displaying documentation in the REPL's help mode:

help?> MyPackage.add1
  Docstring for a public function.

help?> MyPackage.private_add1
  │ Warning
  │
  │  The following bindings may be internal; they may change or be removed in future versions:
  │
  │    •  MyPackage.private_add1

  Docstring for a private function.

See the warning with private_add1? Such warnings, in addition to the more straightforward documentation of the public API, may help reduce usage of package internals (particularly accidental usage), which in turn may help improve the stability of Julia packages.

To summarize, even though the public keyword doesn't change how a package works or is used, it does provide a mechanism for clearly stating the public API and providing warnings when viewing the documentation for internal functions. This, in turn, may improve the stability of Julia packages as they adhere more closely to public APIs.

Read more about public in the Julia manual.

Standardized Entry Point for Scripts

There is now a standardized entry point when running scripts from the command line.

Julia 1.11 introduces the @main macro. This macro, when placed in a script, and when the script is run via the command line, tells Julia to run a function called main after running the code in the script. main will be passed a Vector of Strings containing the command line arguments passed to the script.

To use @main, just include it in your script on its own line after defining your main function. (If @main occurs before defining main, for example at the top of the script, an error will be thrown, so the ordering matters.)

Of course, for this to work there has to be a function main with a method that takes a Vector{String} as the only input.

Let's look at an example to illustrate how this works. Say we have the following in test.jl:

print_val(x) = print(x)

function main(args)
    print_val(args)
end
@main

If we run this file in the REPL with include("test.jl"), the functions print_val and main will be defined, but main will not get called. This is the same behavior as when @main is not present.

On the other hand, if we run this file via the command line with julia test.jl, the functions print_val and main will be defined and then main will be called with the command line arguments as the input. To illustrate:

  • julia test.jl will call main(String[]) (because no command line arguments were passed).
  • julia test.jl 1 hello will call main(["1", "hello"]).

As a result of @main, a Julia file can have different behavior depending on whether it is run as a script or not.

If you're familiar with Python, @main might remind you of if __name__ == "__main__". However, there is one significant difference:

  • In Python, if script1.py imports script2.py and script2.py has the "if main" check, running script1.py as a script will not run script2.py's "if main" code.
  • In Julia, if script1.jl includes script2.jl and script2.jl uses @main, running script1.jl as a script will run script2.jl's main function. (Technicality: Unless script1.jl defines an appropriate main method, in which case script1.jl's main would be called, even if script1.jl did not include @main.)

This isn't to say Julia's @main is bad or wrong; it's just important to know that it works differently than Python. And it's still cool to have a standardized entry point for Julia scripts now!

Read more about @main in the Julia manual.

Improved String Styling

Julia 1.11 introduces a new StyledStrings.jl standard library package. This package provides a convenient way to add styling to strings. StyledStrings makes printing styled strings much easier than calling printstyled, particularly when different parts of the string have different styles.

The easiest way to create a styled string is with styled"...". For example:

using StyledStrings
styled_string = styled"{italic:This} is a {bold,bright_cyan:styled string}!"

Then, when printing the styled string, it will display according to the provided annotations.

Also, because the style information is stored with the string, it can easily be preserved across string manipulations such as string concatenation or grabbing a substring.

Check out the documentation for more information about the variety of different annotations StyledStrings supports.

And here's some more information from the State of Julia talk at JuliaCon 2024:

Slide about StyledStrings

New Functions for Testing Existence of Documentation

Julia 1.11 makes it easy to determine programmatically whether a function has a docstring. This can be useful for, e.g., CI checks to ensure a package is well documented.

There are two functions for this purpose. The first is Docs.hasdoc, which is used to query a particular function. hasdoc takes two inputs: the module to look in and the name (as a Symbol) of the function. For example:

julia> Docs.hasdoc(Base, :sum)
true

The other function provided is Docs.undocumented_names, which returns a list of a module's public names that have no docstrings. (Note that public names include symbols exported via export as well as symbols declared as public via public.) For example:

julia> module Example

       export f1, f4
       public f2, f5

       "Exported, documented"
       f1() = 1

       "Public, documented"
       f2() = 2

       "Internal, documented"
       f3() = 3

       # Exported, undocumented
       f4() = 4

       # Public, undocumented
       f5() = 5

       # Internal, undocumented
       f6() = 6

       end
Main.Example

# Note that `f6` is not returned because it is neither exported nor public.
julia> Docs.undocumented_names(Example)
3-element Vector{Symbol}:
 :Example
 :f4
 :f5

It will be interesting to see what tooling arises to take advantage of these functions.

More Complete timed Macro

The @timed macro provides more complete timing information in Julia 1.11.

Previously, @timed gave run time and allocation/garbage collection information, but nothing about compilation time. Now, compilation time is included.

But why care about @timed when @time already gave all that info? Because @time is hard-coded to print to stdout, meaning there's no way to capture the information, e.g., for logging purposes.

I actually had a project where I wanted to redirect the output of @time to a log file. I couldn't just use redirect_stdio because that would also redirect the output of the code being timed. I ended up using @timed along with Base.time_print to create the log statements, but I was disappointed @timed didn't give me compilation time information. Well, now it does!

New Convenience Function logrange

Pop quiz: Which of the following is the correct way to create a logarithmically spaced range of numbers?

  1. log.(range(exp(a), exp(b), N))
  2. exp.(range(log(a), log(b), N))

I have occasionally needed to use logarithmically spaced ranges of numbers, not so frequently that I memorized which expression to use, but frequently enough that I developed a real distaste for the mental gymnastics I had to go through every time just to remember where to put the exps and logs. Maybe I should have just taken some time to memorize the answer...

But now it doesn't matter! The correct answer is neither 1 nor 2, but logrange(a, b, N)! Here's an example usage:

julia> logrange(1, 10, 5)
5-element Base.LogRange{Float64, Base.TwicePrecision{Float64}}:
 1.0, 1.77828, 3.16228, 5.62341, 10.0

I know it's a fairly minor change, but the addition of logrange in Julia 1.11 is probably the change I'm most excited about. There was much rejoicing when I saw the news!

Summary

In this post, we learned about some of the new features and improvements introduced in Julia 1.11. Curious readers can check out the release notes for the full list of changes.

Note also that with the new release, Julia 1.10 will now become the LTS (long-term support) version, replacing Julia 1.6. As a result, Julia 1.10 will receive maintenance updates instead of Julia 1.6 (which has now reached end of support) until the next LTS version is announced. If you want to learn more about what changes Julia 1.10 brought, check out our post!

What are you most excited about in Julia 1.11? Let us know in the comments below!

Additional Links

0
Subscribe to my newsletter

Read articles from Steven Whitaker directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Steven Whitaker
Steven Whitaker