Mert Özen Her Satırda Daha İleri
DevOps PowerShell Scripting — #02

Variable'lar, Tipler ve Koleksiyonlar: [type] Cast, Array, Hashtable, PSCustomObject, Splatting ve String'ler

Mert Özen 15 Kas 2026 18 dk 5 görüntülenme
Variable'lar, Tipler ve Koleksiyonlar: [type] Cast, Array, Hashtable, PSCustomObject, Splatting ve String'ler

PowerShell'de karşılaştırmanın sonucu soldaki değerin tipine bağlıdır ve Import-Csv her sütunu string okur; bu yüzden veriyi en başta gerçek tiplere çevirmek gerekir. Bookstore'un kitap CSV'sinden yayınevi raporu çıkarırken $null'ı sola yazma kuralını, array ve +=, List, hashtable, PSCustomObject, splatting, string'ler, Türkçe culture'da sayı ve tarih tuzaklarını ve PowerShell 7.7'de [decimal] cast'inin .NET 11 yüzünden farklı sonuç vermesini gösteriyoruz.

Sürüm notu: Bu yazıdaki örnekler PowerShell 7.6.10 (LTS) ve PowerShell 7.7.0 ile, Ubuntu 24.04 üzerinde denenmiştir; iki sürümün farklı sonuç verdiği yerler ayrıca gösterildi. Cmdlet'ler ve çıktıları Windows'ta aynıdır, yalnız yollar C:\ ile başlar ve ayırıcı \ olur. Windows PowerShell 5.1'e ait bilgiler Microsoft Learn dokümanlarından aktarıldı, çalıştırılmadı.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) katalog ekibi, kitap listesini data/books.csv olarak veriyor: ISBN (kitabın uluslararası standart numarası), başlık, yazar, yayınevi, fiyat, stok ve baskı tarihi. İlk iş en ucuz üç kitabı ve fiyatı 8'in altında olanları bulmak:

$books = Import-Csv ./data/books.csv
$books | Sort-Object Price | Select-Object -First 3 Title, Price
$books | Where-Object Price -lt 8 | Select-Object Title, Price

Listede en ucuz kitaplar 6.99 (Pride and Prejudice) ve 7.99 (Emma). İlk komut hangi üç kitabı getirir? İkincisi kaç satır döner? Bir ipucu: CSV (virgülle ayrılmış değerler) dosyasında tip diye bir şey yok.

Önceki yazıda script'in iskeletini kurduk. Bu yazıda içine giren veriye bakıyoruz: variable'lar, tipler, array, hashtable, PSCustomObject, splatting ve string'ler. Sonunda Bookstore'un kitap CSV'sinden yayınevine göre bir stok raporu çıkaran bir script yazacağız. Yol üzerinde PowerShell 7.7'nin .NET 11 ile getirdiği ve fiyat hesabını doğrudan etkileyen bir davranış değişikliğine de rastlayacağız.

Variable'lar, Tipler ve Karşılaştırmanın Sol Tarafı

Tahminin cevabı:

PS> $books = Import-Csv ./data/books.csv
PS> $books.Count
12
PS> $books | Sort-Object Price | Select-Object -First 3 Title, Price

Title                      Price
-----                      -----
The Hobbit                 10.99
Middlemarch                11.50
The Fellowship of the Ring 11.99
PS> $books | Where-Object Price -lt 8 | Select-Object Title, Price

Title                      Price
-----                      -----
Dune                       12.99
Emma                       7.99
The Hobbit                 10.99
Middlemarch                11.50
Pride and Prejudice        6.99
Crime and Punishment       17.00
Norwegian Wood             16.95
The Fellowship of the Ring 11.99
PS> $books[0].Price.GetType().FullName
System.String

Import-Csv her sütunu string olarak okur. Sort-Object Price string'leri alfabetik sıraladı: "10.99", "6.99"'dan önce gelir. Where-Object Price -lt 8 ise sekiz satır döndü ve aralarında 17.00 da var. Sebebi PowerShell'in karşılaştırma kuralı: sağdaki değer, soldakinin tipine çevrilir. Solda string olduğu için 8 de "8" string'ine dönüştü ve "17.00" -lt "8" alfabetik olarak doğru. Bu kural her şeyin temeli:

PS> $stock = 14
PS> $stock.GetType().Name
Int32
PS> $stock = 'fourteen'
PS> $stock.GetType().Name
String
PS> [int]$count = 14
PS> $count = 'fourteen'
MetadataError: Cannot convert value "fourteen" to type "System.Int32". Error: "The input string 'fourteen' was not in a correct format."
PS> $count = '15'
PS> $count.GetType().Name
Int32
PS> [decimal]'12.99' * 3
38.97
PS> [datetime]'2026-11-15' | Get-Date -Format 'yyyy-MM-dd dddd'
2026-11-15 Sunday
PS> [System.IO.Path]::Combine('releases', '1.4.2', 'Bookstore.Api.dll')
releases/1.4.2/Bookstore.Api.dll
PS> '12.99' -lt 8
True
PS> 8 -gt '12.99'
False
PS> [decimal]'12.99' -lt 8
False

PowerShell'de variable'ın tipi, içindeki değerin tipidir ve her atamada değişebilir. Variable'ı [int]$count gibi bir tiple tanımlarsan o variable'a tip kısıtı konur: her atama önce int'e çevrilmeye çalışılır, olmuyorsa hata verir. '15' string'i bu yüzden 15 sayısı olarak saklandı. Köşeli parantez .NET tiplerine de doğrudan kapı açar: [datetime], [decimal], [System.IO.Path]::Combine gibi static method'lar, C# tarafında bildiğin her şey kullanılabilir. Son üç satır karşılaştırma kuralının özeti: aynı iki değer, hangisi solda durduğuna göre farklı sonuç veriyor. Veriyi karşılaştırmadan önce tipine çevir.

Aynı kural $null karşılaştırmasını da etkiler. Solda bir array olduğunda -eq karşılaştırma yapmaz, filtre gibi çalışır ve eşleşen element'leri döndürür:

PS> $publishers = @('Ace', $null, 'Vintage', $null)
PS> ($publishers -eq $null).Count
2
PS> if ($publishers -eq $null) { 'publishers is null' } else { 'publishers has values' }
publishers is null
PS> if ($null -eq $publishers) { 'publishers is null' } else { 'publishers has values' }
publishers has values
PS> $publishers = @('Ace', $null, 'Vintage')
PS> if ($publishers -eq $null) { 'publishers is null' } else { 'publishers has values' }
publishers has values
PS> 'Dune' -eq 'DUNE'
True
PS> 'Dune' -ceq 'DUNE'
False
PS> $books = Import-Csv ./data/books.csv
PS> $books.Publisher -contains 'penguin classics'
True
PS> 'Vintage' -in $books.Publisher
True
PS> $books.Title -like '*dune*'
Dune
Children of Dune

İki $null içeren array'de $publishers -eq $null, iki element'li bir array döndürdü ve if bunu doğru saydı; tek $null kalınca dönen tek element'li array yanlış sayıldı. Aynı satır veriye göre farklı karar veriyor. $null'ı her zaman sola yaz: $null -eq $publishers, array'in kendisinin null olup olmadığını sorar. PSScriptAnalyzer da bunu PSPossibleIncorrectComparisonWithNull kuralıyla yakalıyor; serinin son yazısında açacağız. Son satırlar diğer bir varsayılanı gösteriyor: -eq, -like, -contains ve -in büyük/küçük harfe duyarsızdır. Duyarlı karşılaştırma için başlarına c koy: -ceq, -clike, -ccontains.

Array'ler: @(), += ve List

PowerShell array'i .NET'teki object[]'dir ve boyutu sabittir. += var olan array'i büyütmez; yeni bir array oluşturup variable'ı ona bağlar:

PS> $isbns = @('9780441172719', '9780141439587')
PS> $copy = $isbns
PS> $isbns += '9780547928227'
PS> "isbns: $($isbns.Count), copy: $($copy.Count)"
isbns: 3, copy: 2
PS> $isbns.GetType().Name
Object[]

$copy hâlâ eski, iki element'li array'i gösteriyor. Bu davranışın maliyeti eski bir PowerShell tavsiyesinin kaynağı: döngüde += kullanma. Microsoft'un performans dokümanı PowerShell 7.5'te +='nin iyileştirildiğini söylüyor; ne kadar iyileştiğini görmek için aynı işi üç yolla yapan küçük bir ölçüm:

foreach ($n in 10000, 20000, 40000) {
    $plus = (Measure-Command { $a = @(); foreach ($i in 1..$n) { $a += "book-$i" } }).TotalMilliseconds
    $list = (Measure-Command {
        $l = [System.Collections.Generic.List[string]]::new()
        foreach ($i in 1..$n) { $l.Add("book-$i") }
    }).TotalMilliseconds
    $direct = (Measure-Command { $r = foreach ($i in 1..$n) { "book-$i" } }).TotalMilliseconds
    '{0,6}  +=: {1,8:N0} ms  List: {2,4:N0} ms  foreach: {3,4:N0} ms' -f $n, $plus, $list, $direct
}
PS> ./lab/measure-add.ps1
 10000  +=:       49 ms  List:   18 ms  foreach:    7 ms
 20000  +=:      481 ms  List:    8 ms  foreach:    3 ms
 40000  +=:    2,052 ms  List:   18 ms  foreach:    8 ms

PowerShell 7.7.0'da sayılar aynı aralıkta: 37, 523 ve 2 146 ms. Eleman sayısı ikiye katlanınca +='nin süresi kabaca dört katına çıkıyor, List ve foreach ise neredeyse sabit. Yani 7.5'teki iyileştirme küçük array'lerde farkı kapatmış, ama birkaç bin elemanın üstünde eski tavsiye hâlâ geçerli. Dokümana göre bu iyileştirme 7.5 öncesinde, dolayısıyla Windows PowerShell 5.1'de de yok. En temiz yol üçüncüsü: döngünün çıktısını doğrudan bir variable'a atamak. Döngü boyunca biriktirip arada eleman çıkarman da gerekiyorsa generic List kullan:

PS> $l = [System.Collections.Generic.List[string]]::new()
PS> $l.Add('Dune')
PS> $l.Add(42)
PS> $l
Dune
42
PS> $l.Remove('Dune')
True
PS> $l.Count
1

Add ve Remove gibi method'lar bir değer döndürüyorsa (burada Remove'un True'su) o değer çıktıya karışır. Script içinde $null = $list.Remove('Dune') diye yutmak gerekir; function'ların çıktısını konuştuğumuz beşinci yazıda bu konuya döneceğiz. List[string]'e verilen 42 de string'e çevrildi; tip kısıtı burada da çalışıyor.

Hashtable, [ordered] ve PSCustomObject

Hashtable anahtar-değer çiftleri tutar, C#'taki Dictionary'ye benzer. Varsayılan hashtable'ın iki özelliği var: anahtarlar büyük/küçük harfe duyarsızdır ve sıra korunmaz. Sıra önemliyse [ordered] kullan:

PS> $stock = @{ Dune = 14; Emma = 3; 'The Hobbit' = 0 }
PS> $stock['emma']
3
PS> $stock.Dune
14
PS> $stock.Keys
The Hobbit
Emma
Dune
PS> $ordered = [ordered]@{ Dune = 14; Emma = 3; 'The Hobbit' = 0 }
PS> $ordered.Keys
Dune
Emma
The Hobbit
PS> $ordered[0]
14
PS> $book = [pscustomobject]@{ Isbn = '9780441172719'; Title = 'Dune'; Price = 12.99d; Stock = 14 }
PS> $book

Isbn          Title Price Stock
----          ----- ----- -----
9780441172719 Dune  12.99    14
PS> $book.Price.GetType().Name
Decimal
PS> $book | Add-Member -NotePropertyName StockValue -NotePropertyValue ($book.Price * $book.Stock)
PS> $book.StockValue
181.86
PS> $book | Get-Member -MemberType NoteProperty | Select-Object Name, Definition

Name       Definition
----       ----------
Isbn       string Isbn=9780441172719
Price      decimal Price=12.99
Stock      int Stock=14
StockValue decimal StockValue=181.86
Title      string Title=Dune

$stock['emma'], Emma anahtarını buldu. Keys eklediğimiz sırayla gelmedi, [ordered] ise sırayı korudu ve index ile de erişilebiliyor. Bir kayıt, yani "kitap" gibi sabit alanları olan bir şey için hashtable yerine [pscustomobject] kullan. Literal bir hashtable'ı [pscustomobject]'e cast etmek alan sırasını korur, sonuç tablo olarak düzgün görünür, Export-Csv ve ConvertTo-Json ile beklediğin gibi çalışır. 12.99d'deki d son eki sayıyı doğrudan decimal yapar; bu ayrıntı birazdan önemli olacak.

Splatting

Bir cmdlet'e çok sayıda parametre veriyorsan onları bir hashtable'da toplayıp @ ile geçirebilirsin. Buna splatting deniyor:

PS> $request = @{ Uri = 'http://127.0.0.1:5080/api/books'; TimeoutSec = 5; Headers = @{ Accept = 'application/json' } }
PS> $catalog = Invoke-RestMethod @request
PS> $catalog.Count
5
PS> $catalog[0].price.GetType().Name
Double
PS> $request.TimeoutSec = 2
PS> Invoke-RestMethod @request -Uri http://127.0.0.1:5098/api/books
Invoke-RestMethod: Connection refused
PS> Invoke-RestMethod $request
Invoke-RestMethod: Cannot bind parameter 'Uri'. Cannot convert the "System.Collections.Hashtable" value of type "System.Collections.Hashtable" to type "System.Uri".

Hashtable'ın anahtarları parametre adları. Değerleri koşula göre değiştirip aynı çağrıyı yeniden kullanabilirsin. PowerShell 7.1'den beri splat edilen bir parametreyi komutta açıkça yazarak ezebilirsin; -Uri satırı bunu gösteriyor. Son satır sık yapılan hatayı gösteriyor: $request yazınca hashtable tek bir değer olarak ilk positional parametreye, yani -Uri'ye gitti. Splatting için @ şart. Array ile splatting de var: $dotnetArgs = @('test', '--nologo') ve dotnet @dotnetArgs; dördüncü yazıda build script'inde kullanacağız. price'ın Double gelmesini de not et; birazdan döneceğiz.

String'ler: Tırnaklar, $(), Here-String ve -f

PS> $book = [pscustomobject]@{ Title = 'Dune'; Price = 12.99d; Stock = 14 }
PS> 'Title: $book.Title'
Title: $book.Title
PS> "Title: $book.Title"
Title: @{Title=Dune; Price=12.99; Stock=14}.Title
PS> "Title: $($book.Title), stock value: $($book.Price * $book.Stock)"
Title: Dune, stock value: 181.86
PS> '{0,-12}{1,8:N2}{2,6}' -f $book.Title, $book.Price, $book.Stock
Dune           12.99    14

Tek tırnak içindeki her şey olduğu gibi kalır. Çift tırnakta variable'lar genişletilir, ama yalnız variable adı: "$book.Title", $book'u string'e çevirip arkasına .Title yazdı. Property, method çağrısı ya da hesap için $() subexpression kullan. -f operatörü .NET'in String.Format'ıdır: {0,-12} sola yaslı 12 karakter, {1,8:N2} sağa yaslı 8 karakter ve iki ondalık. Çok satırlı metin için here-string var; @" ile "@ kendi satırlarında durmalı:

PS> Get-Content ./lab/here.ps1
$release = '1.4.2'
$notes = @"
Bookstore.Api $release
Built: $(Get-Date -Format 'yyyy-MM-dd')
Path:  C:\deploy\$release
"@
$notes
PS> ./lab/here.ps1
Bookstore.Api 1.4.2
Built: 2026-11-15
Path:  C:\deploy\1.4.2

@" genişletir, @' genişletmez. Windows yolundaki \ PowerShell'de kaçış karakteri değil (kaçış karakteri backtick); C:\deploy\$release bu yüzden sorunsuz.

Culture: Türkçe Bölge Ayarında Sayılar ve Tarihler

Okur Windows'ta çalışıyor ve çoğu Türk geliştiricinin makinesinde bölge ayarı Türkçe. Bunun script'e etkisini görmek için session'ın culture'ını tr-TR yapalım:

PS> [cultureinfo]::CurrentCulture = 'tr-TR'
PS> (Get-Culture).Name
tr-TR
PS> $price = 12.99d
PS> "interpolation: $price"
interpolation: 12.99
PS> 'format: {0:N2}' -f $price
format: 12,99
PS> "ToString: $($price.ToString())"
ToString: 12,99
PS> [decimal]'12.99'
12,99
PS> [decimal]::Parse('12.99')
1299
PS> [decimal]::Parse('12.99', [cultureinfo]::InvariantCulture)
12,99
PS> 'title' -eq 'TİTLE'
False
PS> 'title'.ToUpper()
TİTLE
PS> 'title'.ToUpperInvariant()
TITLE

Üç farklı davranış var. String interpolation ("$price") ve PowerShell cast'leri ([decimal]'12.99') culture'dan bağımsızdır, dokümandaki adıyla invariant culture kullanır. -f, ToString() ve ekrana yazılan çıktı ise o anki culture'ı kullanır, bu yüzden 12,99 oldu. En tehlikelisi .NET method'larının culture almayan sürümleri: [decimal]::Parse('12.99') Türkçe ayarda noktayı binlik ayırıcı sayıp 1299 döndürdü. Hata yok, uyarı yok, yalnız yüz kat yanlış fiyat. Son satırlar da Türkçe İ konusunu gösteriyor: -eq invariant culture ile karşılaştırdığı için 'title' -eq 'TİTLE' yanlış, ToUpper() ise Türkçe kuralla TİTLE üretiyor.

Tarihlerde durum daha da şaşırtıcı:

PS> [cultureinfo]::CurrentCulture = 'tr-TR'
PS> ([datetime]'06.05.2003').ToString('yyyy-MM-dd')
2003-06-05
PS> (Get-Date -Date '06.05.2003').ToString('yyyy-MM-dd')
2003-05-06
PS> ([datetime]'2003-05-06').ToString('yyyy-MM-dd')
2003-05-06
PS> 'title' -eq 'TITLE'
True

Aynı '06.05.2003' string'i cast ile 5 Haziran, Get-Date -Date parametresiyle 6 Mayıs oldu. Dokümana göre invariant culture ABD tarzı, ay önce gelen tarih kullanır; cmdlet'ler ise parameter binding sırasında culture'a duyarlı çeviri yapar. Script'lerde ve veri dosyalarında tarihi her zaman yyyy-MM-dd biçiminde tut; üçüncü satır iki yoldan da aynı sonucu veriyor.

Bir de sürüm farkı var. PowerShell 7.7 .NET 11 üzerinde çalışıyor ve .NET 11'in breaking change listesinde, double'dan decimal'a çevirinin artık 15 anlamlı basamakta yuvarlanmadığı yazıyor. PowerShell'in string'den [decimal]'a cast'i bu çeviriden geçtiği için aynı satır iki sürümde farklı sonuç veriyor:

PS> $PSVersionTable.PSVersion.ToString()
7.6.10
PS> [decimal]'12.99'
12.99
PS> [decimal]'6.99' * 21
146.79
PS> [decimal]::Parse('6.99', [cultureinfo]::InvariantCulture) * 21
146.79
PS> 6.99d * 21
146.79
PS> $PSVersionTable.PSVersion.ToString()
7.7.0
PS> [decimal]'12.99'
12.990000000000000213162820728
PS> [decimal]'6.99' * 21
146.79000000000000447641923529
PS> [decimal]::Parse('6.99', [cultureinfo]::InvariantCulture) * 21
146.79
PS> 6.99d * 21
146.79

7.7'deki değer, 12.99 double'ının tam karşılığı; yani cast, string'i önce double olarak okuyor ve .NET 11 aradaki yuvarlamayı kaldırdı. [decimal]::Parse ile string doğrudan decimal'a çevriliyor ve iki sürümde de doğru. Terminal ve Command Line serisinin dördüncü yazısında JSON'dan gelen double fiyatları [decimal]'a çevirmeyi önermiştik; 7.6'da bu doğru sonuç veriyor, 7.7'de ise çevirdikten sonra [math]::Round(..., 2) ile yuvarlaman gerekiyor. Para hesabında kural: string'den [decimal]::Parse(..., [cultureinfo]::InvariantCulture), literal için d son eki.

Scope'lar: $script:, $global: ve $using:

Bir function içinde script'teki bir variable'ı okuyabilirsin, ama ona atama yaptığında function kendi scope'unda aynı adla yeni bir variable oluşturur:

$outOfStock = 0

function Test-Stock($book) {
    if ($book.Stock -eq 0) { $outOfStock++ }
}

function Test-StockScript($book) {
    if ($book.Stock -eq 0) { $script:outOfStock++ }
}

$books = Import-Csv ./data/books.csv
foreach ($book in $books) { Test-Stock $book }
"after Test-Stock: $outOfStock"
foreach ($book in $books) { Test-StockScript $book }
"after Test-StockScript: $outOfStock"
PS> ./lab/scope.ps1
after Test-Stock: 0
after Test-StockScript: 1
PS> $limit = 8
PS> 1..3 | ForEach-Object -Parallel { "item $_, limit: $limit" }
item 1, limit: 
item 2, limit: 
item 3, limit: 
PS> 1..3 | ForEach-Object -Parallel { "item $_, limit: $using:limit" }
item 1, limit: 8
item 2, limit: 8
item 3, limit: 8

Test-Stock, $outOfStock'un kendi kopyasını artırdı ve script'teki sayaç 0'da kaldı. $script:outOfStock açıkça script scope'undaki variable'ı değiştirdi. $global: ise bütün session'a yazar; script bittikten sonra da kalır ve bir sonraki çalıştırmayı etkileyebilir, bu yüzden script'lerde kullanma. Daha temiz yol, function'ın sonucu döndürmesi ve sayacı çağıranın tutması; beşinci yazıda function'ları bu şekilde yazacağız.

$using: başka bir runspace'te (ayrı bir PowerShell çalışma ortamı) çalışan koda yerel bir değeri taşır. ForEach-Object -Parallel blokları ayrı runspace'lerde çalıştığı için $limit'i göremedi; $using:limit ile değer kopyalandı. Aynı şey remoting'de Invoke-Command için de geçerli. Ayrıntısını bir sonraki yazıda paralel çalışmada, remoting'i de yedinci yazıda göreceğiz.

Bookstore: Kitap CSV'sinden Yayınevi Raporu

Şimdi hepsini bir araya getirelim. CSV dosyasının başı:

Isbn,Title,Author,Publisher,Price,Stock,Published
9780441172719,Dune,Frank Herbert,Ace,12.99,14,1990-09-01
9780141439587,Emma,Jane Austen,Penguin Classics,7.99,3,2003-05-06
9780547928227,The Hobbit,J.R.R. Tolkien,Mariner,10.99,0,2012-09-18

Script, satırları bir kez ve açık bir culture ile gerçek tiplere çeviriyor, sonra yayınevine göre gruplayıp her yayınevi için bir PSCustomObject döndürüyor:

#!/usr/bin/env pwsh
#Requires -Version 7.6

<#
.SYNOPSIS
    Summarizes the Bookstore book list per publisher.

.DESCRIPTION
    Reads a book CSV (Isbn, Title, Author, Publisher, Price, Stock, Published),
    converts the columns to real types and returns one object per publisher:
    number of titles, total stock, stock value, average price and newest edition.

.PARAMETER Path
    The book CSV. Defaults to data/books.csv in the repo.

.PARAMETER OutFile
    Optional. Writes the report as a semicolon-separated CSV for Excel.

.EXAMPLE
    ./Get-BookstorePublisherReport.ps1 | Format-Table

.EXAMPLE
    ./Get-BookstorePublisherReport.ps1 -OutFile ./publisher-report.csv
#>
param(
    [string] $Path = (Join-Path -Path $PSScriptRoot -ChildPath '..', 'data', 'books.csv'),
    [string] $OutFile
)

$invariant = [cultureinfo]::InvariantCulture

# CSV sütunları string: hepsini burada, bir kez ve culture belirterek çevir.
# Fiyat doğrudan decimal olarak parse ediliyor; 7.7'de [decimal] cast double üzerinden geçiyor.
$books = foreach ($row in Import-Csv -Path $Path) {
    [pscustomobject]@{
        Isbn      = $row.Isbn
        Title     = $row.Title
        Publisher = $row.Publisher
        Price     = [decimal]::Parse($row.Price, $invariant)
        Stock     = [int]$row.Stock
        Published = [datetime]::ParseExact($row.Published, 'yyyy-MM-dd', $invariant)
    }
}

$report = foreach ($group in $books | Group-Object Publisher) {
    $stock = 0
    $stockValue = 0d
    $priceSum = 0d
    foreach ($book in $group.Group) {
        $stock += $book.Stock
        $stockValue += $book.Price * $book.Stock
        $priceSum += $book.Price
    }
    $newest = $group.Group | Sort-Object Published -Descending | Select-Object -First 1

    [pscustomobject]@{
        Publisher  = $group.Name
        Titles     = $group.Count
        Stock      = $stock
        StockValue = $stockValue
        AvgPrice   = [math]::Round($priceSum / $group.Count, 2)
        Newest     = $newest.Published.ToString('yyyy-MM-dd', $invariant)
    }
}
$report = $report | Sort-Object StockValue -Descending

if ($OutFile) {
    $csvOptions = @{
        Path      = $OutFile
        Delimiter = ';'
        Encoding  = 'utf8BOM'
        UseQuotes = 'AsNeeded'
    }
    $report | Export-Csv @csvOptions
    Write-Output "Report written to $OutFile ($(@($report).Count) publishers)"
}
else {
    $report
}

Birkaç karar var. Varsayılan dosya yolu $PSScriptRoot'a göre kuruluyor, böylece script hangi klasörden çağrılırsa çağrılsın data/books.csv'yi bulur. Para toplamları Measure-Object yerine döngüde tutuluyor, çünkü Measure-Object'in Sum ve Average sonuçları double. Script tablo basmıyor, object döndürüyor; biçimlendirme çağırana kalıyor:

PS> ./scripts/Get-BookstorePublisherReport.ps1 | Select-Object -First 2

Publisher  : Penguin Classics
Titles     : 4
Stock      : 39
StockValue : 320.67
AvgPrice   : 8.87
Newest     : 2003-05-06

Publisher  : Ace
Titles     : 2
Stock      : 19
StockValue : 231.81
AvgPrice   : 11.49
Newest     : 2008-07-01
PS> ./scripts/Get-BookstorePublisherReport.ps1 | Format-Table

Publisher        Titles Stock StockValue AvgPrice Newest
---------        ------ ----- ---------- -------- ------
Penguin Classics      4    39     320.67     8.87 2003-05-06
Ace                   2    19     231.81    11.49 2008-07-01
Signet Classic        1    18     179.82     9.99 1961-01-01
Harper Perennial      1    11     103.29     9.39 2002-01-01
Vintage               2     6     101.90    16.98 2000-09-12
Mariner               2     7      83.93    11.49 2012-09-18
PS> $report = ./scripts/Get-BookstorePublisherReport.ps1
PS> $report[0].StockValue.GetType().Name
Decimal
PS> ($report | Measure-Object Titles -Sum).Sum
12
PS> ./scripts/Get-BookstorePublisherReport.ps1 -OutFile ./publisher-report.csv
Report written to ./publisher-report.csv (6 publishers)
PS> Get-Content ./publisher-report.csv -TotalCount 3
Publisher;Titles;Stock;StockValue;AvgPrice;Newest
Penguin Classics;4;39;320.67;8.87;2003-05-06
Ace;2;19;231.81;11.49;2008-07-01

Altı property'li bir object varsayılan olarak liste görünümünde basılır (biçim tanımı olmayan object'lerde PowerShell dört property'ye kadar tablo, beş ve üstünde liste seçiyor); tablo isteyen çağıran Format-Table ekliyor. Object döndürmenin asıl kazancı üçüncü satırda: rapor bir variable'a alınıp Measure-Object ile yeniden işlenebiliyor ve StockValue hâlâ Decimal. -OutFile ise Excel için noktalı virgülle ayrılmış ve BOM'lu (başında byte order mark işareti olan) bir Unicode dosyası (UTF-8) yazıyor; Türkçe Excel'in neden bu ikisini istediğini .NET'te I/O İşlemleri serisinin CSV yazısında anlattık. Aynı komut Türkçe culture'da çalışınca:

PS> [cultureinfo]::CurrentCulture = 'tr-TR'
PS> ./scripts/Get-BookstorePublisherReport.ps1 -OutFile ./publisher-report-tr.csv
Report written to ./publisher-report-tr.csv (6 publishers)
PS> Get-Content ./publisher-report-tr.csv -TotalCount 3
Publisher;Titles;Stock;StockValue;AvgPrice;Newest
Penguin Classics;4;39;320,67;8,87;2003-05-06
Ace;2;19;231,81;11,49;2008-07-01
PS> Format-Hex ./publisher-report-tr.csv -Count 16

   Label: /home/user/bookstore/publisher-report-tr.csv

          Offset Bytes                                           Ascii
                 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
          ------ ----------------------------------------------- -----
0000000000000000 EF BB BF 50 75 62 6C 69 73 68 65 72 3B 54 69 74 Publisher;Tit

Export-Csv sayıları o anki culture ile yazdı: 320,67. Türkçe Excel için doğru, ama aynı dosyayı okuyan başka bir script için sürpriz. Dosyanın ilk üç byte'ı (EF BB BF) da BOM.

Üretimde Dikkat

Bir script'in ürettiği dosya, script'in çalıştığı makinenin culture'ına bağlı olmamalı. Gece çalışan bir rapor sunucuda İngilizce, geliştiricinin makinesinde Türkçe culture ile çalışınca iki farklı dosya üretir. Makineler arası veri için culture'dan bağımsız biçim seç: JSON, ISO (Uluslararası Standardizasyon Örgütü) 8601 tarih biçimi (yyyy-MM-dd), noktalı ondalık. İnsan için üretilen Excel dosyasında ise culture'ı bilinçli seç ve script'in başında [cultureinfo]::CurrentCulture ile sabitle.

Veriyi okurken tipini bir kez ve sınırda belirle. Raporda olduğu gibi CSV'den gelen satırları en başta gerçek tiplere çevir; script'in geri kalanı string'lerle değil, decimal, int ve datetime ile çalışsın. Böylece Önce Tahmin Et'teki sıralama ve filtreleme hataları baştan oluşmaz. Para için decimal, string'den çevirirken [decimal]::Parse kullan; 7.7'deki değişiklik, 7.6'da doğru çalışan bir script'in sessizce farklı toplam üretmesine yol açabilir.

Büyük veride bellek de önemli. Import-Csv bütün dosyayı bir array'e alırsa 1 milyon satırlık bir sipariş dosyası belleğe sığmayabilir. Satırları pipeline'da tek tek işlemek (Import-Csv ... | ForEach-Object { ... }) belleği sabit tutar; bir sonraki yazının konusu bu.

Sık Düşülen Tuzaklar

Tek elemanlı sonucun array olmaması. Bir komut tek bir sonuç döndürürse PowerShell onu array'e sarmaz:

PS> $books = Import-Csv ./data/books.csv
PS> $titles = $books | Where-Object Publisher -eq 'Signet Classic' | ForEach-Object Title
PS> $titles
1984
PS> $titles.GetType().Name
String
PS> $titles[0]
1
PS> $titles.Count
1
PS> $titles = @($books | Where-Object Publisher -eq 'Signet Classic' | ForEach-Object Title)
PS> $titles[0]
1984
PS> $none = $books | Where-Object Publisher -eq 'Tor'
PS> $null -eq $none
True
PS> @($none).Count
0

Signet Classic'in tek kitabı var, bu yüzden $titles bir array değil, "1984" string'i oldu ve $titles[0] ilk karakteri, 1'i döndürdü. Count'un 1 demesi yanıltıcı: PowerShell tek bir değere de Count verir. Sonucu array olarak kullanacaksan @() ile sar. Hiç sonuç yoksa variable $null olur, @() ile sarınca boş array.

-eq'nun array'de filtre gibi davranması. Yukarıda $null ile gördük; aynısı if ($books.Publisher -eq 'Ace') gibi satırlarda da olur. Sonuç bir array'dir ve if onu boş olup olmamasına göre değerlendirir. Tek bir değeri kontrol etmek istiyorsan -contains ya da -in kullan, bunlar her zaman True ya da False döner.

Büyük/küçük harf. -eq ve hashtable anahtarları varsayılan olarak duyarsız, C#'tan gelen biri için beklenmedik. ISBN gibi duyarsızlığın sorun olmadığı yerde rahatlık, dosya adı ya da token karşılaştırmasında ise risk. Linux'ta dosya sistemi büyük/küçük harfe duyarlı olduğu için -eq ile eşit bulduğun iki dosya adı aslında iki ayrı dosya olabilir; gerekiyorsa -ceq kullan.

Hashtable'ı tek değer olarak geçirmek. Splatting'de $ yerine @ yazılır. Invoke-RestMethod $request hata verdi, ama hashtable'ı kabul eden bir parametreye denk gelseydi hiç hata vermeden yanlış çalışabilirdi.

Küçük Bir Deneme

★☆☆ Import-Csv'den gelen kitapları [decimal]::Parse ile çevirip Önce Tahmin Et'teki iki komutu tekrar çalıştır. Sonra aynı filtreyi Where-Object { [decimal]$_.Price -lt 8 } ile yaz ve 7.6'da sonucun neden yine doğru olduğunu açıkla.

★★☆ Rapor script'ine -Culture parametresi ekle (varsayılan tr-TR); -OutFile verildiğinde dosyayı o culture ile yazsın, script bittiğinde de session'ın culture'ını eski hâline getirsin. -Culture en-US ile üretilen dosyayı Türkçe Excel'de açınca ne görürsün?

★★★ Invoke-RestMethod ile /api/books'tan gelen kitapların toplam stok değerini PowerShell 7.6 ve 7.7'de ayrı ayrı hesapla. İki sonucu karşılaştır, sonra 7.7'de de 7.6 ile aynı sonucu veren bir dönüşüm yaz. İpucu: JSON'daki fiyat zaten double olarak geliyor.

Rapor çalışıyor, ama foreach'i ve Group-Object'i şimdilik açıklamadan kullandık. Bir sonraki yazıda koşullara, döngülere ve pipeline'ı script'te kullanmaya geçiyoruz: switch, foreach ile ForEach-Object arasındaki fark, begin/process/end blokları ve Bookstore'un 200 kitap kapağını ForEach-Object -Parallel ile aynı anda kontrol etmek.