Roughly, Hy is to Python as Clojure is to Java. Hy completely inter-ops with Python.
I've hit commit 1,500 in my Hy project at work. I wanted to share my experience working with Hy, where I feel it shines and where it falls short.
Intro to Hy
Basic syntax
Hy is a lisp and so uses hyphens as its delimiter. Hy unmangles all hyphens as underscores and mangles all incoming underscores to hyphens.
The following is all valid, hyphens and underscores can be used interchangeably:
(import a-module)
(import b_module)
(defn a-func [x y]
(+ x y))
(setv a-list [1 2])
(setv a_dict {"k1" "v1" "k2" "v2"})
To use Hy from python:
import hy
from my_hy_module import a_func
a_func(1, 2)
Classes work as expected:
(defclass AClass [object]
(defn --init-- [self])
#@(staticmethod
(defn -a-func [])))
The #@
is a reader macro for with-decorator
, illustrated later.
Most all python features are implemented. For example, in master branch we have args/kwargs unpacking as:
(print #* ["hi" "there"] #** {"sep" "\n"})
Some features that were once in python but were removed are also implemented, for instance parameter unpacking:
(defn add [x [y z]]
(+ x y z))
(add 1 [2 3])
Shadowed built-ins
Most operators are shadowed. This enables:
(map + [1 2] [2 3])
whereas in python you would have to do a comprehension or:
import op
map(op.add, [1, 2], [2, 3])
Special characters in names
Python restricts the characters you can use in setting variable and functions. Hy does not have most of these restrictions.
(defn assert~ [x y]
(npt.assert-almost-equal x y))
Testing
Hy works with pytest. Add project_root/conftest.py
with:
import hy
from _pytest.python import Module
def pytest_collect_file(path, parent):
if path.ext == ".hy" and "test_" in path.basename:
return Module(path, parent)
To collect and run tests written in hy.
Debugging
Pdb/ipdb integrate perfectly with Hy, regardless if called from a hy, python, or ipython repl. The hy code can be stepped through and depending on if which repl you are in, hy/python code be can executed.
Why Hy
Macros
Espousing macros to those who have not learned them is akin to the "monad fallacy". Instead, consider this example of macros reducing pytest boilerplate.
The macro definitions are contained in this gist.
(deffixture numbers
"Some numbers."
[[[1 2] 1 [2 3] [2 3]
[[1 2] 2 [3 4] [2 4]]
(list-it it (np.array x1) x2 (np.array x3) (np.array x4))
(with-fixture numbers
test-numpy-+ [x i y -]
(assert~ (+ i x) y))
(with-fixture numbers
test-numpy-* [x i - y]
(assert~ (* i x) y))
@pytest.fixture(params=[
([1, 2], 1, [2, 3], [2, 3]),
([1, 2], 2, [3, 4], [2, 4])
])
def numbers(request):
"Some numbers."
x, i, y, z = request.params
return np.array(x), i, np.array(y), np.array(z)
def test_numpy_add(numbers):
x, i, y, _ = numbers
npt.assert_almost_equal(x+i, y)
def test_numpy_mult(numbers):
x, i, _, y = numbers
npt.assert_almost_equal(x*i, y)
Macros allowed:
-
Implicitly binding request.params to "it" which are then deconstructed to an anonymous function with the number of parameters as its arity.
-
Selectively transforming these parameters.
-
Unpacking and binding the parameters at function definition level.
With the prep factored out in a way only macros enable, the testing body is now exactly and only the testing logic.
Reader Macros
Reader macros are macros with a shortened syntax. The ones I use most are:
(import [toolz.curried :as tz])
(deftag t [expr]
"Cast form to a tuple."
`(tuple ~expr))
(deftag $ [expr]
"Curry a form."
`(tz.curry ~@expr))
;; For example
#t(map #$(+ 1) [1 2])
Or in python:
tuple(map(lambda x: x+1, [1, 2]))
tuple(x+1 for x in [1, 2])
Depending on the version of hy you are using, it is either defsharp
or deftag
.
Structural editing
Those that are familiar with lisps likely know the terms "slurp", "barf", "wrap", and so on.
Lisp syntax allows for editing the AST directly.
;; Initial text
map [1 2] (+ 1)
;; wrap map with "w" followed by three slurps 3*"s"
(map [1 2] (+ 1))
;; traverse to last form 2*"j" and transpose "t"
(map (+ 1) [1 2])
;; altogether 'wsssjjt'
This kind of editing is not possible (or at least very restricted) when editing python code.
Functional programming
What drove me to move to Hy was when I looked at my code and saw
tz.thread_first
and tz.thread_last
everywhere. Python goes out of its way
to make functional programming a second-class citizen. Most all building blocks
of FP must be implemented and imported everywhere, like the identity function
and composition.
Hy empowers FP with:
-
Threading and anaphoric macros.
-
First class functools and itertools (reduce, starmap, compress…)
-
Common functional methods (juxt, take, drop, constantly, repeatedly…)
When to Hyde
Hy is not always the best choice. I have encountered some issues with Hy, as would be expected with a niche language under active development.
The issues are however minor and due to the key development invariant of maintaining complete python compatibility, at worst they can be addressed by writing that functionality in Python and importing it.
Tooling
The largest challenge I see to Hy adoption is its current state of tooling.
If you want to have an enjoyable Hy experience, you are pretty much limited to Emacs.
The Emacs major-mode hy-mode implements syntax highlighting and some basic repl support but has its own set of issues.
-
No linter, and pylint won't recognize imported hy objects.
-
No autocompletion.
-
There are bugs.
I'm going to work to address some of these issues but as of now, while you have access to all of python's libraries, the same is not true for its tooling.
Scripting
Hy's repl comes far short of Ipython's featureset. You do not have things like autoloading or %pdb toggling. In general, lisp's syntax is not as nice for scripting.
I actually write all my scripts and interactive code in Python as a result.
Performance Critical Code
If you are writing code that does a lot of fancy array indexing and in-place operations, you will have a bad time in Hy.
My work is data-sciency and I have some numba accelerated code in separate python modules. I've called Cython source files from Hy without issue.
There is also a small performance cost to using Hy, it is insignificant for most purposes.
Breaking Changes
Breaking changes do occur.
Version 0.12 had reader macros as defreader
which is now defsharp
in 0.13
and now in master branch is deftag
. There are good reasons for these changes,
but they do require being up to date on hy's development.
The function apply
for calling a function with arguments unpacked was removed
in master when the unpacking generalizations were implemented. However, apply
still has a use-case for threading macros and last I checked they were debating
reintroducing it in some form.
The constants for inf and -inf recently were changed to require capitalization.
Let was originally implemented but removed in favor of setv
.
There are good reasons for all these changes but they do incur extra maintenance on your part.
Documentation
Hy's documentation could be improved. Some examples:
-
Integrating pytest and Hy via the
conftest.py
is not documented. -
Which operators are shadowed are not documented. While now functions like
get
are shadowed, when I started, usingget
as a function would throw the strangeNameError: name 'get' is not defined
. -
Macros are imported with
require
. Unlike imports, requires are not transitive. So if I have amacros.hy
file that does(require [hy.extra.anaphoric [*]])
, in every file requiring from macros I will also need to require the anaphorics again. -
The documentation for
zero?
implies anx is 0
but it actually checks equality. This came up working with numpy, small issues like this are present.
Although the community is small, I've found the maintainers to be very helpful and quick to respond.
My Experience
Python is a practical language - it has amazing libraries, tooling, and communities. But it's development is opinionated towards imperative programming and its syntax, while great for the majority, leaves others wanting more flexibility.
I've really enjoyed my time with Hy and 1,500 commits later, am satisfied with my choice. Small changes like parameter unpacking and no more commas trim things down. Larger changes like macros, threading and the functional built-ins allow for a first-class functional programming experience.
Choosing Hy you don't get Clojurescript or the type safety of Haskell, but you do get Numpy, Pandas, Matplotlib, Numba, Django, and every other python library from the comfort of Lisp.