Skip to content

Commit fd5e3e8

Browse files
committed
Optimize range iteration
For loops support a range syntax for iterating a fixed number of times, which looks like this. ```liquid {% for i in range (1..100) %} ... {% endfor %} ``` Previously, we converted these ranges to arrays using `to_a`, which initialized an array containing each number in the range. Since all we use these ranges for is iteration, this is far less efficient than using a range iterator. Doing this means that iterating over ranges now takes O(1) rather than O(n) memory. See the PR for more benchmarks. * Remove to_a cast on ranges * Add ReversableRange iterator * Add custom range-specific slicing logic
1 parent b3b63a6 commit fd5e3e8

9 files changed

Lines changed: 208 additions & 9 deletions

File tree

Gemfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@ group :test do
1818
gem 'rubocop', '~> 0.53.0'
1919

2020
platform :mri do
21-
gem 'liquid-c', github: 'Shopify/liquid-c', ref: '9168659de45d6d576fce30c735f857e597fa26f6'
21+
gem 'liquid-c', github: 'Shopify/liquid-c', ref: 'reversable-range'
2222
end
2323
end

lib/liquid.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ module Liquid
7474
require 'liquid/utils'
7575
require 'liquid/tokenizer'
7676
require 'liquid/parse_context'
77+
require 'liquid/reversable_range'
7778

7879
# Load all the tags of the standard library
7980
#

lib/liquid/range_lookup.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ def self.parse(start_markup, end_markup)
66
if start_obj.respond_to?(:evaluate) || end_obj.respond_to?(:evaluate)
77
new(start_obj, end_obj)
88
else
9-
start_obj.to_i..end_obj.to_i
9+
ReversableRange.new(start_obj.to_i, end_obj.to_i)
1010
end
1111
end
1212

@@ -18,7 +18,7 @@ def initialize(start_obj, end_obj)
1818
def evaluate(context)
1919
start_int = to_integer(context.evaluate(@start_obj))
2020
end_int = to_integer(context.evaluate(@end_obj))
21-
start_int..end_int
21+
ReversableRange.new(start_int, end_int)
2222
end
2323

2424
private

lib/liquid/reversable_range.rb

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
module Liquid
2+
class ReversableRange
3+
include Enumerable
4+
5+
def initialize(min, max)
6+
@min = min
7+
@max = max
8+
@reversed = false
9+
end
10+
11+
def each
12+
if reversed
13+
index = max
14+
while index >= min
15+
yield index
16+
index -= 1
17+
end
18+
else
19+
index = min
20+
while index <= max
21+
yield index
22+
index += 1
23+
end
24+
end
25+
end
26+
27+
def reverse!
28+
@reversed = !reversed
29+
self
30+
end
31+
32+
def empty?
33+
max < min
34+
end
35+
36+
def size
37+
difference = max - min
38+
if difference > 0
39+
difference + 1
40+
else
41+
0
42+
end
43+
end
44+
45+
def load_slice(from, to = nil)
46+
to ||= max
47+
slice_max = [max, to].min
48+
slice_min = [min, from].max
49+
range = ReversableRange.new(slice_min, slice_max)
50+
range.reverse! if reversed
51+
range
52+
end
53+
54+
def ==(other)
55+
other.is_a?(self.class) &&
56+
other.min == min &&
57+
other.max == max &&
58+
other.reversed == reversed
59+
end
60+
61+
def to_s
62+
if reversed
63+
"#{max}..#{min}"
64+
else
65+
"#{min}..#{max}"
66+
end
67+
end
68+
69+
def to_liquid
70+
self
71+
end
72+
73+
protected
74+
75+
attr_reader :min, :max, :reversed
76+
end
77+
end

lib/liquid/tags/for.rb

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,6 @@ def collection_segment(context)
133133
end
134134

135135
collection = context.evaluate(@collection_name)
136-
collection = collection.to_a if collection.is_a?(Range)
137136

138137
limit_value = context.evaluate(@limit)
139138
to = if limit_value.nil?
@@ -145,14 +144,14 @@ def collection_segment(context)
145144
segment = Utils.slice_collection(collection, from, to)
146145
segment.reverse! if @reversed
147146

148-
offsets[@name] = from + segment.length
147+
offsets[@name] = from + segment.size
149148

150149
segment
151150
end
152151

153152
def render_segment(context, segment)
154153
for_stack = context.registers[:for_stack] ||= []
155-
length = segment.length
154+
length = segment.size
156155

157156
result = ''
158157

lib/liquid/utils.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
module Liquid
22
module Utils
33
def self.slice_collection(collection, from, to)
4+
return collection.load_slice(from, to) if collection.is_a?(ReversableRange)
5+
46
if (from != 0 || !to.nil?) && collection.respond_to?(:load_slice)
57
collection.load_slice(from, to)
68
else

test/integration/tags/for_tag_test.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ def test_for_reversed
3636
assert_template_result('321', '{%for item in array reversed %}{{item}}{%endfor%}', assigns)
3737
end
3838

39+
def test_for_range_reversed
40+
assert_template_result('321', '{%for item in (1..3) reversed %}{{item}}{%endfor%}', {})
41+
end
42+
3943
def test_for_with_range
4044
assert_template_result(' 1 2 3 ', '{%for item in (1..3) %} {{item}} {%endfor%}')
4145

test/unit/context_unit_test.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -349,9 +349,9 @@ def test_nested_context_from_within_drop
349349

350350
def test_ranges
351351
@context.merge("test" => '5')
352-
assert_equal (1..5), @context['(1..5)']
353-
assert_equal (1..5), @context['(1..test)']
354-
assert_equal (5..5), @context['(test..test)']
352+
assert_equal ReversableRange.new(1, 5), @context['(1..5)']
353+
assert_equal ReversableRange.new(1, 5), @context['(1..test)']
354+
assert_equal ReversableRange.new(5, 5), @context['(test..test)']
355355
end
356356

357357
def test_cents_through_drop_nestedly
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
require 'test_helper'
2+
3+
class ReversableRangeTest < Minitest::Test
4+
include Liquid
5+
6+
def test_each_iterates_through_items_in_the_range
7+
actual_items = []
8+
ReversableRange.new(1, 10).each { |item| actual_items << item }
9+
10+
expected_items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
11+
assert_equal expected_items, actual_items
12+
end
13+
14+
def test_implements_enumerable
15+
actual_items = ReversableRange.new(1, 10).select(&:even?)
16+
17+
expected_items = [2, 4, 6, 8, 10]
18+
assert_equal expected_items, actual_items
19+
end
20+
21+
def test_is_not_empty_max_greater_than_min
22+
range = ReversableRange.new(9, 10)
23+
24+
refute_predicate range, :empty?
25+
end
26+
27+
def test_is_not_empty_max_equal_to_min
28+
range = ReversableRange.new(10, 10)
29+
30+
refute_predicate range, :empty?
31+
end
32+
33+
def test_is_empty_if_not_reversed_and_max_less_than_min
34+
range = ReversableRange.new(10, 9)
35+
36+
assert_predicate range, :empty?
37+
end
38+
39+
def test_ranges_with_the_same_max_and_min_have_one_element
40+
actual_items = ReversableRange.new(1337, 1337).to_a
41+
expected_items = [1337]
42+
assert_equal expected_items, actual_items
43+
end
44+
45+
def test_load_slice_returns_a_sub_range
46+
actual_items = ReversableRange.new(1, 10).load_slice(5, 8).to_a
47+
48+
expected_items = [5, 6, 7, 8]
49+
assert_equal expected_items, actual_items
50+
end
51+
52+
def test_load_slice_returns_a_reversed_sub_range_if_reversed
53+
range = ReversableRange.new(1, 10)
54+
range.reverse!
55+
actual_items = range.load_slice(5, 8).to_a
56+
57+
expected_items = [8, 7, 6, 5]
58+
assert_equal expected_items, actual_items
59+
end
60+
61+
def test_is_equal_to_other_if_also_a_reversable_range_and_has_same_properties
62+
one = ReversableRange.new(1, 10)
63+
one.reverse!
64+
65+
two = ReversableRange.new(1, 10)
66+
two.reverse!
67+
68+
assert_equal one, two
69+
end
70+
71+
def test_is_not_equal_to_a_non_reversable_range
72+
range = ReversableRange.new(1, 10)
73+
range.reverse!
74+
75+
refute_equal range, :something_else
76+
end
77+
78+
def test_is_not_equal_if_ranges_have_different_mins
79+
one = ReversableRange.new(1, 10)
80+
two = ReversableRange.new(2, 10)
81+
82+
refute_equal one, two
83+
end
84+
85+
def test_is_not_equal_if_ranges_have_different_maxes
86+
one = ReversableRange.new(1, 10)
87+
two = ReversableRange.new(1, 11)
88+
89+
refute_equal one, two
90+
end
91+
92+
def test_is_not_equal_if_only_one_is_reversed
93+
one = ReversableRange.new(1, 10)
94+
95+
two = ReversableRange.new(1, 10)
96+
two.reverse!
97+
98+
refute_equal one, two
99+
end
100+
101+
def test_to_s_mirrors_rubys_range_syntax
102+
range = ReversableRange.new(1, 10)
103+
assert_equal '1..10', range.to_s
104+
end
105+
106+
def test_to_s_reverses_when_reversed
107+
range = ReversableRange.new(1, 10)
108+
range.reverse!
109+
assert_equal '10..1', range.to_s
110+
end
111+
112+
def test_size
113+
range = ReversableRange.new(1, 10)
114+
assert_equal 10, range.size
115+
end
116+
end

0 commit comments

Comments
 (0)