Working with Functions in Windows PowerShell

  • 11/11/2015
There are clear-cut guidelines that can be used to design functions. These guidelines can be used to ensure that functions are easy to understand, easy to maintain, and easy to troubleshoot. This chapter from Windows PowerShell Step by Step, 3rd Edition examines the reasons for the scripting guidelines and provides examples of both good and bad code design.

After completing this chapter, you will be able to

  • Understand functions.
  • Use functions to provide ease of reuse.
  • Use functions to encapsulate logic.
  • Use functions to provide ease of modification.

There are clear-cut guidelines that can be used to design functions. These guidelines can be used to ensure that functions are easy to understand, easy to maintain, and easy to troubleshoot. This chapter examines the reasons for the scripting guidelines and provides examples of both good and bad code design.

Understanding functions

In Windows PowerShell, functions have moved to the forefront as the primary programming element used when writing Windows PowerShell scripts. This is not necessarily due to improvements in functions per se, but rather to a combination of factors, including the maturity of Windows PowerShell script writers. In Windows PowerShell 1.0, functions were not well understood, perhaps due to the lack of clear documentation as to their use, purpose, and application.

Microsoft Visual Basic Scripting Edition (VBScript) included both subroutines and functions. According to the classic definitions, a subroutine was used to encapsulate code that would do things like write to a database or create a Microsoft Word document. Functions, on the other hand, were used to return a value. An example of a classic VBScript function is one that converts a temperature from Fahrenheit to Celsius. The function receives a value in Fahrenheit and returns the value in Celsius. The classic function always returns a value—if it does not, a subroutine should be used instead.

To create a function in Windows PowerShell, you begin with the Function keyword, followed by the name of the function. As a best practice, use the Windows PowerShell verb-noun combination when creating functions. Pick the verb from the standard list of Windows PowerShell verbs to make your functions easier to remember. It is a best practice to avoid creating new verbs when there is an existing verb that can easily do the job.

An idea of the verb coverage can be obtained by using the Get-Command cmdlet and pipelining the results to the Group-Object cmdlet. This is shown here.

Get-Command -CommandType cmdlet | Group-Object -Property Verb |
Sort-Object -Property count -Descending

When the preceding command is run, the resulting output is as follows. This command was run on Windows 10 and includes cmdlets from the default modules. As shown in the listing, Get is used the most by the default cmdlets, followed distantly by Set, New, and Remove.

Count Name                      Group
----- ----                      -----
  107 Get                       {Get-Acl, Get-Alias, Get-AppLockerFileInformation...
   49 Set                       {Set-Acl, Set-Alias, Set-AppBackgroundTaskResourc...
   37 New                       {New-Alias, New-AppLockerPolicy, New-CertificateN...
   29 Remove                    {Remove-AppxPackage, Remove-AppxProvisionedPackag...
   17 Add                       {Add-AppxPackage, Add-AppxProvisionedPackage, Add...
   15 Export                    {Export-Alias, Export-BinaryMiLog, Export-Certifi...
   14 Disable                   {Disable-AppBackgroundTaskDiagnosticLog, Disable-...
   14 Enable                    {Enable-AppBackgroundTaskDiagnosticLog, Enable-Co...
   12 Import                    {Import-Alias, Import-BinaryMiLog, Import-Certifi...
   11 Invoke                    {Invoke-CimMethod, Invoke-Command, Invoke-DscReso...
   10 Clear                     {Clear-Content, Clear-EventLog, Clear-History, Cl...
   10 Test                      {Test-AppLockerPolicy, Test-Certificate, Test-Com...
    9 Write                     {Write-Debug, Write-Error, Write-EventLog, Write-...
    9 Start                     {Start-BitsTransfer, Start-DscConfiguration, Star...
    8 Register                  {Register-ArgumentCompleter, Register-CimIndicati...
    7 Out                       {Out-Default, Out-File, Out-GridView, Out-Host...}
    6 Stop                      {Stop-Computer, Stop-DtcDiagnosticResourceManager...
    6 ConvertTo                 {ConvertTo-Csv, ConvertTo-Html, ConvertTo-Json, C...
    5 Update                    {Update-FormatData, Update-Help, Update-List, Upd...
    5 Format                    {Format-Custom, Format-List, Format-SecureBootUEF...
    5 ConvertFrom               {ConvertFrom-Csv, ConvertFrom-Json, ConvertFrom-S...
    4 Wait                      {Wait-Debugger, Wait-Event, Wait-Job, Wait-Process}
    4 Unregister                {Unregister-Event, Unregister-PackageSource, Unre...
    3 Rename                    {Rename-Computer, Rename-Item, Rename-ItemProperty}
    3 Receive                   {Receive-DtcDiagnosticTransaction, Receive-Job, R...
    3 Move                      {Move-AppxPackage, Move-Item, Move-ItemProperty}
    3 Suspend                   {Suspend-BitsTransfer, Suspend-Job, Suspend-Service}
    3 Show                      {Show-Command, Show-ControlPanelItem, Show-EventLog}
    3 Debug                     {Debug-Job, Debug-Process, Debug-Runspace}
    3 Complete                  {Complete-BitsTransfer, Complete-DtcDiagnosticTra...
    3 Select                    {Select-Object, Select-String, Select-Xml}
    3 Resume                    {Resume-BitsTransfer, Resume-Job, Resume-Service}
    3 Save                      {Save-Help, Save-Package, Save-WindowsImage}
    2 Unblock                   {Unblock-File, Unblock-Tpm}
    2 Split                     {Split-Path, Split-WindowsImage}
    2 Undo                      {Undo-DtcDiagnosticTransaction, Undo-Transaction}
    2 Restart                   {Restart-Computer, Restart-Service}
    2 Resolve                   {Resolve-DnsName, Resolve-Path}
    2 Send                      {Send-DtcDiagnosticTransaction, Send-MailMessage}
    2 Convert                   {Convert-Path, Convert-String}
    2 Use                       {Use-Transaction, Use-WindowsUnattend}
    2 Disconnect                {Disconnect-PSSession, Disconnect-WSMan}
    2 Join                      {Join-DtcDiagnosticResourceManager, Join-Path}
    2 Exit                      {Exit-PSHostProcess, Exit-PSSession}
    2 Enter                     {Enter-PSHostProcess, Enter-PSSession}
    2 Copy                      {Copy-Item, Copy-ItemProperty}
    2 Expand                    {Expand-WindowsCustomDataImage, Expand-WindowsImage}
    2 Measure                   {Measure-Command, Measure-Object}
    2 Connect                   {Connect-PSSession, Connect-WSMan}
    2 Mount                     {Mount-AppxVolume, Mount-WindowsImage}
    2 Dismount                  {Dismount-AppxVolume, Dismount-WindowsImage}
    1 Pop                       {Pop-Location}
    1 Trace                     {Trace-Command}
    1 Uninstall                 {Uninstall-Package}
    1 Checkpoint                {Checkpoint-Computer}
    1 Tee                       {Tee-Object}
    1 Unprotect                 {Unprotect-CmsMessage}
    1 Where                     {Where-Object}
    1 Switch                    {Switch-Certificate}
    1 Compare                   {Compare-Object}
    1 Limit                     {Limit-EventLog}
    1 Install                   {Install-Package}
    1 Protect                   {Protect-CmsMessage}
    1 Optimize                  {Optimize-WindowsImage}
    1 ForEach                   {ForEach-Object}
    1 Find                      {Find-Package}
    1 Initialize                {Initialize-Tpm}
    1 Group                     {Group-Object}
    1 Reset                     {Reset-ComputerMachinePassword}
    1 Repair                    {Repair-WindowsImage}
    1 Sort                      {Sort-Object}
    1 Restore                   {Restore-Computer}
    1 Push                      {Push-Location}
    1 Publish                   {Publish-DscConfiguration}
    1 Confirm                   {Confirm-SecureBootUEFI}
    1 Read                      {Read-Host}

A function is not required to accept any parameters. In fact, many functions do not require input to perform their job in the script. Let’s use an example to illustrate this point. A common task for network administrators is obtaining the operating system version. Script writers often need to do this to ensure that their script uses the correct interface or exits gracefully. It is also quite common that one set of files would be copied to a desktop running one version of the operating system, and a different set of files would be copied for another version of the operating system. The first step in creating a function is to come up with a name. Because the function is going to retrieve information, in the listing of cmdlet verbs shown earlier, the best verb to use is Get. For the noun portion of the name, it is best to use something that describes the information that will be obtained. In this example, a noun of OperatingSystemVersion makes sense. An example of such a function is shown in the Get-OperatingSystemVersion.ps1 script. The Get-OperatingSystemVersion function uses Windows Management Instrumentation (WMI) to obtain the version of the operating system. In this basic form of the function, you have the function keyword followed by the name of the function, and a script block with code in it, which is delimited by braces. This pattern is shown here.

Function Function-Name
{
 #insert code here
}

In the Get-OperatingSystemVersion.ps1 script, the Get-OperatingSystemVersion function is at the top of the script. It uses the Function keyword to define the function, followed by the name, Get-OperatingSystemVersion. The script block opens, followed by the code, and then the script block closes. The function uses the Get-CimInstance cmdlet to retrieve an instance of the Win32_OperatingSystem WMI class. Because this WMI class only returns a single instance, the properties of the class are directly accessible. The version property is the one you’ll work with, so use parentheses to force the evaluation of the code inside. The returned management object is used to emit the version value. The braces are used to close the script block. The operating system version is returned to the code that calls the function. In this example, a string that writes This OS is version is used. A subexpression is used to force evaluation of the function. The version of the operating system is returned to the place where the function was called. This is shown here.

Get-OperatingSystemVersion.ps1

Function Get-OperatingSystemVersion
{
 (Get-CimInstance -Class Win32_OperatingSystem).Version
} #end Get-OperatingSystemVersion

"This OS is version $(Get-OperatingSystemVersion)"

Now let’s look at choosing the cmdlet verb. In the earlier listing of cmdlet verbs, there is one cmdlet that uses the verb Read. It is the Read-Host cmdlet, which is used to obtain information from the command line. This would indicate that the verb Read is not used to describe reading a file. There is no verb called Display, and the Write verb is used in cmdlet names such as Write-Error and Write-Debug, both of which do not really seem to have the concept of displaying information. If you were writing a function that would read the content of a text file and display statistics about that file, you might call the function Get-TextStatistics. This is in keeping with cmdlet names such as Get-Process and Get-Service, which include the concept of emitting their retrieved content within their essential functionality. The Get-TextStatistics function accepts a single parameter called path. The interesting thing about parameters for functions is that when you pass a value to the parameter, you use a hyphen. When you refer to the value inside the function, it is a variable such as $path. To call the Get-TextStatistics function, you have a couple of options. The first is to use the name of the function and put the value inside parentheses. This is shown here.

Get-TextStatistics("C:\fso\mytext.txt")

This is a natural way to call the function, and it works when there is a single parameter. It does not work when there are two or more parameters. Another way to pass a value to the function is to use the hyphen and the parameter name. This is shown here.

Get-TextStatistics -path "C:\fso\mytext.txt"

Note from the previous example that no parentheses are required. You can also use positional arguments when passing a value. In this usage, you omit the name of the parameter entirely and simply place the value for the parameter following the call to the function. This is illustrated here.

Get-TextStatistics "C:\fso\mytext.txt"

One additional way to pass a value to a function is to use partial parameter names. All that is required is enough of the parameter name to disambiguate it from other parameters. This is illustrated here.

Get-TextStatistics -p "C:\fso\mytext.txt"

The complete text of the Get-TextStatistics function is shown here.

Get-TextStatistics Function

Function Get-TextStatistics($path)
{
 Get-Content -path $path |
 Measure-Object -line -character -word
}

Between Windows PowerShell 1.0 and Windows PowerShell 2.0, the number of verbs grew from 40 to 60. In Windows PowerShell 5.0, the number of verbs remained consistent at 98. The list of approved verbs is shown here.

Add          Clear       Close       Copy        Enter       Exit        Find
Format       Get         Hide        Join        Lock        Move        New
Open         Optimize    Pop         Push        Redo        Remove      Rename
Reset        Resize      Search      Select      Set         Show        Skip
Split        Step        Switch      Undo        Unlock      Watch       Backup
Checkpoint   Compare     Compress    Convert     ConvertFrom ConvertTo   Dismount
Edit         Expand      Export      Group       Import      Initialize  Limit
Merge        Mount       Out         Publish     Restore     Save        Sync
Unpublish    Update      Approve     Assert      Complete    Confirm     Deny
Disable      Enable      Install     Invoke      Register    Request     Restart
Resume       Start       Stop        Submit      Suspend     Uninstall   Unregister
Wait         Debug       Measure     Ping        Repair      Resolve     Test
Trace        Connect     Disconnect  Read        Receive     Send        Write
Block        Grant       Protect     Revoke      Unblock     Unprotect   Use

After the function has been named, you should specify any parameters the function might require. The parameters are contained within parentheses. In the Get-TextStatistics function, the function accepts a single parameter: -path. When you have a function that accepts a single parameter, you can pass the value to the function by placing the value for the parameter inside parentheses. This is known as calling a function like a method, and is disallowed when you use Set-StrictMode with the Latest value for the -Version parameter. The following command generates an error when the latest strict mode is in effect—otherwise, it is a permissible way to call a function.

Get-TextLength("C:\fso\test.txt")

The path C:\fso\test.txt is passed to the Get-TextStatistics function via the -path parameter. Inside the function, the string C:\fso\text.txt is contained in the $path variable. The $path variable lives only within the confines of the Get-TextStatistics function. It is not available outside the scope of the function. It is available from within child scopes of the Get-TextStatistics function. A child scope of Get-TextStatistics is one that is created from within the Get-TextStatistics function. In the Get-Text-StatisticsCallChildFunction.ps1 script, the Write-Path function is called from within the Get-Text-Statistics function. This means the Write-Path function will have access to variables that are created within the Get-TextStatistics function. This is the concept of variable scope, which is extremely important when working with functions. As you use functions to separate the creation of objects, you must always be aware of where the objects get created, and where you intend to use them. In the Get-TextStatisticsCallChildFunction, the $path variable does not obtain its value until it is passed to the function. It therefore lives within the Get-TextStatistics function. But because the Write-Path function is called from within the Get-TextStatistics function, it inherits the variables from that scope. When you call a function from within another function, variables created within the parent function are available to the child function. This is shown in the Get-TextStatisticsCallChildFunction.ps1 script, which follows.

Get-TextStatisticsCallChildFunction.ps1

Function Get-TextStatistics($path)
{
 Get-Content -path $path |
 Measure-Object -line -character -word
 Write-Path
}

Function Write-Path()
{
 "Inside Write-Path the `$path variable is equal to $path"
}

Get-TextStatistics("C:\fso\test.txt")
"Outside the Get-TextStatistics function `$path is equal to $path"

Inside the Get-TextStatistics function, the $path variable is used to provide the path to the Get-Content cmdlet. When the Write-Path function is called, nothing is passed to it. But inside the Write-Path function, the value of $path is maintained. Outside both of the functions, however, $path does not have any value. The output from running the script is shown here.

              Lines               Words          Characters Property
              -----               -----          ---------- --------
                  3                  41                 210
Inside Write-Path the $path variable is equal to C:\fso\test.txt
Outside the Get-TextStatistics function $path is equal to

You will then need to open and close a script block. A pair of opening and closing braces is used to delimit the script block on a function. As a best practice, when writing a function, I will always use the Function keyword, and type in the name, the input parameters, and the braces for the script block at the same time. This is shown here.

Function My-Function
{
 #insert code here
}

In this manner, I make sure I do not forget to close the braces. Trying to identify a missing brace within a long script can be somewhat problematic, because the error that is presented does not always correspond to the line that is missing the brace. For example, suppose the closing brace is left off the Get-TextStatistics function, as shown in the Get-TextStatisticsCallChildFunction-DoesNOTWork-MissingClosingBrace.ps1 script. An error will be generated, as shown here.

Missing closing '}' in statement block.
At C:\Scripts\Get-TextStatisticsCallChildFunction-DoesNOTWork-MissingClosingBracket.ps1:28
char:1

The problem is that the position indicator of the error message points to the first character on line 28. Line 28 happens to be the first blank line after the end of the script. This means that Windows PowerShell scanned the entire script looking for the closing brace. Because it did not find it, it states that the error is at the end of the script. If you were to place a closing brace on line 28, the error in this example would go away, but the script would not work. The Get-TextStatisticsCallChildFunction-DoesNOTWork-MissingClosingBracket.ps1 script is shown here, with a comment that indicates where the missing closing brace should be placed.

Get-TextStatisticsCallChildFunction-DoesNOTWork-MissingClosingBrace.ps1

Function Get-TextStatistics($path)
{
 Get-Content -path $path |
 Measure-Object -line -character -word
 Write-Path
# Here is where the missing brace goes

Function Write-Path()
{
 "Inside Write-Path the `$path variable is equal to $path"
}
Get-TextStatistics("C:\fso\test.txt")
Write-Host "Outside the Get-TextStatistics function `$path is equal to $path"

One other technique to guard against the problem of the missing brace is to add a comment to the closing brace of each function.