November 2, 2024

Phi: A New Kind of Programming Language

Phi is a new programming language that is safe, powerful, and fun.

In essence, Phi combines the best of Python, Go, V, and Janet, and adds features seen nowhere else -- like macro methods, seamless contracts/type constraints, math that actually works correctly (thanks to Douglas Crockford's DEC64), and a distributed, end-to-end encrypted Actor model.

Here is a quick example:

struct User
  id        int         {:min 1}
  username  str         {:min_len 2}
  birthdate time.moment {:constraints [ $(v.birthdate <= time.now() - time.years(18)) ]}


fn main()
  user := User(:id 1, :username "alan_kay", :birthdate time.moment(:year 1940, :month 5, :day 17)) !
  print("Hello, ${user.username}")

Phi is Safe

Accessing array elements is always safe
fn main()
  arr := []str()

  # Trying to get the first element of an empty array will not blow up.
  #
  # This will not panic or crash, but gracefully handle the error and, since
  # we are in `main`, exit(1); in any other function we would `return err`
  print(arr![0])
Phi does not have unsafe nil or null

In Phi, nil is its own type; there is no such thing as a nil map, array, channel, pointer, or any other type. If we want to indicate the lack of a value, we can create a ?T rather than a T, where T is a type.

For example:

fn find(arr []T, wanted T) ?int
  for i v in arr
    if v == wanted
      return i

  return nil


fn main()
  arr := ["zero" "one" "two" "three" "four"]
  wanted := "four"
  print("four is at position ${find(arr wanted)}")
  print("ten is at position ${find(arr, :wanted "ten")}")

  # Another example:

  hello := ?str("I am essentially a nilable string that can be safely used as a string only after ensuring I'm not nil")

  if hello == nil
    print("nil")
  else
    print("`hello` as a str: $hello")

Phi is Powerful

Macros

By combining macros with flexible but "static" typing, Phi can be very flexible and very safe; see below.

Phi is Fun

Phi provides super high expressivity; it feels like a scripting language while providing type safety
fn main()
  nums := [1 2 3 4 5]

  # Note that `.map(...)` is a macro method
  nums_str := nums.map(v.str()).join(", ")

  print("The sum of $nums_str is ${nums.apply(+)}")
  print("The product of $nums_str is ${nums.apply(*)}")
  print("10 factorial is ${factorial(10)}")
  print("10 factorial is also simply ${range(:min 2, :max 11).apply(*)}")

fn factorial(n int) int
  if n <= 1
    return 1
  return range(:min 2, :max n+1).apply(*)
No cruft!

Every single character that Phi makes you type is necessary while maintaining readability and clarity regarding what exactly is happening (regarding mutations, allocations, etc):

fn main()
  # The `:` in `:=` means we are allocating, and the `=` means we are assigning.
  #
  # No commas are required between values in an array:

  nums := [1 2 3]
  print(nums)
Seamless database integration

All imports and global constants are defined in globals.phi and can be used from any other .phi file in the same directory:

#
# globals.phi
#

postgres_connect := "..." {:priv true}
db := pq.Connect(postgres_connect) !

go fn()
  from time.every(time.minutes(5))
    db.ping() !
      eprint("Error pinging Postgres: $err")


#
# main.phi
#

struct User
  id       int       {:required true, :constraints [ $(v.id >= 0) ]}
  username str       {:required true, :constraints [ $(v.username.len > 0) ]}
  created  time.time {:default time.now}

fn main()
  # `db.MyTable()` acts as if it returns a `![]MyTable`
  recent_user_ids := [user.id for user in db.User!() if user.created >= time.now() - time.hours(5)]

  # Alternate syntax, using `.filter(...)` method
  prev_users := db.User!().filter(v.created < time.now() - time.hours(5))

Mutable and Immutable Versions of Methods

In Python we have to do this:

nums = [5, 4, 3, 2, 1]
ordered = sorted(nums)  # Immutable sort
nums.sort()             # Mutable sort

But in Phi we don't need 2 names for the same concept. Instead we can create 2 different methods with the same name -- one for sorting immutable arrays (and thus returning a value), and one for sorting mutable ones (and thus not).

@nums := [5 4 3 2 1]    # `@` makes `nums` mutable
ordered := nums.sort()  # Immutable sort; no `@` needed here because `nums` is not mutated on this line
@nums.sort()            # Mutable sort; `@` is needed here because `nums` _is_ mutated

How? Here's the implementation:

# Mutable `arr`; notice the `@` prefix telling us that `arr` is mutable
fn (@arr []T) reverse()
  if arr.len == 0
    return

  @temp := T()

  for i _ in range(:max arr.len/2)  # Compiler optimizes to `for @i := 0; i < arr.len/2; @i += 1`
    @temp = arr[i] !
    @arr[i] = arr[arr.len/2 - i - 1] !

    @arr[arr.len/2 - i - 1] = temp


# Immutable `arr`
fn (arr []T) reverse() []T
  @new_arr := []T() {:cap arr.len}

  for i in range(:max arr.len)
    @new_arr += arr[arr.len - i - 1]  # No `arr![...]` necessary; compiler proves OK

  return new_arr

Macros

Phi macros look like normal Phi code:

# `$#`, below, purposely looks kind of like a comment because it doesn't run at
# runtime, just like comments don't run at runtime (or at all)

macro def_strs(vars ...symbol)
  $# for var in vars
    $var := "$var"

def_strs(a b c)

# The above macro-expands to:

a := "a"
b := "b"
c := "c"

# Thefore, at the end of the call to the `def_strs(...)` macro:

print(a == "a")  # true
print(b == "b")  # true
print(c == "c")  # true

Compile-time type generation using macros:

$# for n in range(:min 1, :max 9)
  type int${n*8} [$n]byte

This macro-expands to:

type int8 [1]byte
type int16 [2]byte
type int24 [3]byte
type int32 [4]byte
type int40 [5]byte
type int48 [6]byte
type int56 [7]byte
type int64 [8]byte

Map Macro Method

This code:

doubled := [1 2 3].map(v * 2)

...macro-expands to:

doubled := [v * 2 from [1 2 3]]

...which finally macro-expands to:

# `i` stands for "index", `v` stands for "value"
doubled := [v * 2 for i v in [1 2 3]]

Under the hood, this code is as efficient as the `array.map(...) macro method:

macro (arr []T) map(expr ...symbol) []T
  # This is a mutable variable whose expanded name begins with `new_arr_` and
  # ends with a randomly-generated suffix guaranteed to prevent this variable
  # name from colliding with an existing variable in the context where
  # this macro method is called
  @new_arr_* = []T() {:cap arr.len}

  for i v in arr
    # Here, `new_arr_*` means "the last variable defined whose name begins with `new_arr_`"
    @new_arr_* += ...expr    # `...expr` unpacks to `v * 2`

  emit new_arr_*

Phi macros are purposely unhygienic so as to make the above possible, where v from the calling context can be referenced inside the macro.

Unwanted conflicts between the macro's "local" variables with the calling context's variables is done via the * suffix trick described above.

Goroutines and Channels

Phi features Go-style channels, only more powerful, because Phi also gives us bidirectional typed channels. That is, you can define not just a chan<T> but a chan<T U> to support request/response patterns, where you want to send a value of one type over a channel, and receive a response that is a value of another type:

# Define a channel where we want to send a `tup<str str>` and receive back a `num`
word_counter := chan<tup<str str>, num>() {:cap tup(1 0)}

# Remember, `tup("..." "...")` is shorthand for `tup<str str>("..." "...")`
word_counter.req <- tup("Hello there! Count the words."  " ") !!

go fn()
  # Note that `<-ch` is the same as `ch.read()`
  # Tuple destructuring:
  outer_str substr := <-word_counter.req !!
    eprint("Error reading request from channel `word_counter`: $err")
    return

  # This trailing `!!` is also for handling write errs (e.g., chan could be closed... I guess)
  word_counter.resp <- outer_str.count(substr) + 1 !!

print("Our string contains approximately ${word_counter.resp.read!()} words")

Tuples and Destructuring

Phi has generic tuples with type inference:

# Tuples
fn get_tup() tup<num bool str>
  return tup(1 true "three")

# Note that
#
#   tup(1 true "three")
#
# is shorthand for
#
#   tup<num bool str>(1 true "three")

# Valid
t := get_tup()

# Destructuring a tuple; the variable names can be anything
a b c := get_tup()

# Destructuring a struct; the variable names must match the `user` struct's field names
id username email := user

Facts About Phi

  • All mutations are explicit; variables that are about to be modified must be prefixed by @

  • Key axioms guiding its design:

    • Asking, "What should I be able to do?"
  • Optionals via sumtypes: ?T is short for sumtype(nil T) where T is a type (e.g., num, str, bool, etc)

  • Results via sumtypes: !T is short for sumtype(error T) where T is a type, but with one very important difference -- Phi requires that errors be handled immediately

  • _args is a tuple that is automatically created which contains the current function or method's arguments

  • num is the primary numerical type, and it is implemented as a DEC64 as smartly designed by Douglas Crockford

    • Phi originally used Dec128 (IEEE 754-2008) but this was later rejected for being too complex, needlessly slow, needlessly large, and for treating positive and negative infinity as valid numbers, which one almost never wants
  • int is defined using type int num {:constraints [ $(v mod 1 == 0) ]}

  • from arr is exactly equivalent to for i v in arr

  • The methods arr.keys() and arr.values() are not needed because we can simply do arr.map(k) and arr.map(v), respectively

Non-features

Phi, proudly, does not have these features:

  • Per-source file imports

    • Instead, in globals.phi we define which libraries should show up globally, in every .phi source file in the same folder
  • nil values that can be passed around and blow shit up when used

    • Unlike in Go, the default value of arrays (slices), channels, and pointers is not nil but rather a useful, allocated, empty value of the desired type
  • Variable shadowing

  • Boilerplate everywhere

  • Parentheses everywhere, as in all other Lisp-like languages

  • Commas everywhere, as in non-Lisps (Go, C, C++, Python, JavaScript, Java, etc)

  • Print functions other than print, prin (to exclude trailing newline), eprint (for printing to stderr), and eprin

    • No println, printf, sprintf, etc
  • Ways of defining a variable other than type for types, fn for functions, and def for everything else

    • No var, let, const, etc
    • Putting an @ in front of the var makes it mutable (e.g., def @nums [1 2 3] or @nums := [1 2 3])
  • Static-feeling code

  • Pretending types don't exist

  • Unhelpful helper functions that make the language gigantic and harder to learn

    • See: much of Clojure
  • Unnecessary types

    • In Janet, arrays and mutable arrays are different types with different implementations; same with maps and other built-in types
  • Operators

    • Phi has "op'able functions" like +, -, *, and /, but no operators; put another way, all Phi operators are functions, which eliminates the need for having 2 names for the same concept and functionality; + is a real function that can be passed around, for example, like in Lisps, but not in the weaker scripting languages we are used to
  • Explicit pointers

    • If for performance reasons it is important for certain types or values to not be pointer types under the hood, you can do {:no_ptr true}

Future Features

  1. SkyCastles, which is what Phi calls a not-common-enough pattern in Go code.

    • In essence, a SkyCastle provides a service to the other parts of your program
    • It's not just a passive, inert data structure, but a service that receives values and even functions for mutating the data structures that are private to said service/SkyCastle
    • This is usually implemented as an infinite loop in a function or method that's been launched in a goroutine listens on 1 or more channels for values that are then processed. By making SkyCastle a built-in data type, the boilerplate code can be eliminated and this pattern made more popular, often supplanting the passing around of a mutable data structure
  2. Distributed Actors (thanks to the Relay protocol, from Relay Anonymous Messenger)

    • Will leverage bidirectional channels that represent remote services
  3. Svelte-style reactivity using the $$ prefix

  4. Various features copy/paste/modify/transplanted from a few additional sources

    • Vale, which is safer than Rust yet dramatically simpler; generational references can replace Phi's locks on global mutable variables, for example
    • Hyperfiddle's Electric Clojure, which abstracts away the network calls between the frontend and backend! It also lets programmers write simple functions that get compiled to some frontend code and some backend code
  5. Generative testing, a la clojure/test.generative

Possible Future Features

  1. Create convenient syntax for creating lazy sequences?

  2. Create LowPhi, an implementation of Phi meant for very low-level systems, thus replacing C.

C code example from a book on Xv6 Unix:

char buf[512];
int n;

for(;;){
  n = read(0, buf, sizeof buf);
  if(n == 0)
    break;

  if(n < 0){
    fprintf(2, "read error\n");
    exit(1);
  }

  if(write(1, buf, n) != n){
    fprintf(2, "write error\n");
    exit(1);
  }
}

The equivalent solution in (Low)Phi:

# `FD` == File Descriptor
type FD int {:min 0, :named {:stdin 0, :stdout 1, :stderr 2}}

fn main()
  cat() !

fn cat() !
  @buf := []byte() {:len 512, :fixed_len true}

  for
    n := read(.stdin @buf buf.len) !
    if n == 0
      break

    # `.stdout` is short for `FD.stdout` and `FD[:stdout]`.  This works because
    # I'm imagining that `write()`'s first argument is of type `FD`
    write(.stdout buf n) !

Implementation Status

The Phi compiler is currently being prototyped and is in the very early stages. Please steal these language features to help ensure that the future of software is much more reliable than it is today!