Windows PowerShell 2.0 offers several ways to modularize a set of commands. Solutions range from easy to complex, depending on your needs. However, all the solutions have some common rules and traits.

First, an individual function should ideally be focused on a single task. A function should either produce no output (as might be the case if it’s taking some action), or it should produce a single kind of output (as might be the case if it’s retrieving some information). Using cmdlet-style verb-noun naming for your functions is one way to help keep them single-tasked. 

In addition, functions should create output only by using the Write-Output command. If you need a function to also log something to a file (e.g., errors), use Out-File. You can use Write-Verbose, Write-Debug, Write-Warning, and Write-Error to output such information (verbose progress, debug information, warnings, and errors, respectively). Avoid using Write-Host because its output can’t be used as flexibly as the other Write cmdlets’ output can. 

Finally, functions’ input should come entirely through parameters. A function should never refer to any variables created outside itself.

To illustrate the power of PowerShell functions, let’s consider a situation in which we want to use Windows Management Instrumentation (WMI) to retrieve OS information and BIOS serial numbers from one or more remote computers. Thus, from the Win32_OperatingSystem class, we want the Caption, ServicePackMajorVersion, and BuildNumber properties; from the Win32_BIOS class we want the SerialNumber property. We also want the output to contain the computer names.

Start with a Command

I prefer to work directly from the command line rather than in a script file, to get the main functional portion of a task out of the way first. The following two commands obtain the information we’re looking for:

Get-WmiObject -class Win32_OperatingSystem -computername Server-R2 |
__SERVER,ServicePackMajorVersion,BuildNumber,Caption
Get-WmiObject -class Win32_BIOS -computername Server-R2 | Select SerialNumber

You can add a comma-separated list of computer names to -computername to target additional computers.

A Single Kind of Output

A problem with running two commands is that you get two separate sets of output, which isn’t what we want the eventual function to do. So, we need a way to combine the desired information into a single output table.

It would be nice to call the __SERVER column ComputerName rather than just __SERVER. Using the name __SERVER is handy because __SERVER is a WMI system property, and the __SERVER command always returns a computer’s real name (regardless of what nickname or IP address you specified to reach the computer)—but __SERVER is ugly.

 As is usually the case in PowerShell, numerous methods exist for accomplishing this task. One solution is to create a new, blank object and add just the properties you want to it from Win32_OperatingSystem and Win32_BIOS. This approach is kind of a script-based solution, but the logic is pretty clear. The script in Listing 1 lets you store the WMI information in two variables (one for each WMI class), then specify the bits to tack on to a new, blank object.

Listing 1: Script to Store WMI Information in Two Variables
$os = Get-WmiObject -class Win32_OperatingSystem -computername Server-R2
$bios = Get-WmiObject -class Win32_BIOS -computername Server-R2
$obj = New-Object PSObject
$obj | Add-Member NoteProperty ComputerName ($os.__SERVER)
$obj | Add-Member NoteProperty OSVersion ($os.Caption)
$obj | Add-Member NoteProperty OSBuild ($os.BuildNumber)
$obj | Add-Member NoteProperty SPVersion ($os.ServicePackMajorVersion)
$obj | Add-Member NoteProperty BIOSSerial ($bios.SerialNumber)

An entirely different approach is to use the Select-Object command. This command creates a new custom object with whatever properties you want. You can use a special trick called a hashtable or dictionary to add a property from Win32_BIOS. This solution is a one-line command rather than a full script, but the syntax—especially the punctuation—is a bit harder to follow. The following command renames the __SERVER property to ComputerName but leaves the other property names as they are.

Get-WmiObject -class Win32_OperatingSystem -computername Server-R2 | Select @{n='ComputerName';e={$_.__SERVER}},Caption,BuildNumber,ServicePackMajorVersion,@{n='BIOSSerial';e={Get-WmiObject -class Win32_BIOS -computername $_ | Select -expand SerialNumber}}

I know it seems crazy, but it works!

For the purposes of this article, let’s use the solution presented in Listing 1. I prefer this solution because the syntax is easier to follow.

Simple and Parameterized Functions

The simplest type of function simply wraps your commands into a function construct, as the script in Listing 2 does. This script uses the Write-Output command to actually output the custom object to the pipeline. The problem with this function is that someone must still open it and edit it to change the computer name, which is a hassle.

Listing 2: Simple Function that Wraps Commands into a Function Construct
Function Get-OSInventory {
  $os = Get-WmiObject -class Win32_OperatingSystem -computername Server-R2
  $bios = Get-WmiObject -class Win32_BIOS -computername Server-R2
  $obj = New-Object PSObject
  $obj | Add-Member NoteProperty ComputerName ($os.__SERVER)
  $obj | Add-Member NoteProperty OSVersion ($os.Caption)
  $obj | Add-Member NoteProperty OSBuild ($os.BuildNumber)
  $obj | Add-Member NoteProperty SPVersion ($os.ServicePackMajorVersion)
  $obj | Add-Member NoteProperty BIOSSerial ($bios.SerialNumber)
  Write-Output $obj
}

Adding a parameter to the function lets someone else run it without having to edit it. The script in Listing 3 declares the parameter as a string, as well as provides a default value of 'localhost' in case someone forgets to provide the parameter.

Listing 3: Utilities.ps1
Function Get-OSInventory {
Param([string]$computername = 'locahost')
  $os = Get-WmiObject -class Win32_OperatingSystem -computername $computername
  $bios = Get-WmiObject -class Win32_BIOS -computername $computername
  $obj = New-Object PSObject
  $obj | Add-Member NoteProperty ComputerName ($os.__SERVER)
  $obj | Add-Member NoteProperty OSVersion ($os.Caption)
  $obj | Add-Member NoteProperty OSBuild ($os.BuildNumber)
  $obj | Add-Member NoteProperty SPVersion ($os.ServicePackMajorVersion)
  $obj | Add-Member NoteProperty BIOSSerial ($bios.SerialNumber)
  Write-Output $obj
}

The parameter gets dropped in place of the hard-coded computer name on the two Get-WmiObject commands.

Several options exist for running this script. You can use a positional parameter, like so:

Get-OSInventory Server-R2

or a named parameter, like so:

Get-OSInventory -computername Server-R2

Either way, pulling the function into the shell to use it as a command is a bit tricky. 

Converting a Function into a Command

You have two simple options for making the new function visible in the shell (plus a third option that I discuss later in the article). The first option is to copy the function into a profile script that executes automatically every time you open a new shell window. For information about using PowerShell profiles, run the command

 help about_profiles

The profile scripts you’ll use don’t exist by default; the Help file tells you what to name them and where to put them. Just copy the function straight into a profile script to run it.

Another option is to dot source the script; for example:

. c:\Scripts\Utilities.ps1

This command loads the script’s contents into the shell’s scope, making whatever’s in the script available globally throughout the shell—until you close the shell, of course. A disadvantage of this option is that you must rerun the command every time you open the shell (or put the dot-sourcing command into your profile).

Accepting Pipeline Input

The ability to send pipeline input to the function would be useful, but the function can currently accept only a positional or named parameter. Listing 4 includes a special kind of function, called a pipeline function or filtering function, that sends pipeline input to a function.

Listing 4: Pipeline Function
Function Get-OSInventory {BEGIN {}
  PROCESS {
    $computername = $_
    $os = Get-WmiObject -class Win32_OperatingSystem -computername $computername
    $bios = Get-WmiObject -class Win32_BIOS -computername $computername
    $obj = New-Object PSObject
    $obj | Add-Member NoteProperty ComputerName ($os.__SERVER)
    $obj | Add-Member NoteProperty OSVersion ($os.Caption)
    $obj | Add-Member NoteProperty OSBuild ($os.BuildNumber)
    $obj | Add-Member NoteProperty SPVersion ($os.ServicePackMajorVersion)
    $obj | Add-Member NoteProperty BIOSSerial ($bios.SerialNumber)
    Write-Output $obj   }
  END {}
}

Assuming you have a text file full of computer names, you’d run the function with one name per line, like so:

Get-Content names.txt | Get-OSInventory

When the names get piped into the function, PowerShell executes the BEGIN block first. I don’t have any commands in that block, but I could use the block to set up a database connection or something else I wanted to reuse throughout the function.

The shell executes the PROCESS block once for each name piped in. It puts each computer name from the text file into the $_ placeholder—which I recommend copying into the $computername variable, for better readability.

Finally, when all the names have made it through the PROCESS block, the END block executes. Again, I didn’t need to use the END block, but I could have closed a database connection or performed other cleanup tasks before the function finally completed. You can omit the BEGIN and END blocks if you don’t want to use them, but I like to include them to maintain consistency with my other functions.

An advantage of using a function this way is that the approach works with any technique to obtain computer names, because the act of getting the names is external to the function itself. For example:

Get-ADComputer -filter * -searchbase 'ou=West,dc=company,dc=com' } | Select -expand Name | Get-OSInventory

This command uses the Windows Server 2008 R2 Active Directory (AD) module’s Get-ADComputer command to obtain computers from the West organizational unit (OU) of the company.com domain. The Select-Object command retrieves just the contents of those computers’ Name properties, piping the computer names into the function. The benefit of the function not worrying about where the computer names came from is that you can reuse the function, without alteration, with a variety of computer name sources. Keeping a function single-tasked helps ensure that you can reuse the function in multiple places.

Having a function output custom objects to a pipeline makes the function more flexible, too. For example, suppose you want the output in a CSV file:

Get-ADComputer -filter * -searchbase 'ou=West,dc=company,dc=com' } | Select -expand Name | Get-OSInventory | Export-CSV output.csv

Or perhaps you want to filter the output so that you only get Windows XP computers running something other than SP3:

Get-ADComputer -filter * -searchbase 'ou=West,dc=company,dc=com' } | Select -expand Name | Get-OSInventory |
Where { $_.OSBuild -eq 2600 -and $_.SPVersion -ne 3 }

Keeping a function single-tasked and outputting the objects lets the function work with a variety of other PowerShell commands.

Now you can accept pipeline input. However, note that because you deleted the parameter block, you also removed the ability to use the -computername parameter. (Big sigh.) What you really need is a function that could work either way—just like a cmdlet. 

Advanced Functions: PowerShell’s Script Cmdlets

It would be nice to have a function that could support both pipeline input and the use of positional and named parameters. PowerShell 2.0 provides a solution called an advanced function (which is also called a script cmdlet, because it works similarly to a real cmdlet). Advanced functions are definitely advanced in nature.

A significant problem lies in the fact that if you pipe input into the function, the function will execute the PROCESS script block one time for each input item. Each item is then placed, one at a time, into the parameter.

Using a parameter causes the PROCESS script block to execute only once. However, the parameter contains all the input items, which you must manually enumerate through.

I like to create advanced functions as a kind of shell that’s designed to handle both of these scenarios but that doesn’t do any real work. A better solution is to move the actual work into a support function that’s designed to work with one computer at a time. The advanced function ensures that the input is broken down into one computer at a time if necessary. Listing 5 contains the code for the advanced function.

Listing 5: Advanced Function to Query OS Information
Function OSInventoryHelper {
Param([string]$computername)
  $os = Get-WmiObject -class Win32_OperatingSystem -computername $computername
  $bios = Get-WmiObject -class Win32_BIOS -computername $computername
  $obj = New-Object PSObject
  $obj | Add-Member NoteProperty ComputerName ($os.__SERVER)
  $obj | Add-Member NoteProperty OSVersion ($os.Caption)
  $obj | Add-Member NoteProperty OSBuild ($os.BuildNumber)
  $obj | Add-Member NoteProperty SPVersion ($os.ServicePackMajorVersion)
  $obj | Add-Member NoteProperty BIOSSerial ($bios.SerialNumber)
  Write-Output $obj
}
Function Get-OSInventory {
  \\[CmdletBinding()]
  Param(
    [Parameter(Mandtory=$True,ValueFromPipeline=$true)]
    [String[]]$ComputerName
  )
  BEGIN {
    $inputWasFromPipeline = -not $PSBoundParameters.ContainsKey('computername')
  }
  PROCESS {
    If ($inputWasFromPipeline) {
      OSInventoryHelper $computername
    } else {
      foreach ($computer in $computername) {
        OSInventoryHelper $computer
      }
    }
  }
}

A complete discussion of advanced functions is beyond the scope of this article. For more information about these fun constructs, run the command

help *advanced*

You’ll notice that the real work in the script in Listing 5 is done in the OSInventoryHelper function. You can hide the OSInventoryHelper function from users, forcing them to use Get-OSInventory directly. (I explain how to do so in the next section.)

The advanced function in Listing 5 can be used in multiple ways. For example:

Get-Content names.txt | Get-OSInventory | Export-CSV output.csv
Get-OSInventory -computername server-r2,server56,dc001 | Format-Table
Get-OSInventory localhost
Get-OSInventory

The last command actually prompts you for one or more computer names; press Enter on a blank line when you’re done entering computer names.

Distributing the Result

Now that we have an advanced function, we need to distribute it in such a way that other administrators can easily load it into the shell without having to use a profile script or dot sourcing. In addition, it would be nice to hide the OSInventoryHelper function.

We can accomplish both tasks by saving OSInventoryHelper and the actual Get-OSInventory functions into a file called Utilities.psm1 that includes the following additional line of code

Export-ModuleMember -function Get-OSInventory

to ensure that only Get-OSInventory is visible to other administrators. (OSInventoryHelper is visible if you open the file, but it’s hidden when you load the script into the shell.) The Utilities.psm1 script should be saved in the \My Documents\WindowsPowerShell\Modules\Utilities folder because the shell automatically searches this path for new modules.

To load Get-OSInventory into the shell as a command, run 

Import-Module Utilities

The road to this advanced function within a script module was long and full of complex syntax, but the end result is beautiful.