📜 ⬆️ ⬇️

Lazy list in C ++


In Scala there is an interesting collection - Stream. The container, which is a list, whose elements are calculated (and saved after this) at the first call:

Stream implements lazy lists where elements are only evaluated when they are needed.

I wanted to implement something similar in C ++.

purpose


I want to get a container that can be used with standard algorithms and that its elements can be generated as they are accessed.

Looking ahead, I will give an example of use:
typedef lazy::list<int> list_type; list_type fibonacci(int n1, int n2) { int next = n1 + n2; std::cout << "fibonacci(" << n1 << ", " << n2 << ") -> " << n1 << std::endl; return list_type(n1, std::bind(&fibonacci, n2, next)); } int main() { list_type list(fibonacci(0, 1)); auto res3 = std::find_if(list.begin(), list.end(), [](int v){return v > 3;}); std::cout << "first number greater 3 is " << *res3 << std::endl; std::cout << std::endl; auto res10 = std::find_if(list.begin(), list.end(), [](int v){return v > 10;}); std::cout << "first number greater 10 is " << *res10 << std::endl; return 0; } 

The output will be:
')
 fibonacci(0, 1) -> 0 fibonacci(1, 1) -> 1 fibonacci(1, 2) -> 1 fibonacci(2, 3) -> 2 fibonacci(3, 5) -> 3 fibonacci(5, 8) -> 5 first number greater 3 is 5 fibonacci(8, 13) -> 8 fibonacci(13, 21) -> 13 first number greater 10 is 13 


As you can see from the example, the function of generating an element is called only once for each element, the resulting value is stored in the container and is not recalculated.

Restrictions


Suppose we want to do something like:

 auto iter = --list.end(); 

To get the element preceding the end, you must go around all the elements generated by the function, and this is infinity (for the example above). Accordingly, the lazy list iterator will be unidirectional - ForwardIterator. A similar situation will occur when getting the number of items in the list, and when deleting the last item (pop_back). Thus, the container will not have these methods.

For simplicity, I did not implement the insertion in an arbitrary place and the removal of an arbitrary element. Exclusively from the consideration that the sequence of elements can be generated by some function, and when inserting / deleting this sequence will be broken. From the same considerations, the elements are not modifiable. But this is already a convention.

What happened?


The result is a list in which you can add both elements and functors that generate a lazy list, which in turn can contain elements and functors. You can delete either the first element (pop_front) or all elements (clear). Elements are inserted at the beginning or end of the list.

The list iterator is unidirectional, not allowing to modify the elements of the list.

 template< typename T, typename Allocator = std::allocator<T> > class list; 

list definition
 template< typename T, typename Allocator = std::allocator<T> > class list { public: typedef list<T, Allocator> self_type; typedef T value_type; typedef std::function<self_type ()> func_type; typedef __details_lazy_list::const_iterator<self_type> iterator; typedef __details_lazy_list::const_iterator<self_type> const_iterator; friend __details_lazy_list::const_iterator<self_type>; list(); list(const self_type& that); list(self_type&& that); template<typename ... Args> explicit list(Args&&... args) { push_others(std::forward<Args>(args)...); } void push_back(const value_type& value); void push_back(value_type&& value); void push_back(const func_type& func); void push_back(func_type&& func); void push_back(const self_type& that); void push_back(self_type&& that); void push_front(const value_type& value); void push_front(value_type&& value); void push_front(const func_type& func); void push_front(func_type&& func); void push_front(const self_type& that); void push_front(self_type&& that); void clear(); bool empty() const; const_iterator begin() const; const_iterator end() const; private: typedef std::list<value_type, Allocator> container_type; typedef typename container_type::iterator inner_iterator; typedef value_type const * funcs_map_key_type; typedef std::pair<funcs_map_key_type, func_type> funcs_map_value_type; typedef std::map<funcs_map_key_type, func_type> funcs_map_type; void forward(const_iterator& iter) const; void insert(inner_iterator pos, self_type&& that) const; template<typename Arg, typename ...Args> void push_others(Arg&& arg, Args&&... args) { push_back(std::forward<Arg>(arg)); push_others(std::forward<Args>(args)...); } template<typename Arg> void push_others(Arg&& arg) { push_back(std::forward<Arg>(arg)); } void push_others() {} mutable container_type _cont; mutable funcs_map_type _funcs; }; 


iterator definition
 template< typename lazy_list_type > class const_iterator: public std::iterator<std::input_iterator_tag, typename lazy_list_type::value_type> { friend lazy_list_type; public: typedef std::iterator<std::input_iterator_tag, typename lazy_list_type::value_type> base_type; const_iterator(); typename base_type::reference const operator* () const; typename base_type::pointer const operator-> () const; const_iterator& operator++(); const_iterator operator++(int); bool operator== (const const_iterator& that); bool operator!= (const const_iterator& that); private: typedef typename lazy_list_type::inner_iterator inner_iterator; const_iterator(const lazy_list_type* owner, inner_iterator iter); const lazy_list_type* _owner; inner_iterator _iter; }; 


At the end of the article there is a link to the repository, where you can get the implementation with unit tests.

Template parameters.


T - type of stored items

Allocator - Allocator used to place elements.

Internal types


Type ofDescription
value_typeT
self_typeown type
func_typeThe type of functor used to generate the element. The functor returns a self_type object.
iteratorconstant forward iterator
const_iteratorconstant forward iterator

Methods


MethodDescription
push_frontinsert in the beginning
push_backinsert at the end
emptycheck if the container is empty
cleardelete all items
pop_frontdelete first item

The push_front and push_back methods accept a functor that generates elements, the value of a stored element, or another object of type self_type.

Constructors


SignatureDescription
list();Creates an empty container
template<typename ... Args> explicit list(Args&&... args)Creates a container and puts the transferred items into it.
Values ​​of the following types can be transferred:
value_type
func_type
self_type

How it works


Inside, two standard containers are used - std :: list for storing values ​​and std :: map for storing functors. The functor should return a lazy list, i.e. self_type This allows, first, to calculate several elements at once if necessary, and secondly, not to care about the case when there is no next value — the sequence has ended, in this case, you can simply return the empty container.

With the addition of a new element, everything is simple - it is immediately added to the internal list.

When adding a functor, it is checked whether there is a functor associated with the element after which it is added (push_back). If there is no functor, then the passed functor is added to the map, and the pointer to the previous element is taken as the key. When adding to the beginning, in an empty container or after an element with which an associated functor already exists, the operator () method of the functor is simply called, and the values ​​from the resulting container are inserted in the right place (at the beginning or end), new functors are added to the map, they are in the returned container.

One could store in the list a pair of "value - functor", but it seems to me that in the process of work the number of functors will be significantly less than the number of calculated elements, and the memory costs in the case of storing pairs will be higher.
Again, since I assume that the number of functors will not be very large, there is no particular difference what to use - map or unordered_map. The only thing is that when using map memory costs will be slightly less, I think so.

When an iterator is incremented, the presence of the functor for the current element is checked, if it exists, the value returned by it is added to the container, and the functor is deleted. After that, the iterator is incremented to the internal list. If there is no functor or it returns an empty container, then it simply moves to the next element in the internal list.

Why all this?


The implementation of such a list pushed me to the task of Water Pouring, presented in a lecture on the language of Scala. The point is this: there are several glasses of fixed volume, a faucet from which any glass can be filled (we can only fill the glass completely), and a sink where you can pour the contents of the glasses. Filling, emptying and pouring water from one glass to another, you need to get the specified amount of water in one of the glasses. The solution is a sequence of actions to obtain such a state.

For example, there are two glasses of 3 and 5 liters, we want to get 4 liters.


We will consider the current amount of water in each of the glasses as a state. From each state, you can get a new set of possible states by applying one of the possible operations: fill, pour, pour. From each set of states you can get a new set. In order not to loop, we will separately store the obtained states and discard them when receiving a set of new states.


In each set of states, we will look at whether the desired state is a glass with the desired water level.

We will need possible options to influence the current state. Each water transfer option will inherit the imove interface:

 class imove { public: virtual state operator()(const state& cur) const = 0; virtual std::unique_ptr<imove> clone() const = 0; virtual std::string to_string() const = 0; virtual ~imove() {} }; 

The to_string method is to_string needed to display information on the screen.

The following types of movement are possible for this task:

Fill the glass - fill
 class fill: public imove { public: fill(unsigned glass, unsigned capacity); state operator()(const state& cur) const override; std::unique_ptr<imove> clone() const override; std::string to_string() const override; protected: const unsigned _glass; const unsigned _capacity; }; fill::fill(unsigned glass, unsigned capacity) : _glass(glass), _capacity(capacity) {} state fill::operator()(const state& cur) const { assert(cur.size() > _glass); state next(cur); next[_glass] = _capacity; return next; } std::unique_ptr<imove> fill::clone() const { return std::unique_ptr<imove>(new fill(_glass, _capacity)); } std::string fill::to_string() const { return "fill(" + std::to_string(_glass) + ")"; } 


Empty water from the glass - empty
 class empty: public fill { public: empty(unsigned glass); std::unique_ptr<imove> clone() const override; std::string to_string() const override; }; empty::empty(unsigned glass) : fill(glass, 0) {} std::unique_ptr<imove> empty::clone() const { return std::unique_ptr<imove>(new empty(_glass)); } std::string empty::to_string() const { return "empty(" + std::to_string(_glass) + ")"; } 


Pour from one glass to another - pour
 class pour: public imove { public: pour(unsigned from, unsigned to, unsigned capacity_to); state operator()(const state& cur) const override; std::unique_ptr<imove> clone() const override; std::string to_string() const override; protected: const unsigned _from; const unsigned _to; const unsigned _capacity_to; }; pour::pour(unsigned from, unsigned to, unsigned capacity_to) : _from(from), _to(to), _capacity_to(capacity_to) {} state pour::operator()(const state& cur) const { assert((cur.size() > _from) && (cur.size() > _to)); assert(_capacity_to >= cur[_to]); unsigned amount = std::min(cur[_from], _capacity_to - cur[_to]); state next(cur); next[_from] -= amount; next[_to] += amount; return next; } std::unique_ptr<imove> pour::clone() const { return std::unique_ptr<imove>(new pour(_from, _to, _capacity_to)); } std::string pour::to_string() const { return "pour(" + std::to_string(_from) + ", " + std::to_string(_to) + ")"; } 


You also need to store information about the new state, namely the state and the set of movements that led to it. The path class will be responsible for this:

 class path { public: path(const state& initial_state); path(const path& that); void extend(imove_ptr move); const state& end_state() const; std::string to_string() const; bool empty() const; private: std::list<imove_ptr> _history; state _end_state; }; 

And the class itself, which uses the lazy list and the above-mentioned auxiliary classes to find a solution:

 typedef std::list<path> paths_list; class water_pouring { public: water_pouring(std::initializer_list<unsigned> capacities); path solve(unsigned target); private: typedef lazy::list<paths_list> list_of_paths_type; list_of_paths_type extend(const paths_list& paths); const std::vector<unsigned> _capacities; const std::vector<imove_ptr> _posible_moves; const state _initial; std::set<state> _explored_states; list_of_paths_type _paths; }; 

The class has two public methods — a constructor that takes the capacity of the glasses, and a method that returns the path to achieve the desired state.

The private extend method is used to generate lazy list items.

It stores the capacity of the glasses, a set of possible displacements, the initial state, the already “found” states and the actually lazy list of states with a history of their receipt.

For possible movements, use the create_moves function.
 std::vector<imove_ptr> create_moves(const std::vector<unsigned>& capacities) { std::vector<imove_ptr> moves; for (size_t i = 0; i < capacities.size(); ++i) { moves.emplace_back(new empty(i)); moves.emplace_back(new fill(i, capacities[i])); for (size_t j = 0; j < capacities.size(); ++j) { if (i != j) moves.emplace_back(new pour(i, j, capacities[j])); } } return moves; } 


Method water_pouring::extend :

 water_pouring::list_of_paths_type water_pouring::extend(const paths_list& paths) { paths_list next; for (auto& cur_path: paths) { for (auto move: _posible_moves) { state next_state = (*move)(cur_path.end_state()); if (_explored_states.find(next_state) == _explored_states.end()) { path new_path(cur_path); new_path.extend(move); next.push_back(new_path); _explored_states.insert(next_state); } } } if (next.empty()) return list_of_paths_type(); return list_of_paths_type(next, std::bind(&water_pouring::extend, this, next)); } 

Method water_pouring::solve :

 path water_pouring::solve(unsigned target) { paths_list::const_iterator solution; auto it = std::find_if( _paths.begin(), _paths.end(), [target, &solution](const paths_list& paths) -> bool { solution = std::find_if( paths.begin(), paths.end(), [target](const path& p) -> bool { auto it = std::find( p.end_state().begin(), p.end_state().end(), target); return it != p.end_state().end(); }); return solution != paths.end(); }); if (it != _paths.end()) return *solution; return path(state({0})); } 

Actually, to find a solution, the function std :: find_if is used, and the predicate is a lambda function, which looks at the paths to the presence of the required state. Lambda captures the solution link so that once again it does not go through the list of solutions that will be pointed to by it if the solution has been found.

As a result, the program will display the following solution:

 fill(1) pour(1, 0) empty(0) pour(1, 0) fill(1) pour(1, 0) --> (3, 4) 

I could not come up with another task where the lazy list could be useful. I hope the idea will attract someone.

Links


Source: https://habr.com/ru/post/277737/


All Articles