Всё изложенное ниже основано на моём личном опыте, не претендует на абсолютную истину и относится к PowerShell версии 5.1. Описанные трудности могут быть следствием моей собственной некомпетентности в PowerShell. Материал ориентирован на разработчиков, привыкших к более классическим языкам программирования

В этой статье я собрал непривычные, нелогичные и местами раздражающие особенности PowerShell, с которыми столкнулся в реальных проектах.

Начну со своего "любимого" поведения в PowerShell.

В Powershell оператор return не то, к чему мы привыкли

Сможете определить, какой результат будет выведен при запуске следующего кода?

 function Test-Func {
     $arrayList = [System.Collections.ArrayList]::new()
     $arraylist.Add("New item")

     return $false
 }

 $result = Test-Func
 if ($result) {Write-Host "Result is True"}
 else {Write-Host "Result is False"}

Мне, как человеку, не особо знакомому с особенностями Powershell, все было предельно очевидно: функция Test-Func в явном виде всегда возвращает $false, а значит результат будет Result is False. Представьте мое удивление, когда я, столкнувшись с аналогичным кодом, получил Result is True.

А дело вот в чем. Как я уже сказал, Powershell прежде всего шелл, и только потом язык программирования. И в нашем случае работают 2 фактора:

  1. Если результат вызываемой функции (или метода) не присвоен переменной, то этот результат выводится в конвейер (output stream)

  2. Функция возвращает не только то, что указано в return, но и все, что было выведено в output stream во время выполнения этой функции.

В нашей функции Test-Func вызывается метод $arraylist.Add("New item"), который добавляет в массив новый элемент, а также возвращает индекс этого нового элемента — 0. Так как возвращаемый результат не присвоен переменной, он попадает в output stream. Таким образом, наша функция Test-Func возвращает массив, состоящий из двух элементов 0 и $false, а не ожидаемый нами $false.

Чтобы исправить нашу функцию, придется подавить вывод результата вызова метода $arraylist.Add("New item"), например так (рекомендуемый способ):

$null = $arraylist.Add("New item")

или так (но медленнее)

[void]$arraylist.Add("New item")

или можно так (еще медленнее, особенно в PS 5.1)

$arraylist.Add("New item") | Out-Null

Powershell может разворачивать массивы, состоящие из 1 элемента

Вот небольшой пример кода

function Get-Many {
    $array = @("A", "B", "C")
    return $array
}

function Get-Single {
    $array = @("A")
    return $array
}

$many = Get-Many
$single = Get-Single

$many.GetType().Name
$single.GetType().Name

Угадаете, какие типы переменных $many и $single выведет данный код?

Вроде обе функции Get-Many и Get-Single возвращают массивы, и мы можем ожидать, что результат будет одинаковым для обеих переменных:

Object[]
Object[]

но на самом деле результат будет:

Object[]
String

Здорово правда? Не спросив нас PowerShell "упростил" нам жизнь. А ведь мы скорее всего захотим передать массив в цикл и конечно же получим ошибку, если вместо ожидаемого массива получим String.

Почему так получилось? Потому что PowerShell — язык, построенный вокруг конвейеров (pipeline) и в него заложена логика удобной работы с конвейерами и выводом данных, чтобы админы могли быстро "набросать" скрипт.

Но как же быть нам, людям которые привыкли, что результатом функции должно быть именно то, что она возвращает, без всякой магии? Одно из решений - использовать общеизвестный в мире PowerShell костыль в виде запятой ,, которая по сути оборачивает любое значение в массив.

Если мы перепишем наш код следующим образом, то все будет работать, как мы того и ожидаем.

function Get-Many {
    $array = @("A", "B", "C")
    return , $array
}

function Get-Single {
    $array = @("A")
    return , $array
}

$many = Get-Many
$single = Get-Single

$many.GetType().Name
$single.GetType().Name

Обратите внимание, на запятую в строке return , $array, именно в ней вся магия. Теперь результат будет, как мы и ожидаем:

Object[]
Object[]

Автоматический Scope в PowerShell — скрытая причина ошибок

Рассмотрим следующий пример:

$array = @("A", "B", "C")
Write-Host "Old items count: $($array.Count)"

function Add-Item {
    param($item)
    
    if ($array) {
        Write-Host "Add item"
        $array += $item
    }
}

Add-Item "New Item"
Write-Host "New items count: $($array.Count)"

Что мы тут ожидаем увидеть?

Функция Add-Item должна добавить новый элемент в массив, который хранится во внешней переменной, но только если эта переменная существует и не пустая (то есть функция видит эту переменную).

На первый взгляд все ОК, но результат будет вот таким:

Old items count: 3
Add item
New items count: 3

Как мы видим, количество элементов не изменилось, хотя условие проверки видимости переменной $array сработало, то есть функция переменную $array видит.

Почему так получилось? Здесь свою роль играет область видимости переменной $array. Как вы, скорее всего знаете, в PowerShell у переменных бывают разные области видимости (Scope):

  • global - переменная видна во всей сессии PowerShell

  • script - переменная видна во всем скрипте

  • local - локальная переменная скрипт-блока.

Присвоение значения переменной, если такой переменной еще нет, это всегда создание новой переменной. При этом важно — переменная, созданная в корне скрипта (в корневом скрипт-блоке), всегда имеет область видимости script, даже если этого не указано явно. Это работает даже для параметров корневого скрипт-блока, созданных через param($myVar).

При обращении к переменной внутри функции сначала PowerShell ищет среди ее локальных переменных. Если локальная переменная не найдена, то поиск ведется среди переменных уровня script, а затем уровня global.

В нашем случае инициализация переменной $array = @("A", "B", "C") происходит в корневом скрипт-блоке, а значит ее область видимости становится script.

Далее, когда в функции Add-Item вычисляется условие if ($array) {...}, то в функции нет локальной переменной $array, а значит PowerShell найдет переменную $array уровня script, так как у них одинаковые названия. То есть, обращение к переменной по имени $array вернет переменную $script:array.

А вот дальше, пытаясь добавить новый элемент в массив $array через += мы сталкиваемся со следующим: массивы в PowerShell иммутабельны, поэтому добавление элемента массива через += это не что иное, как создание нового массива но уже с новым элементом и присвоение его переменной $array. Тем самым мы создаем новую переменную с таким же именем, но локальную для функции Add-Item и уже в нее добавляется новый элемент. Значение переменной $script:array остается без изменений.

И самое неприятное, в данном случае установка строгого режима (через Set-StrictMode -Version Latest) нас не обезопасит, так как изначально в функции Add-Item обращение идет к уже существующей переменной $array уровня script.

Для того, чтобы не попадать в подобные ситуации (да и в целом это правило хорошего тона в любом языке), никогда нельзя использовать не инициализированные в явном виде переменные, а при обращении к переменным уровня script или global необходимо указывать явно их scope.

Ниже исправленный пример:

$array = @("A", "B", "C")
Write-Host "Old items count: $($array.Count)"

function Add-Item {
    param($item)
    
    if ($script:array) {
        Write-Host "Add item"
        $script:array += $item
    }
}

Add-Item "New Item"
Write-Host "New items count: $($array.Count)"

В PowerShell порядок операндов в логических операциях может иметь значение

Простой пример:

$array = @(1, 2, $null, 3)
$isNull = $array -eq $null

Вроде все просто, переменная $array ведь не пустая, это заполненный массив, а значит переменная $isNull должна быть $false... А вот как бы не так. Переменная $isNull будет также массивом (коллекцией) с одним элементом $null. И это скорее всего не то, чего мы ожидали.

Почему так? Потому, что при сравнении значений PowerShell оценивает первый операнд чтобы определить, является ли он скаляром или коллекцией. Если это скаляр, он сравнивает операнды друг с другом как целые объекты, а если коллекция — то он выполнит итерации по всей коллекции, сравнивая каждый элемент в коллекции. И в результате будет массив с совпадающими значениями.

Поэтому в PowerShell при сравнении на равенство $null рекомендуется всегда операнд $null ставить слева.

$array = @(1, 2, $null, 3)
$isNull = $null -eq $array

Вот теперь, $isNull ожидаемо будет $false.


В PowerShell парсинг команд имеет приоритет над парсингом выражений

Простой пример:

Write-Host "A" + "B"

Мне, как человеку, больше привыкшему к другим ЯП, было очевидно, что: Write-Host это функция, которая должна вывести конкатенацию двух строк "A" и "B". То есть результат я ожидал "AB".

И конечно же я ошибся, результат будет: "A + B"

Дело в том, что PowerShell имеет 2 режима парсинга:

  • Command Mode — режим команд, где всё разбирается как аргументы команды.

  • Expression Mode — режим выражений, обычный режим языка (операторы, выражения, +, -, *, скобки, строки)

И Write-Host это не просто функция, это команда. Если строка начинается с команды — строка парсится как команда, а не как выражение. И тогда "A" + "B" рассматривается парсером не как выражение, а как параметры команды, разделенные пробелом.

Более наглядным будет отображение Abstract Syntax Tree (AST), которое формируется парсером из нашего кода.

Ast дерево
Ast дерево

Здесь мы видим, что создается нода CommandAst, имеющая 4 строковые константы StringConstantExpressionAst. Первая это название самой функции Write-Host, а вот остальные это как раз и есть то, что парсер принял за параметры команды: A, + и B. И хотя + указан без кавычек, парсер все равно посчитал его строкой (типа BareWord), а не оператором.

Чтобы парсер посчитал "A" + "B" выражением, необходимо использовать скобки.

Write-Host ("A" + "B")

Тогда результат будет ожидаемым AB.


В PowerShell объекты одного и того-же типа могут иметь разное поведение

Согласно мануалу от Microsoft, начиная с PowerShell 3.0, можно использовать ускоритель типов [ordered] для создания объектов типа [OrderedDictionary].
Попробуем создать 2 объекта этого типа, только первый через ускоритель типа [ordered], второй через создание экземпляра [OrderedDictionary] напрямую.

$dict1 = [ordered]@{}
$dict2 = [System.Collections.Specialized.OrderedDictionary]::new()

if ($dict1.GetType() -eq $dict2.GetType()) {Write-Host "Types are equal"}

$dict1['one'] = 1
$dict1['One'] = 1

$dict2['one'] = 1
$dict2['One'] = 1

В коде мы проверяем, что типы объектов идентичны. Убедимся в этом:

$dict1.GetType()
$dict2.GetType()

IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     OrderedDictionary                        System.Object

IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     OrderedDictionary                        System.Object

Зная, что все объекты в PowerShell оборачиваются в объект типа [PsObject], можно проверить свойства $dict1.PsObject и $dict2.PsObject. Но и они будут идентичны.

Но если мы выведем список элементов полученных словарей, то обнаружим, что в словаре $dict1 только 1 элемент 'One', а в словаре $dict2 два элемента — 'one' и 'One'.

Types are equal

$dict1
Name                           Value
----                           -----
One                            1

$dict2
one                            1
One                            1

То есть наглядно видно, что имея одинаковый тип у них разное поведение: $dict1 регистронезависим, а $dict2 регистрозависим. Да и по оформлению вывода содержимого объектов в консоль видно, что они разные.

И почему же так происходит? Потому,что в PowerShell слишком много "магии". На самом деле [ordered]@{} это [OrderedHashtable], который только маскируется под [OrderedDictionary], но ведёт себя иначе. А вот объект, созданный напрямую через [OrderedDictionary]::new() является почти чистым .Net объектом, обернутым в PsObject. И в нем нет регистронезависимости, принятой во всем PowerShell.


В PowerShell замыкания работают не так, как мы привыкли

Замыкания это важнейший механизм во многих современных языках программирования. Например JavaScript без замыканий и представить нельзя. В PowerShell также есть замыкания, но есть нюансы...
Например, рассмотрим следующий код:

Add-Type -AssemblyName System.Windows.Forms

function Get-Form {
    $form = New-Object System.Windows.Forms.Form
    
    $extVal = "Test Form"
    $form.Add_Shown({
        $form.Text = $extVal
    })

    $form.ShowDialog()
}

Get-Form

Как видим, в функции Get-Form мы создаем форму, подписываемся на событие отображения формы Add_Shown и показываем эту форму. В скрипт-блоке обработчика события Add_Shown мы меняем текст заголовка формы на значение из внешней (по отношению к обработчику) переменной $extVal.

Данный код выполнится корректно, замыкание на переменную $extVal работает, ура!

Теперь мы немного хотим изменить логику функции: не открывать форму сразу в функции Get-Form, а сначала просто возвращаем экземпляр формы (переменную $form).

Add-Type -AssemblyName System.Windows.Forms

function Get-Form {
    $form = New-Object System.Windows.Forms.Form
    
    $extVal = "Test Form"
    $form.Add_Shown({
        $form.Text = $extVal
    })

    return $form
}

$myForm = Get-Form
$myForm.ShowDialog()

И вот теперь мы получим ошибку, что в хэндлере события Add_Shown переменные $form и $extVal не существуют.
Почему так, ведь в первом случае все работало?

Многие, конечно, догадаются, что на самом деле никаких полноценных замыканий не было ни в первом ни во втором случае. В первом случае все работало потому, что вызов $form.ShowDialog() внутри функции Get-Form синхронный и блокирует дальнейшее выполнение кода и выхода из функции не было. Таким образом на момент сработки события Add_Shown контекст функции Get-Form еще существовал и обработчик события видел переменные из контекста функции.

А вот во втором случае функция Get-Form к моменту сработки события уже отработала и ее контекст очищен.

PowerShell по умолчанию не захватывает лексическое окружение, как привычные замыкания в других языках. Для того, чтобы замыкания в PowerShell реально работали, необходимо использовать метод GetNewClosure(). Исправим наш код:

    ...
    $form.Add_Shown({
        $form.Text = $extVal
    }.GetNewClosure())
    ...

И вот сейчас все сработает.


В PowerShell производительность может существенно падать при элементарной конкатенации строк или массивов

Конкатенация строк

Все строки в PowerShell (как и во многих других языках) являются иммутабельными. То есть при простой конкатенации "A" + "B" PowerShell на самом деле создает новую строку достаточно большой для хранения содержимого левых и правых операндов, а затем копирует элементы обоих операндов в новую строку.

На простых операциях это не заметно, но вот при больших итерациях или при работе с длинными строками это существенно влияет на производительность.

Microsoft в своей документации для повышения производительности скриптов PowerShell при работе со строками рекомендует использовать 2 подхода:

  • Хранить строки в массиве и объединять их оператором -join

  • Использовать .NET класс [StringBuilder]

Microsoft также приводит результаты замера производительности всех трех рассматриваемых подходов по объединению строк.

$tests = @{
    'Join operator' = {
        $string = @(
            foreach ($i in 0..$args[0]) {
                "Iteration $i"
            }
        ) -join "`n"
        $string
    }
    'StringBuilder' = {
        $sb = [System.Text.StringBuilder]::new()
        foreach ($i in 0..$args[0]) {
            $sb = $sb.AppendLine("Iteration $i")
        }
        $sb.ToString()
    }
    'Addition Assignment +=' = {
        $string = ''
        foreach ($i in 0..$args[0]) {
            $string += "Iteration $i`n"
        }
        $string
    }
}

При 10 тыс. итераций результат следующий:

  • оператор Join — 14.75 мс

  • класс [StringBuilder] — 62.44 мс (в 4.23 раза медленнее)

  • обычная конкатенация через оператор += — 619.64 мс (в 42 раза медленнее)

Причем падение производительности экспоненциальное. При 50 тыс. итераций производительность для обычной конкатенации существенно хуже:

  • оператор Join — 43.15 мс

  • класс [StringBuilder] — 304.32 мс (в 7 раз медленнее)

  • обычная конкатенация через оператор += — 14 225.13 мс (в 330 раз медленнее)

Конкатенация массивов (добавление элементов в массив)

С массивами та же история, что и со строками.

Несмотря на то, что почти во всех обучающих мануалах предлагается использовать для добавления элемента в массив оператор +=, это подходит только для простых случаев, а при больших массивах приведет к существенному падению производительности. Причина та же что и со строками — пересоздание нового массива каждый раз.

В качестве альтернативы Microsoft предлагает:

  • использовать явное присваивание итоговой коллекции (Explicit Assignment) — то есть сразу создавать массив заполненный нужными значениями в выражении или в цикле.

  • вместо массива использовать .Net типизированный универсальный список [System.Collections.Generic.List[T]]

Бенчмарк от Microsoft

$tests = @{
    'PowerShell Explicit Assignment' = {
        param($Count)

        $result = foreach($i in 1..$Count) {
            $i
        }
    }
    '.Add(T) to List<T>' = {
        param($Count)

        $result = [Collections.Generic.List[int]]::new()
        foreach($i in 1..$Count) {
            $result.Add($i)
        }
    }
    '+= Operator to Array' = {
        param($Count)

        $result = @()
        foreach($i in 1..$Count) {
            $result += $i
        }
    }
}

При 5 тыс. итераций результат следующий:

  • Explicit Assignment — 26.65 мс

  • [List<T>] — 110.98 мс (в 4.16 раза медленнее)

  • оператор += — 402.91 мс (в 15 раз медленнее)

Также как и в случае со строками, падение производительности экспоненциальное. При 100 тыс. итераций при использовании оператора += результат существенно хуже:

  • Explicit Assignment — 11.18 мс

  • [List<T>] — 1384.03 мс (в 124 раза медленнее)

  • оператор += — 201 991.06 мс (в 18 067 раз медленнее)

Как видим, при добавлении 100 000 элементов в массив с помощью оператора += производительность катастрофически падает до неприемлемого уровня. Все это актуально для всех версий PowerShell до версии 7.5.


Неочевидные моменты в PowerShell при работе с классами

С версии 5.0 в PowerShell появились классы и они существенно облегчили написание чистого кода и управление областями видимости.

Однако и при работе с классами есть подводные камни, которые могут усложнить реализацию проекта, если о них не знать.

В сессии PowerShell загруженные классы не могут быть выгружены или перезаписаны

В отличие от функций (команд), которые могут быть перезаписаны одна другой, если их названия совпадают, классы перезаписаны быть не могут.

Если скрипт импортирует разные модули, в которых есть классы с одинаковыми названиями, то возникнет ошибка.

Но самое неприятное во время разработки заключается в том, что после того, как вы измените код класса и перезапустите скрипт в той же сессии (как, например, делает VSCode), то с большой долей вероятности PowerShell не обновит класс и будет выполнена его старая версия. Особенно, если используются вложенные импорты, что нормально при разработке модулей с классами.

То есть в IDE вы будете видеть новую версию класса, а отладка кода будет осуществляться по старой версии, что очень сбивает с толку.

Поэтому каждый запуск скрипта после изменения кода классов должен осуществляться в новой сессии PowerShell (с перезапуском сессии). Без этого вы будете сталкиваться с необъяснимыми багами во время разработки.

PowerShell странно ведет себя с локальными переменными методов класса, совпадающими со свойствами классов

Например, есть следующий класс:

Class MyClass {
    [string]$var1

    [void]Method1() {
        $var1 = "local"
        $var2 = "local too"
        $this.var1 = $var1
    }
}

Вроде бы по синтаксису все правильно, но PowerShell выдаст ошибку и потребует использовать $this для переменной $var1. В отличие от других привычных языков, PowerShell не даст создать локальную переменную $var1, если в классе уже есть свойство с таким же именем. Но спокойно создаст локальную переменную $var2, так как она не совпадает с названиями свойств класса.

С чем связано такое поведение, я не знаю. Может кто подскажет в комментариях. Скорее всего проблема в том, как устроен механизм работы пространства имен в PowerShell и то, что это все-таки скриптовый язык, а классы это уже надстройка со своими ограничениями.

Обойти это ограничение можно указав квалифицированное имя переменной $local:var1.

В методах классов PowerShell нельзя сделать опциональные параметры, значения параметров по умолчанию будут проигнорированы

Например, если мы захотим в классе создать метод с опциональным параметром (со значением "по умолчанию"), то PowerShell позволит нам это сделать, не сообщая об ошибке.

Class MyTest {
    [void]SetVar($text = "Default value") {
        Write-Host $text
    }
}

Но если мы попытаемся вызвать этот метод без параметра:, PowerShell выдаст ошибку.


$cls = [MyTest]::new()
$cls.SetVar()

Не удается найти перегрузку для "GetVar" и количества аргументов: "0".

И даже если мы все-таки укажем в качестве параметра $null, значение "по-умолчанию" все-равно будет проигнорировано, хотя ошибки и не будет.


Заключение

Несмотря на все странности и ограничения, PowerShell остается мощным инструментом, который дает богатый функционал и отлично справляется с автоматизацией. Надеюсь,что данный материал поможет кому-то сэкономить время и избежать ошибок в коде.

Комментарии (2)


  1. Tzimie
    13.12.2025 06:44

    У меня тоже есть похожая статья. Кстати, вы знали что русская строка на PowerShell зависит от того, является ли .ps1 файл сохранённых UTF8 или 16?

    И ещё неумение печатать объекты, пресловутый object, сравнить хотя бы питоном


  1. Krinopotam Автор
    13.12.2025 06:44

    Да, спасибо, вы правы! И даже скажу более - для корректной работы скрипта MS рекомендует использовать UTF8 именно с BOM, или UTF16. Если скрипт сохранен как UTF8 без BOM, однозначно будут проблемы с русским языком и спецсимволами.