I drove up the third part of a series of articles on functional programming. Today we will talk about all types of this paradigm and show them using examples. Learn more about primitive types, generic types, and much more under the cut!
Now that we have some understanding of the functions, we will see how types interact with functions such as domain and range. This article is just a review. For deeper immersion in types there is a series of "understanding F # types" .
First we need to understand the type notation a little better. We have seen the " ->
" arrow notation separating domain and range. So the function signature always looks like this:
val functionName : domain -> range
Some more examples of functions:
let intToString x = sprintf "x is %i" x // int string let stringToInt x = System.Int32.Parse(x)
If you execute this code in an interactive window , you can see the following signatures:
val intToString : int -> string val stringToInt : string -> int
They mean:
intToString
has a domain of type int
, which is mapped to a range of type string
.stringToInt
has a domain of type string
, which maps to the range of type int
.There are expected primitive types: string, int, float, bool, char, byte, etc., as well as many other derivatives of the .NET type system.
A couple more examples of functions with primitive types:
let intToFloat x = float x // "float" - int float let intToBool x = (x = 2) // true x 2 let stringToString x = x + " world"
and their signatures:
val intToFloat : int -> float val intToBool : int -> bool val stringToString : string -> string
In the previous examples, the F # compiler correctly defined the types of parameters and results. But this is not always the case. If you try to execute the following code, you will get a compilation error:
let stringLength x = x.Length => error FS0072: Lookup on object of indeterminate type
The compiler does not know the type of the argument "x", and because of this does not know whether the "Length" is a valid method. In most cases, this can be fixed by passing an "type annotation" to the F # compiler. Then he will know which type to use. In the revised version, we indicate that the type "x" is a string.
let stringLength (x:string) = x.Length
The brackets around the x:string
parameter are important. If they are skipped, the compiler will decide that the string is the return value! That is, an "open" colon is used to indicate the type of the return value, as shown in the following example.
let stringLengthAsInt (x:string) :int = x.Length
We indicate that the x
parameter is a string, and the return value is an integer.
A function that accepts other functions as parameters or returns a function is called a higher order function (the higher-order function is sometimes abbreviated to HOF). They are used as an abstraction for setting as general a behavior as possible. This type of function is very common in F #, most standard libraries use them.
Consider the evalWith5ThenAdd2
function, which takes a function as a parameter, and then calculates this function from 5 and adds 2 to the result:
let evalWith5ThenAdd2 fn = fn 5 + 2 // , fn(5) + 2
The signature of this function is as follows:
val evalWith5ThenAdd2 : (int -> int) -> int
You can see that domain is (int->int)
, and range is int
. What does it mean? This means that the input parameter is not a simple value, but a function from the set of functions from int
to int
. The output value is not a function, but simply an int
.
Let's try:
let add1 x = x + 1 // - (int -> int) evalWith5ThenAdd2 add1 //
and get:
val add1 : int -> int val it : int = 8
" add1
" is a function that maps an int
to an int
, as we can see from the signature. It is a valid evalWith5ThenAdd2
parameter, and its result is 8.
By the way, the special word " it
" is used to denote the last calculated value, in this case it is the result we were waiting for. This is not a keyword, it is just a naming convention.
Another case:
let times3 x = x * 3 // - (int -> int) evalWith5ThenAdd2 times3 //
gives:
val times3 : int -> int val it : int = 17
" times3
" is also a function that maps an int
to an int
, as seen from the signature. It is also a valid parameter for evalWith5ThenAdd2
. The result of the calculation is 17.
Note that the input data is type sensitive. If the function being passed uses float
, not int
, then nothing happens. For example, if we have:
let times3float x = x * 3.0 // - (float->float) evalWith5ThenAdd2 times3float
The compiler, when trying to compile, will return an error:
error FS0001: Type mismatch. Expecting a int -> int but given a float -> float
indicating that the input function must be a function of type int->int
.
Value functions can also be the result of functions. For example, the following function will generate an "adder" function that will add an input value.
let adderGenerator numberToAdd = (+) numberToAdd
Its signature is:
val adderGenerator : int -> (int -> int)
means that the generator accepts an int
and creates a function ("adder") that matches the ints
in ints
. Let's see how it works:
let add1 = adderGenerator 1 let add2 = adderGenerator 2
Two adder functions are created. The first creates a function that adds to input 1, the second adds 2. Note that the signatures are exactly what we expected.
val add1 : (int -> int) val add2 : (int -> int)
Now you can use the generated functions as usual, they are no different from functions defined explicitly:
add1 5 // val it : int = 6 add2 5 // val it : int = 7
In the first example, we looked at the function:
let evalWith5ThenAdd2 fn = fn 5 +2 > val evalWith5ThenAdd2 : (int -> int) -> int
In this example, F # can infer that " fn
" converts an int
to an int
, so its signature will be int->int
.
But what is the signature "fn" in the following case?
let evalWith5 fn = fn 5
It is clear that " fn
" is a kind of function that accepts an int
, but what does it return? The compiler cannot answer this question. In such cases, if it is necessary to specify the type of the function, you can add an annotation type for the parameters of the functions, as well as for primitive types.
let evalWith5AsInt (fn:int->int) = fn 5 let evalWith5AsFloat (fn:int->float) = fn 5
You can also define a return type.
let evalWith5AsString fn :string = fn 5
Since the main function returns a string
, the function " fn
" is also forced to return a string
. Thus, it is not required to explicitly specify the type " fn
".
During the programming process, we sometimes want the function to do something without returning anything. Consider the " printInt
" function. The function does not return anything. It simply prints the string to the console as a side effect of the execution.
let printInt x = printf "x is %i" x //
What is its signature?
val printInt : int -> unit
What is a " unit
"?
Even if the function does not return values, it still needs a range. In the world of mathematics there are no "void" functions. Each function must return something, because the function is a display, and the display must display something!
So, in F #, functions like this return a special type of result called " unit
". It contains only one value, denoted by " ()
". You might think that unit
and ()
are something like "void" and "null" from C #, respectively. But unlike them, unit
is a real type, and ()
real value. To verify this, it suffices to perform:
let whatIsThis = ()
the following signature will be obtained:
val whatIsThis : unit = ()
Which indicates that the " whatIsThis
" label is of type unit
and is associated with the value ()
.
Now, returning to the " printInt
" signature, you can understand the meaning of this entry:
val printInt : int -> unit
This signature says that printInt
has a domain of int
, which is converted to something that does not interest us.
Now that we understand a unit
, can we predict its appearance in a different context? For example, try to create a reusable function "hello world". Since there is no input or output, we can expect the signature unit -> unit
. We'll see:
let printHello = printf "hello world" //
Result:
hello world val printHello : unit = ()
Not exactly what we expected. "Hello world" was displayed immediately, and the result was not a function, but a simple value of type unit. We can say that this is a simple value, because, as we saw earlier, it has a signature of the form:
val aName: type = constant
In this example, we see that printHello
really a simple value ()
. This is not a function that we can call later.
What is the difference between printInt
and printHello
? In the case of printInt
value cannot be determined until we know the value of the parameter x
, so the definition was a function. In the case of printHello
there are no parameters, so the right side can be defined in place. And it was equal to ()
with a side effect in the form of console output.
You can create a real reusable function without parameters, forcing the definition to have a unit
argument to the argument:
let printHelloFn () = printf "hello world" //
Now its signature is:
val printHelloFn : unit -> unit
and to call it, we must pass ()
as a parameter:
printHelloFn ()
In some cases, the compiler requires the unit
type and complains. For example, both of the following cases will cause a compiler error:
do 1+1 // => FS0020: This expression should have type 'unit' let something = 2+2 // => FS0020: This expression should have type 'unit' "hello"
To help in these situations, there is a special function ignore
, which accepts anything and returns a unit
. The correct version of this code could be:
do (1+1 |> ignore) // ok let something = 2+2 |> ignore // ok "hello"
In most cases, if the type of the function parameter can be any type, we need to say something about it. F # uses generics from .NET for such situations.
For example, the following function converts a parameter to a string by adding some text:
let onAStick x = x.ToString() + " on a stick"
No matter what type of parameter, all objects are able to ToString()
.
Signature:
val onAStick : 'a -> string
What kind of 'a
? In F #, this is a method of indicating a generic type that is unknown at the time of compilation. An apostrophe before "a" means that the type is generic. Equivalent to this signature in C #:
string onAStick<a>(); // string OnAStick<TObject>(); // F#- 'a // C#'- "TObject"
It should be understood that this F # function still has strong typing, even with generic types. It does not accept an Object
parameter. Strong typing is good because it allows you to preserve their type safety when composing functions.
The same function is used for int
, float
and string
.
onAStick 22 onAStick 3.14159 onAStick "hello"
If there are two generalized parameters, the compiler will give them two different names: 'a
for the first, 'b
for the second, and so on. For example:
let concatString xy = x.ToString() + y.ToString()
In this signature there will be two generalized types: 'a
and 'b
:
val concatString : 'a -> 'b -> string
On the other hand, the compiler recognizes when only one universal type is required. In the following example, x
and y
must be of the same type:
let isEqual xy = (x=y)
So, the function signature has the same generalized type for both parameters:
val isEqual : 'a -> 'a -> bool
Generalized parameters are also very important when it comes to lists and other abstract structures, and we will see quite a lot of them in the following examples.
So far, only basic types have been discussed. These types can be combined in various ways into more complex types. A complete analysis of them will be later in another series , but in the meantime, and here we briefly analyze them, so that they can be recognized in function signatures.
("hello", 1)
is a tuple made on the basis of string
and int
. A comma is the hallmark of a tuple; if a comma is seen somewhere in F #, it is almost guaranteed part of a tuple. string * int // ("hello", 1)
IEnumrable
). In function signatures, they have their own keywords: " list
", " seq
", and " []
" for arrays. int list // List type [1;2;3] string list // List type ["a";"b";"c"] seq<int> // Seq type seq{1..10} int [] // Array type [|1;2;3|]
Some
(when the value exists) and None
(when there is no value). In function signatures, they have their own " option
" keyword: int option // Some 1
Here are a few expressions to test your understanding of function signatures. To check, just run them in the interactive window!
let testA = float 2 let testB x = float 2 let testC x = float 2 + x let testD x = x.ToString().Length let testE (x:float) = x.ToString().Length let testF x = printfn "%s" x let testG x = printfn "%f" x let testH = 2 * 2 |> ignore let testI x = 2 * 2 |> ignore let testJ (x:int) = 2 * 2 |> ignore let testK = "hello" let testL() = "hello" let testM x = x=x let testN x = x 1 // : x? let testO x:string = x 1 // : :string ?
For F #, there are many tutorials, including materials for those who come with C # or Java experience. The following links may be helpful as you learn more about F #:
Several other ways to get started with learning F # are also described.
Finally, the F # community is very friendly to beginners. There is a very active Slack chat, supported by the F # Software Foundation, with rooms for beginners that you can freely join . We strongly recommend that you do this!
Do not forget to visit the site of the Russian-speaking community F # ! If you have any questions about learning the language, we will be happy to discuss them in chat rooms:
#ru_general
in Slack chat F # Software FoundationTranslated by @kleidemos Translation and editorial changes are made by the efforts of the Russian-speaking community of F # -developers . We also thank @schvepsss and @shwars for preparing this article for publication.
Source: https://habr.com/ru/post/422115/
All Articles