Skip to main content
Version: 1.14

Generic functions

Creating generic functions

Let's create get function. We will start by writing this function in Scala.

def head[T](list: java.util.List[T]): T =
list.get(0)

When we try to compile this we will see that it has type List[Unknown] -> Unknown. In order to have better typing information we need to create new subclass of TypingFunction, that will calculate type of head's result, and attach it to the function.

@GenericType(typingFunction = classOf[HeadGenericFunction])
def head[T](list: java.util.List[T]): T =
list.get(0)

private class HeadGenericFunction extends TypingFunction {
override def computeResultType(arguments: List[TypingResult]):
ValidatedNel[GenericFunctionTypingError, TypingResult] = ???
}

Now we need to implement computeResultType function that takes list of argument types and return type of the result or some errors. Our function gets exactly one parameter - list of elements of type T and returns type T otherwise we will return validation error that signals we expected other arguments.

private val listClass = classOf[java.util.List[_]]

override def computeResultType(arguments: List[TypingResult]):
ValidatedNel[GenericFunctionTypingError, TypingResult] = {
arguments match {
case TypedClass(`listClass`, t :: Nil) :: Nil => t.validNel
case _ => ArgumentTypeError.invalidNel
}
}

Now head function is ready, and we can run nussknacker and see that head(List[Int]) = Int or head(List[String]) = String.

Specifying parameters

Sometimes we want to use parameters that are more specific than what Scala or Java can offer. In such cases we need to manually specify types of the parameters and the result. We will write a custom plus that will work with integers, floats and strings.

We will start implementing the function itself and declaring typing function for it.

@GenericType(typingFunction = classOf[PlusGenericFunction])
def plus(left: Any, right: Any): Any = (left, right) match {
case (left: Int, right: Int) => left + right
case (left: Double, right: Double) => left + right
case (left: String, right: String) => left + right
case _ => throw new AssertionError("should not be reached")
}

private class PlusGenericFunction() extends TypingFunction {
???
}

Next we add function that calculates type of result. It will check number of parameters and if there are exactly two then it will remove their values and match them with supported types.

private val numberType = Typed.typedClass[Number]
private val intType = Typed.typedClass[Int]
private val doubleType = Typed.typedClass[Double]
private val stringType = Typed.typedClass[String]

override def computeResultType(arguments: List[TypingResult]):
ValidatedNel[GenericFunctionTypingError, TypingResult] = arguments match {
case left :: right :: Nil =>
(left.withoutValue, right.withoutValue) match {
case (`intType`, `intType`) =>
intType.validNel
case (`doubleType`, `doubleType`) =>
doubleType.validNel
case (l, r) if List(l, r).forall(_.canBeSubclassOf(numberType)) =>
OtherError(s"Addition of ${l.display} and ${r.display} is not supported").invalidNel
case (`stringType`, `stringType`) =>
stringType.validNel
case _ =>
ArgumentTypeError.invalidNel
}
case _ => ArgumentTypeError.invalidNel
}

This function will work as long as we provide it with correct arguments, but when we try to use it with types int and string we get error "expected (Object, Object), found (Int, String)", which doesn't look like a real error. To fix it we have to specify what types this function can accept. We will add two possible signatures (Number, Number) -> Number and (String, String) -> String.

override def signatures: Option[NonEmptyList[MethodTypeInfo]] = 
Some(NonEmptyList.of(
MethodTypeInfo.withoutVarargs(
Parameter("left", numberType) :: Parameter("right", numberType) :: Nil,
numberType
),
MethodTypeInfo.withoutVarargs(
Parameter("left", stringType) :: Parameter("right", stringType) :: Nil,
stringType
)
))

Now when we try to use this function with string and int we will get much more reasonable error saying that two numbers or two strings were expected.

Working with objects

Now let's create a function that processes typed objects instead of simpler types. We will make a function that takes two objects and merges them, creating one object with fields from both arguments. Again, let's start with implementation. Here it's worth noting that objects are represented as maps with string keys, so our function needs to accept this type.

@GenericType(typingFunction = classOf[UnionGenericFunction])
def union(x: java.util.Map[String, Any], y: java.util.Map[String, Any]):
java.util.Map[String, Any] = {
val res = new java.util.HashMap[String, Any](x)
res.putAll(y)
res
}

private class UnionGenericFunction extends TypingFunction {
???
}

Then we create typing function. It should check if it got two typed objects as arguments and if it did, then it checks if they have common file and either returns an error or calculates their union.

override def computeResultType(arguments: List[TypingResult]): 
ValidatedNel[GenericFunctionTypingError, TypingResult] = arguments match {
case TypedObjectTypingResult(x, _, infoX) :: TypedObjectTypingResult(y, _, infoY) :: Nil =>
if (x.keys.exists(y.keys.toSet.contains))
OtherError("Argument maps have common field").invalidNel
else
TypedObjectTypingResult(x ++ y, Typed.typedClass[java.util.HashMap[_, _]], infoX ++ infoY).validNel
case _ =>
ArgumentTypeError.invalidNel
}

Using static arguments

Let's continue working with typed objects. Now we will create function that takes an object, name of a field and returns values of this field.

Here we make use of types that hold their value. We need to know the value of second argument during compilation so that we can calculate type of result, therefore we match second argument with TypedObjectWithValue and return error when it is a simple string.

@GenericType(typingFunction = classOf[GetFieldGenericFunction])
def getField(obj: java.util.Map[String, Any], name: String): Any =
obj.get(name)

private class GetFieldGenericFunction extends TypingFunction {
private val stringType = Typed.typedClass[String]

override def computeResultType(arguments: List[TypingResult]):
ValidatedNel[GenericFunctionTypingError, TypingResult] = arguments match {
case TypedObjectTypingResult(fields, _, _) :: TypedObjectWithValue(`stringType`, name: String) :: Nil =>
fields.get(name) match {
case Some(v) => v.validNel
case None => OtherError("No field with given name").invalidNel
}
case TypedObjectTypingResult(_, _, _) :: x :: Nil if x.canBeSubclassOf(stringType) =>
OtherError("Expected string with known value").invalidNel
case _ =>
ArgumentTypeError.invalidNel
}
}

Using varargs

Function with varargs can be used just like regular functions. While in many cases they do not need typing function to work properly, it sometimes can still be useful. Let's create a function that takes multiple varargs and returns list of all of them.

@GenericType(typingFunction = classOf[ToListGenericFunction])
@varargs
def toList[T](elems: T*): java.util.List[T] =
elems.asJava

private class ToListGenericFunction extends TypingFunction {
override def computeResultType(arguments: List[TypingResult]):
ValidatedNel[GenericFunctionTypingError, TypingResult] = {
val supertypeFinder = new CommonSupertypeFinder(SupertypeClassResolutionStrategy.AnySuperclass, true)
val commonSupertype = arguments
.reduceOption(supertypeFinder.commonSupertype(_, _)(NumberTypesPromotionStrategy.ToSupertype))
.getOrElse(Unknown)
Typed.genericTypeClass[java.util.List[_]](commonSupertype :: Nil).validNel
}
}

Here we use supertypeFinder to get best type for elements of result list. We specify supertype class resolution strategy AnySuperclass and number types promotion strategy ToSupertype as they are the closest to expected behaviour of types in list. In other circumstances we could freely use any other strategy.