It’s becoming common knowledge that list comprehensions are often considered the pythonic, more efficient/effective way of building lists, while doing some form of processing. This is in contrast to iterating over elements in a loop (typical habits that are formed when coming from programming in C/Java) and appending the items onto a list.

For algorithm challenges, I’ve found myself enjoying restructuring code to take advantage of this more visually aesthetic syntax, and along the way producing some one-liners that don’t feel forced (eye-sores).

This NYU article seems to identify the points associated with this consensus quoting 2 scenarios that list comprehension offers improvement:

  1. List comprehension is faster because it is optimized for the Python interpreter to spot a predictable pattern during looping.
  2. Besides the syntactic benefit of list comprehensions, they are often as fast or faster than equivalent use of map.

There are several articles that would agree with these points, and the occasional disagreement that it provides nothing for performance, and is mostly just syntactically aesthetic (when done concisely).


Testing Environment

For this project, I’ve spun up Linode’s Nanode VPS to perform some tests. This ensures that the testing environment is as least chaotic (with minimal programs/services running) as it can be, while still returning realistic, repeatable results. Below is a snapshot of the new instance.

The version of Python being used is 3.7.3


The Timing Decorator

Functions in Python are first-class objects. This ultimately means that you can treat them as you would objects, such as passing them into other functions as a parameter.

A decorator is syntactic sugar which allows us to execute code both before and after the function it’s being applied on. It can be used in a variety of ways (caching/memoization, protecting views etc.). In this case we just need a start timer and end timer to perform timing analysis of the comprehensions we plan to test.

Since a decorator modifies how a function typically behaves (and applies its own function encapsulating the original function, @functools.wraps(func_name) helps preserve the metadata/details as explained here.

  1. import functools
  2. import time
  3. def time_this(func):
  4. @functools.wraps(func) # preserves metadata of original func
  5. def wrapper_timer(*args, **kwargs):
  6. start_time = time.perf_counter()
  7. value = func(*args, **kwargs)
  8. end_time = time.perf_counter()
  9. func_run_time = end_time - start_time
  10. print(f"Finished {func.__name__!r} in {func_run_time:.5f} secs")
  11. return value
  12. return wrapper_timer

List Comprehension Test Cases

There are several cases I want to explore:

Each case will have 1,000 10,000 100,000 1,000,000 and 10,000,000 samples to build a list with

  1. Randomly assorted numbers
    1. The base case of using a for loop and appending to a list
    2. Using list comprehension to populate a list
  2. Numbers increasing by a fixed/predictable amount
    1. The base case of using a for loop and appending to a list
    2. Using list comprehension to populate a list
  3. Applying a function on each sample (unordered)
    1. The base case of using a for loop and appending to a list
    2. Using a list comprehension to populate a list
    3. Using a lambda function within a list comprehension function (evaluating if there is any degradation using lambda syntax)
    4. Applying function to a list using map and casting the map to a list

Code Used in Test Cases Outlined

Section 1 of Test Cases - Random List being built

  1. test_iterations = [1_000, 10_000, 100_000, 1_000_000, 10_000_000]
  2. for test_case in test_iterations:
  3. int_list = [x for x in range(test_case)]
  4. shuffle(int_list)
  5. print("Test Case: ", test_case)
  6. @time_this
  7. def loop_list():
  8. answer = []
  9. for num in int_list:
  10. answer.append(num)
  11. #return answer
  12. loop_list()
  13. @time_this
  14. def list_comp():
  15. answer = [num for num in int_list]
  16. #return answer
  17. list_comp()

Section 2 of Test Cases - Ordered List (Patterned) being built

  1. test_iterations = [1_000, 10_000, 100_000, 1_000_000, 10_000_000]
  2. for test_case in test_iterations:
  3. print("Test Case: ", test_case)
  4. @time_this
  5. def loop_list():
  6. answer = []
  7. for num in range(test_case):
  8. answer.append(num)
  9. #return answer
  10. loop_list()
  11. @time_this
  12. def list_comp():
  13. answer = [num for num in range(test_case)]
  14. #return answer
  15. list_comp()

Section 3 of Test Cases - Applying Functions onto Lists

  1. test_iterations = [1_000, 10_000, 100_000, 1_000_000, 10_000_000]
  2. for test_case in test_iterations:
  3. int_list = [x for x in range(test_case)]
  4. shuffle(int_list)
  5. print("Test Case: ", test_case)
  6. @time_this
  7. def loop_list():
  8. answer = []
  9. for num in int_list:
  10. answer.append(num**2-3)
  11. return answer
  12. loop_list()
  13. @time_this
  14. def list_comp():
  15. answer = [num**2-3 for num in int_list]
  16. return answer
  17. list_comp()
  18. @time_this
  19. def lamb_list():
  20. answer = [(lambda x: x**2-3)(num) for num in int_list]
  21. return answer
  22. lamb_list()
  23. @time_this
  24. def map_list():
  25. answer = list(map(lambda x: x**2-3, [num for num in int_list]))
  26. return answer
  27. map_list()

Results

Taking the average of 10 results produced in each section. The top row indicates how large of a list is being built. The first column briefly notes how the list was generated (based on test cases above). The data is time taken in seconds (s).

Section 1 Results

1,000 10,000 100,000 1,000,000 10,000,000
For Loop 0.00011 0.00109 0.01793 0.23127 2.95845
List Comp 0.00005 0.00042 0.00569 0.12741 1.57571

List Comprehensions consistently performed approximately twice as fast as for loops, where the lists being generated consisted of random samples.

Zooming in on the smaller List Sizes:

Section 2 Results

1,000 10,000 100,000 1,000,000 10,000,000
For Loop 0.00013 0.00122 0.01275 0.13191 1.33011
List Comp 0.00006 0.00062 0.00946 0.12563 0.81151

When the list was ordered (generated directly by a range function), the samples continued to behave in a fashion such that list comprehension was always faster (up to 2x as fast) than the for loop.

The general increase in time can be accounted due to the fact the range function was called, in contrast to iterating over a list that was generated before the function call. Interestingly, for the cases of 1,000,000 and 10,000,000 we can see that some optimization occurred where the results the final lists being generated were faster than that of using an unordered list.

Section 3 Results

1,000 10,000 100,000 1,000,000 10,000,000
For Loop 0.00038 0.00355 0.04881 0.55235 5.73516
List Comp 0.00035 0.00331 0.04865 0.51484 4.80632
Lambda in List Comp 0.00044 0.00437 0.05306 0.62929 5.84127
Map + Lambda + List Comp 0.00041 0.00431 0.04886 0.71190 6.63414

When it comes to applying a simple function over a list of randomly assorted integers, it would seem that it makes little difference whether the list was generated via a list comprehension, or if the value was calculated and then appended to the list in a for loop.

Lambda functions within list comprehensions take up a similar amount of time compared to using map, to apply a lambda function on each element. These two methods take slightly longer than the for loop and list comprehension methods of building lists. When the list size being built is greater than 1,000,000 we can see that using map started to take more time than lambdas performed in list comprehensions.


Conclusion

If generating a list simply, it’s clear that list comprehension performs the fastest in time. If Lambda functions and Maps can be avoided, they should, and often times are also considered the less pythonic way (adding reading complexity to list comprehensions). When it comes to building a list simply, list comprehensions are twice as fast as building/appending to a list in a for loop (C/Java paradigm).

When it comes to performing some sort of function while generating a list (or after using map), the fastest method was to do so in the list comprehension but surprisingly, using a for loop took a negligible (albeit slightly more) amount of time - offering almost the same performance as list comprehension. Utilizing lambda functions padded more time, and using a map appeared to be the slowest method for large list cases (over 1,000,000). Using maps can be up to 2 seconds slower compared to list comprehensions for cases where the list is 10,000,000 elements long.

It can be observed that List Comprehension is always the faster paradigm.