Working with lists in apps often means filtering data before showing it to users.
But, we know that filters can sometimes create complex and tricky code to maintain 😵💫
Today, we’ll dive into a cleaner, more reusable approach using the Predicate
interface 🚀
📦 Let's imagine we're working on a fun app to help keep track of all our products in storage. We’ve got a Product
model.
data class Product(val id: Int, val name: String, val price: Int, val inStock: Boolean, val weight: Int, val category: String)
🛑 The Problems with Traditional Filtering
Take a look at this filtering example:
fun filter(
products: List<Product>,
name: String?,
price: String?,
inStock: Boolean?,
weight: Int?,
category: String?
): List<Product> {
return products
.asSequence()
.filter { product ->
name?.let { product.name.contains(it, ignoreCase = true) } ?: true
}
.filter { product ->
price?.let { product.price == it.toInt() } ?: true
}
.filter { product ->
inStock?.let { product.inStock == it } ?: true
}
.filter { product ->
weight?.let { product.weight == it } ?: true
}
.filter { product ->
category?.let { product.category == it } ?: true
}
.toList()
}
🚩 Problems with this strategy:
- Code is hard to maintain:
- Adding new conditions requires modifying existing code
- Poor readability:
- Multiple filters that are difficult to understand and extend.
- Code is hard to reuse:
- We have to duplicate the code if we want to use it in another context.
✅ Improving Validation with the Predicate Interface
The Predicate
interface from java.util.function
is here to save the day! It allows us to encapsulate individual validation rules and chain them together easily.
@FunctionalInterface
public interface Predicate<T> {
boolean test(T t);
default Predicate<T> and(Predicate<? super T> other) {
Objects.requireNonNull(other);
return (t) -> test(t) && other.test(t);
}
default Predicate<T> negate() {
return (t) -> !test(t);
}
default Predicate<T> or(Predicate<? super T> other) {
Objects.requireNonNull(other);
return (t) -> test(t) || other.test(t);
}
static <T> Predicate<T> isEqual(Object targetRef) {
return (null == targetRef)
? Objects::isNull
: object -> targetRef.equals(object);
}
static <T> Predicate<T> not(Predicate<? super T> target) {
Objects.requireNonNull(target);
return (Predicate<T>)target.negate();
}
}
⛓️ What is the Chain of Responsibility Pattern?
The Chain of Responsibility pattern allows passing requests along a chain of handlers. Each handler decides whether to process the request or pass it to the next handler.
✨ Predicate in Action
Now, we will create predicates for each field of the Product
model, such as category
, weight
, inStock
, and so on. These predicates will check if values meet the given conditions. Each of these predicates
will implement the Predicate<Product>
interface, and we can combine them using the and
, or
, and negate
methods.
class ProductWeightPredicate(private val weight: Int) : Predicate<Product> {
override fun test(product: Product): Boolean {
return product.weight == weight
}
}
class ProductCategoryPredicate(private val category: String) : Predicate<Product> {
override fun test(product: Product): Boolean {
return product.category == category
}
}
Now we can dynamically combine these predicates
. For example:
private val predicates: MutableSet<Predicate<Product>> = mutableSetOf()
fun onWeightChanged(weight: Int) {
predicates.add(ProductWeightPredicate(weight))
}
fun onCategoryChanged(category: String) {
predicates.add(ProductCategoryPredicate(category))
}
fun onFilterClicked(products: List<Product>): List<Product> {
val predicate = predicates.reduce { acc, predicate -> acc.and(predicate) }
return products.filter { predicate.test(it) }
}
🔍 Searching by Category or Name
We can also combine predicates into different chains. For example, filtering by category
or product
name when searching:
class ProductNamePredicate(private val name: String) : Predicate<Product> {
override fun test(product: Product): Boolean {
return product.name.contains(name, ignoreCase = true)
}
}
fun filterByCategoryOrName(
products: List<Product>,
query: String,
): List<Product> {
val predicate = ProductNamePredicate(query).or(ProductCategoryPredicate(query))
return products.filter { predicate.test(it) }
}
🎉 Benefits of This Approach
- Flexibility: We can add new filtering rules without modifying existing code. Each new condition is a new predicate.
- Reusability: Predicates can be reused in different contexts, greatly simplifying code maintenance.
- Clean Code: Your filtering logic becomes easier to read, extend, and maintain.
Happy coding! ⭐
Top comments (0)