Skip to content

Commit

Permalink
Initial release
Browse files Browse the repository at this point in the history
  • Loading branch information
sabiwara committed Apr 25, 2024
1 parent 913cfdc commit 86b4436
Show file tree
Hide file tree
Showing 19 changed files with 1,050 additions and 1 deletion.
4 changes: 4 additions & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: CI
on: [push, pull_request]

jobs:
test:
runs-on: ubuntu-latest
name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}}
strategy:
matrix:
include:
- elixir: "1.13"
otp: "24.3"
- elixir: "1.14"
otp: "25.3"
- elixir: "1.15"
otp: "26.0"
- elixir: "1.16"
otp: "26.2"
- elixir: "1.16"
otp: "27.0-rc3"
steps:
- uses: actions/checkout@v2
- uses: erlef/setup-beam@v1
with:
otp-version: ${{matrix.otp}}
elixir-version: ${{matrix.elixir}}
- name: Install Dependencies
run: mix deps.get
- name: Check compile warnings
run: mix compile --warnings-as-errors
- name: Check format
run: mix format --check-formatted
- name: Unit tests
run: mix test.unit
- name: Property-based tests
run: PROP_TEST_RUNTIME=30000 mix test.prop
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ erl_crash.dump
*.ez

# Ignore package tarball (built via "mix hex.build").
cmp-*.tar
ion-*.tar

# Temporary files, for example, from tests.
/tmp/
2 changes: 2 additions & 0 deletions .iex.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import_file("~/.iex.exs")
import_if_available(Ion, only: :sigils)
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Changelog

## Dev

## v0.1.0 (2024-04-25)

- Initial release
20 changes: 20 additions & 0 deletions LICENSE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
MIT License

Copyright (c) 2024 Sabiwara

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
112 changes: 112 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Ion

[![Hex Version](https://img.shields.io/hexpm/v/ion.svg)](https://hex.pm/packages/ion)
[![docs](https://img.shields.io/badge/docs-hexpm-blue.svg)](https://hexdocs.pm/ion/)
[![CI](https://github.com/sabiwara/ion/workflows/CI/badge.svg)](https://github.com/sabiwara/ion/actions?query=workflow%3ACI)

Lightweight utility library for efficient IO data and chardata handling.

## TLDR

Interpolation:

```elixir
# plain string
"#{name}: #{count}."
# IO data (using Ion)
~i"#{name}: #{count}."
# IO data (manual)
[name, " ", to_string(count), ?.]
```

Joining:

```elixir
# plain string
Enum.join(integers, "+")
# IO data (using Ion)
Ion.join(integers, "+")
# IO data (manual)
Enum.map_intersperse(integers, "+", &to_string/1)
```

Map joining:

```elixir
# plain string
Enum.map_join(users, ", ", fn user -> "#{user.first}.#{user.last}@passione.org" end)
# IO data (using Ion)
Ion.map_join(users, ", ", fn user -> ~i"#{user.first}.#{user.last}@passione.org" end)
# IO data (manual)
Enum.map_intersperse(users, ", ", fn user -> [user.first, ?., user.last, "@passione.org"] end)
```

## Why `Ion`?

[IO data and chardata](https://hexdocs.pm/elixir/io-and-the-file-system.html#iodata-and-chardata)
are one of the secret weapons behind Elixir and Erlang performance when it comes
to string processing and IO. Turns out the fastest way to concatenate strings
is: avoiding concatenation in the first place!

While it is perfectly possible to handcraft IO data with just the standard
library, it can sometimes be tedious, cryptic and error prone. `Ion` provides a
few common recipes which:

- drop-in replacement, with APIS consistent with the standard way of building
strings
- reduce the cognitive overhead and make the intent explicit
- are implemented in an optimal fashion (`Ion` is fast!)
- help reducing bugs through better typing (see below)

The examples above illustrate how easy it is to migrate from building strings to
building IO data or chardata.

### Increased safety

Building IO lists manually or through interspersing is error prone: we need to
be careful to cast things that are neither strings nor nested IO data or will
end up with invalid data:

```elixir
iex> as_bytes = Enum.intersperse(100..105, "+")
[100, "+", 101, "+", 102, "+", 103, "+", 104, "+", 105]
iex> IO.iodata_to_binary(as_bytes)
"d+e+f+g+h+i"
# need to make sure we have strings:
iex> Enum.map_intersperse(100..105, "+", &to_string/1) |> IO.iodata_to_binary()
# works just like Enum.join/2:
iex> Ion.join(100..105, "+") |> IO.iodata_to_binary()
"100+101+102+103+104+105"
```

### Performance

Because they are specialized, the join functions should also be faster than
interspersing (~1.5x, see the `benchmarks` folder).

## Installation

Ion can be installed by adding `Ion` to your list of dependencies in `mix.exs`:

```elixir
def deps do
[
{:ion, "~> 0.1.0"}
]
end
```

Or you can just try it out from `iex` or an `.exs` script:

```elixir
iex> Mix.install([:ion])
:ok
iex> Ion.join(["Hello", :world], ",")
["Hello", ",", "world"]
```

Documentation can be found at [https://hexdocs.pm/ion](https://hexdocs.pm/ion).

## Copyright and License

Ion is licensed under the [MIT License](LICENSE.md).
24 changes: 24 additions & 0 deletions benchmarks/iodata_empty.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
defmodule Bench.IO.Empty do
def inputs() do
for n <- [5, 50, 500] do
:rand.seed(:exrop, {1, 2, 3})
iodata = Stream.repeatedly(fn -> <<?a..?z |> Enum.random()>> end) |> Enum.take(n)
{"n = #{n}", iodata}
end ++ [
{"pathological", Enum.reduce(1..50, "not_empty", fn _, acc -> [acc] end)}
]
end

def run() do
Benchee.run(
[
{"IO.iodata_length() == 0", fn iodata -> IO.iodata_length(iodata) == 0 end},
{"Ion.iodata_empty?/1", fn iodata -> Ion.iodata_empty?(iodata) end}
],
inputs: inputs(),
print: [fast_warning: false]
)
end
end

Bench.IO.Empty.run()
60 changes: 60 additions & 0 deletions benchmarks/iodata_empty.results.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
Operating System: macOS
CPU Information: Apple M1
Number of Available Cores: 8
Available memory: 16 GB
Elixir 1.14.5
Erlang 26.0.1

Benchmark suite executing with the following configuration:
warmup: 2 s
time: 5 s
memory time: 0 ns
reduction time: 0 ns
parallel: 1
inputs: n = 5, n = 50, n = 500, pathological
Estimated total run time: 56 s

Benchmarking IO.iodata_length() == 0 with input n = 5 ...
Benchmarking IO.iodata_length() == 0 with input n = 50 ...
Benchmarking IO.iodata_length() == 0 with input n = 500 ...
Benchmarking IO.iodata_length() == 0 with input pathological ...
Benchmarking Ion.iodata_empty?/1 with input n = 5 ...
Benchmarking Ion.iodata_empty?/1 with input n = 50 ...
Benchmarking Ion.iodata_empty?/1 with input n = 500 ...
Benchmarking Ion.iodata_empty?/1 with input pathological ...

##### With input n = 5 #####
Name ips average deviation median 99th %
Ion.iodata_empty?/1 60.16 M 16.62 ns ±3994.43% 16.70 ns 29.20 ns
IO.iodata_length() == 0 25.62 M 39.03 ns ±7769.23% 42 ns 42 ns

Comparison:
Ion.iodata_empty?/1 60.16 M
IO.iodata_length() == 0 25.62 M - 2.35x slower +22.41 ns

##### With input n = 50 #####
Name ips average deviation median 99th %
Ion.iodata_empty?/1 26.38 M 37.91 ns ±17084.78% 42 ns 42 ns
IO.iodata_length() == 0 8.52 M 117.34 ns ±5382.12% 125 ns 125 ns

Comparison:
Ion.iodata_empty?/1 26.38 M
IO.iodata_length() == 0 8.52 M - 3.10x slower +79.43 ns

##### With input n = 500 #####
Name ips average deviation median 99th %
Ion.iodata_empty?/1 24.78 M 40.35 ns ±41066.46% 42 ns 42 ns
IO.iodata_length() == 0 1.20 M 836.14 ns ±169.27% 833 ns 916 ns

Comparison:
Ion.iodata_empty?/1 24.78 M
IO.iodata_length() == 0 1.20 M - 20.72x slower +795.79 ns

##### With input pathological #####
Name ips average deviation median 99th %
IO.iodata_length() == 0 3.45 M 289.98 ns ±461.74% 292 ns 334 ns
Ion.iodata_empty?/1 2.18 M 458.13 ns ±5164.45% 416 ns 542 ns

Comparison:
IO.iodata_length() == 0 3.45 M
Ion.iodata_empty?/1 2.18 M - 1.58x slower +168.15 ns
21 changes: 21 additions & 0 deletions benchmarks/join.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
range = 1..100
integers = Enum.shuffle(range)
strings = Enum.map(integers, &<<&1::utf8, &1::utf8>>)
charlists = Enum.map(integers, &[&1, &1])

Benchee.run(
%{
"Enum.intersperse (strings)" => fn -> Enum.intersperse(strings, "-") end,
"Enum.join (strings)" => fn -> Enum.join(strings, "-") end,
"Ion.join (strings)" => fn -> Ion.join(strings, "-") end,
"Enum.intersperse (charlists)" => fn -> Enum.intersperse(charlists, "-") end,
"Ion.join (charlists)" => fn -> Ion.join(charlists, "-") end,
"Enum.map_intersperse (ints)" => fn -> Enum.map_intersperse(integers, "-", &to_string/1) end,
"Enum.join (ints)" => fn -> Enum.join(integers, "-") end,
"Ion.join (ints)" => fn -> Ion.join(integers, "-") end,
"Enum.join (range)" => fn -> Enum.join(range, "-") end,
"Ion.join (range)" => fn -> Ion.join(range, "-") end,
},
time: 2,
memory_time: 0.5
)
66 changes: 66 additions & 0 deletions benchmarks/join.results.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
Operating System: macOS
CPU Information: Apple M1
Number of Available Cores: 8
Available memory: 16 GB
Elixir 1.14.5
Erlang 26.0.1

Benchmark suite executing with the following configuration:
warmup: 2 s
time: 2 s
memory time: 500 ms
reduction time: 0 ns
parallel: 1
inputs: none specified
Estimated total run time: 45 s

Benchmarking Enum.intersperse (charlists) ...
Benchmarking Enum.intersperse (strings) ...
Benchmarking Enum.join (ints) ...
Benchmarking Enum.join (range) ...
Benchmarking Enum.join (strings) ...
Benchmarking Enum.map_intersperse (ints) ...
Benchmarking Ion.join (charlists) ...
Benchmarking Ion.join (ints) ...
Benchmarking Ion.join (range) ...
Benchmarking Ion.join (strings) ...

Name ips average deviation median 99th %
Ion.join (strings) 1840.60 K 0.54 μs ±2872.63% 0.50 μs 0.67 μs
Ion.join (charlists) 1763.59 K 0.57 μs ±3011.09% 0.50 μs 0.71 μs
Enum.intersperse (charlists) 1219.01 K 0.82 μs ±1589.04% 0.75 μs 0.96 μs
Enum.intersperse (strings) 1217.86 K 0.82 μs ±1577.94% 0.75 μs 0.96 μs
Enum.join (strings) 498.47 K 2.01 μs ±411.72% 1.88 μs 3.17 μs
Ion.join (ints) 297.30 K 3.36 μs ±139.02% 3.25 μs 4.21 μs
Ion.join (range) 280.18 K 3.57 μs ±162.61% 3.46 μs 4.50 μs
Enum.map_intersperse (ints) 265.88 K 3.76 μs ±162.12% 3.67 μs 4.63 μs
Enum.join (ints) 209.26 K 4.78 μs ±69.28% 4.63 μs 8.54 μs
Enum.join (range) 190.09 K 5.26 μs ±121.90% 5.13 μs 7.33 μs

Comparison:
Ion.join (strings) 1840.60 K
Ion.join (charlists) 1763.59 K - 1.04x slower +0.0237 μs
Enum.intersperse (charlists) 1219.01 K - 1.51x slower +0.28 μs
Enum.intersperse (strings) 1217.86 K - 1.51x slower +0.28 μs
Enum.join (strings) 498.47 K - 3.69x slower +1.46 μs
Ion.join (ints) 297.30 K - 6.19x slower +2.82 μs
Ion.join (range) 280.18 K - 6.57x slower +3.03 μs
Enum.map_intersperse (ints) 265.88 K - 6.92x slower +3.22 μs
Enum.join (ints) 209.26 K - 8.80x slower +4.24 μs
Enum.join (range) 190.09 K - 9.68x slower +4.72 μs

Memory usage statistics:

Name Memory usage
Ion.join (strings) 3.13 KB
Ion.join (charlists) 3.13 KB - 1.00x memory usage +0 KB
Enum.intersperse (charlists) 3.09 KB - 0.99x memory usage -0.03906 KB
Enum.intersperse (strings) 3.09 KB - 0.99x memory usage -0.03906 KB
Enum.join (strings) 4.66 KB - 1.49x memory usage +1.52 KB
Ion.join (ints) 5.48 KB - 1.75x memory usage +2.34 KB
Ion.join (range) 5.52 KB - 1.76x memory usage +2.39 KB
Enum.map_intersperse (ints) 5.45 KB - 1.74x memory usage +2.32 KB
Enum.join (ints) 5.53 KB - 1.77x memory usage +2.40 KB
Enum.join (range) 5.53 KB - 1.77x memory usage +2.40 KB

**All measurements for memory usage were the same**
21 changes: 21 additions & 0 deletions benchmarks/map_join.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
range = 1..100
integers = Enum.shuffle(range)
strings = Enum.map(integers, &<<&1::utf8, &1::utf8>>)
charlists = Enum.map(integers, &[&1, &1])

Benchee.run(
%{
"Enum.map_intersperse (strings)" => fn -> Enum.map_intersperse(strings, "-", & &1) end,
"Enum.map_join (strings)" => fn -> Enum.map_join(strings, "-", & &1) end,
"Ion.map_join (strings)" => fn -> Ion.map_join(strings, "-", & &1) end,
"Enum.map_intersperse (charlists)" => fn -> Enum.map_intersperse(charlists, "-", & &1) end,
"Ion.map_join (charlists)" => fn -> Ion.map_join(charlists, "-", & &1) end,
"Enum.map_intersperse (ints)" => fn -> Enum.map_intersperse(integers, "-", &to_string/1) end,
"Enum.map_join (ints)" => fn -> Enum.map_join(integers, "-", & &1) end,
"Ion.map_join (ints)" => fn -> Ion.map_join(integers, "-", & &1) end,
"Enum.map_join (range)" => fn -> Enum.map_join(range, "-", & &1) end,
"Ion.map_join (range)" => fn -> Ion.map_join(range, "-", & &1) end,
},
time: 2,
memory_time: 0.5
)
Loading

0 comments on commit 86b4436

Please sign in to comment.