در مفهوم Vectors گفتیم که «آرایهها قلب زبان Julia هستند» و بردار یک آرایهی یکبعدی است.
با توجه به این، میتوان امیدوار بود که این زبان راههای متنوع و قدرتمند زیادی برای انجام کارها با بردارها فراهم کند، هرچه که این کارها باشند.
نکتهای دربارهی اصطلاحات: هرچند این سند زیاد دربارهی «بردارها» صحبت میکند، بخش زیادی از آن به هر نوع تکرارپذیری هم مربوط میشود: بازه، تاپل، مجموعه و موارد دیگر.
بعضی از توابع بسیار ساده یک بردار بهعنوان ورودی میگیرند و (برای ورودی یکبعدی) یک خروجی اسکالر برمیگردانند.
v = [2, 3, 4]
length(v) # => 3
sum(v) # => 9
وقتی به مفهوم آرایههای چندبعدی برسیم، روشنتر خواهد شد که این کار کاهش بُعد است و نه لزوماً برگرداندن یک اسکالر. اگر این موضوع برایتان بیمعنی است، فعلاً نگرانش نباشید.
توابع بسیار بیشتری از این نوع وجود دارد.
برای چند نمونه، مفهوم Statistics را ببینید.
توابعی هم هستند که روی چند بردار کار میکنند، مثل تابع (بسیار کاربردی) zip.
julia> z = zip( 1:3, ['a', 'b', 'c'], ["I", "make", "tuples"] )
zip([1, 2, 3], ['a', 'b', 'c'], ["I", "make", "tuples"])
# convert iterator to vector
julia> collect(z)
3-element Vector{Tuple{Int64, Char, String}}:
(1, 'a', "I")
(2, 'b', "make")
(3, 'c', "tuples")
zip() هر تعداد دلخواهی از ورودیهای بردارگونه را میگیرد و یک تکرارگر از تاپلها برمیگرداند.
معمولاً همهی ورودیها طول یکسانی دارند. اگر یکی کوتاهتر باشد، بقیه به کوتاهترین طول بریده میشوند: شاید همان چیزی باشد که در ذهن داشتید، اما معمولاً یک bug در code شماست.
فرض کنید یک بردار عددی دارید و میخواهید ۰٫۵ را از هر مقدار کم کنید.
julia> v = [1.2, 1.5, 1.7]
3-element Vector{Float64}:
1.2
1.5
1.7
julia> v - 0.5
ERROR: MethodError: no method matching -(::Vector{Float64}, ::Float64)
این کار شکست میخورد. پس کمکردن یک بردار دیگر چطور؟
julia> v - [0.5, 0.5, 0.5]
3-element Vector{Float64}:
0.7
1.0
1.2
موفقیتآمیز است، اما با بلندتر شدن بردارها خیلی خستهکننده و پرمصرف از نظر حافظه میشود.
بسته به اینکه تا کجای برنامهی درسی پیش رفتهاید، احتمالاً میتوانید روشهای دیگری هم به ذهنتان برسد:
[x - 0.5 for x in v] نتیجهی دلخواه را میدهد (به سبک Python).map(x -> x - 0.5, v) هم کار میکند (به سبک Haskell، هرچند در زبانهای زیادی رایج است).خوشبختانه، Julia یک نقطهی «جادویی» دارد که این مسئله را خیلی ساده حل میکند: v .- 0.5 تنها چیزی است که لازم دارید.
بخش بعدی توضیح میدهد چرا.
خب، v - 0.5 شکست میخورد ولی v .- 0.5 موفق میشود، و باید بفهمیم این نقطه چه کار میکند.
دو کار، که با هم ترکیب میشوند تا نتیجهی دلخواه را بدهند.
نخست، افزودن یک نقطه پیش از هر عملگر میانوندی به این معناست که «این عمل را جداگانه روی هر عنصر اعمال کن».
به همین ترتیب، افزودن یک نقطه پس از نام یک تابع آن را «برداری» میکند، حتی اگر آن تابع برای ورودیهای اسکالر نوشته شده باشد.
julia> sqrt.([1, 4, 9])
3-element Vector{Float64}:
1.0
2.0
3.0
به عنوان یک نکتهی جانبی، عملگرهای میانوندی در واقع فقط «شکر نحوی» برای تابع زیربنایی هستند.
یعنی مثلاً مفسر [1, 5, 10] .% 3 را به mod.([1, 5, 10], 3) ترجمه میکند، و سپس نحو mod. اجرا میشود (هر دو نسخه [1, 2, 1] را برمیگردانند).
در مثال قبلی دیدیم که میتوان بردارهایی با طول برابر را از هم کم کرد، اما لطفاً توجه داشته باشید که .- عملگری امنتر از - است، چون قصد عنصربهعنصر بودن را روشن میکند.
julia> v .- [0.5, 0.5, 0.5]
3-element Vector{Float64}:
0.7
1.0
1.2
بردارهایی با طول نابرابر چه؟
julia> v .- [0.5, 0.5]
ERROR: DimensionMismatch: arrays could not be broadcast to a common size
julia> v .- [0.5,]
3-element Vector{Float64}:
0.7
1.0
1.2
بهطور کلی، طولهای نابرابر خطا هستند، مگر وقتی که یکی طول ۱ داشته باشد (از نظر فنی، یک بُعد «تکعنصری»).
مقادیر تکعنصری مثل [0.5,] یا فقط 0.5 بهطور خودکار با تکرار به طول لازم بسط داده میشوند.
این موضوع در قلب broadcasting قرار دارد.
هر کسی که نگران مصرف حافظهی این «تکرار» است میتواند خیالش راحت باشد: این کار به روشی بسیار کارآمد پیادهسازی شده که در واقع مقادیر را در حافظه کپی نمیکند.
برنامهنویسانی که با پخش در زبانهای دیگر آشنا هستند باید بدانند که رویکرد Julia (اغلب) شبیه NumPy است، اما در برابر ناسازگاری اندازهها بسیار کمتحملتر از R است.
اگر مصرف حافظه دغدغهتان است، عملیات درجا روشی رایج برای کاهش تخصیصهای حافظه است.
با این حال، عملیاتهای پخش در مثالهای بالا یک Vector جدید میسازند و بردار اصلی را تغییر نمیدهند.
julia> v = [1, 2, 3]
3-element Vector{Int64}:
1
2
3
julia> v .+ 1
3-element Vector{Int64}:
2
3
4
julia> v
3-element Vector{Int64}:
1
2
3
برای تغییر درجای v، باید عمل تغییر پخش شود.
julia> v .= v .+ 1
3-element Vector{Int64}:
2
3
4
julia> v .+= 1 # equivalent operation to above
3-element Vector{Int64}:
3
4
5
julia> v
3-element Vector{Int64}:
3
4
5
اما حواستان باشد! نقطهی پیش از عملگر انتساب .= مهم است.
julia> v = [1, 2, 3];
julia> v = v .+ 1
3-element Vector{Int64}:
2
3
4
julia> v
3-element Vector{Int64}:
2
3
4
به نظر میرسد این هم به همان شکل کار کرده است، اما اینجا v .+ 1 یک بردار جدید [2, 3, 4] ساخته و بعد آن را به متغیر v نسبت داده است، و بردار اولیهی [1, 2, 3] در حافظه باقی مانده تا بهعنوان زباله جمعآوری شود.
در نتیجه این کار دو برابر حافظهای را مصرف میکند که مثال قبلی مصرف میکرد، چون آن مثال حافظهی تخصیصیافته برای بردار اولیه را دوباره به کار میگیرد.
به همین ترتیب، در پخش با بردارهایی با اندازهی یکسان هم ظرافتهای مشابهی وجود دارد.
برای مثال، دو بردار v و w با اندازهی یکسان را در نظر بگیرید:
v .= w یک copy از w را در محل حافظهی v میسازد. تغییرات بعدی در v روی w اثر نمیگذارد و برعکس.v = w یک اشارهگر دیگر به محل حافظهی w با اسم v میسازد. تغییرات بعدی در v در w منعکس میشود و برعکس.این زیربخش نسبتاً پر از ریاضی است، پس انتظار نمیرود بیشتر دانشجوها واقعاً آن را بفهمند. با این حال، هشدار مفیدی است که وقتی پیامهای خطای غیرمنتظره میبینید میتواند در debug به شما کمک کند.
julia> v = [1, 2, 3]
3-element Vector{Int64}:
1
2
3
julia> v * v
ERROR: MethodError: no method matching *(::Vector{Int64}, ::Vector{Int64})
# look, no commas
julia> u = [1 2 3]
1×3 Matrix{Int64}:
1 2 3
julia> u * v
1-element Vector{Int64}:
14
julia> v * u
3×3 Matrix{Int64}:
1 2 3
2 4 6
3 6 9
اگر تصادفاً پیشزمینهای در جبر خطی دارید، پس (۱) شما یک کاربر معمولی Exercism نیستید (اما اینجا بسیار خوشآمدید!) و (۲) ممکن است تشخیص دهید که v یک بردار ستونی است، u یک بردار سطری است، u * v ضرب داخلی است و v * u ضرب خارجی.
Julia در این مورد هم، مثل همه چیز، از قواعد ریاضیات پیروی میکند.
برای بقیه: لطفاً فقط این را بفهمید که چرا توصیه میکنیم همیشه برای محاسبات عنصربهعنصر از عملگرهای نقطهای استفاده کنید: v .* v دقیقاً همانطور که انتظار دارید کار میکند و [1, 4, 9] را میدهد.
انتخاب عناصر یک بردار با شمارهی نمایه در مفاهیم قبلی بحث شده است.
a = collect('A':'Z') # => 26-element Vector{Char}
# index with an integer
a[2] # => 'B'
# index with a range
a[12:2:18] # => ['L', 'N', 'P, 'R']
# index with another vector
a[ [1, 3, 5] ] # => ['A', 'C', 'E']
همچنین میتوان عناصری را انتخاب کرد که یک عبارت منطقی (از نظر فنی، یک «محمول») را برآورده میکنند. این کار معمولاً به پخش نیاز دارد.
julia> a[a .< 'D']
3-element Vector{Char}:
'A': ASCII/Unicode U+0041 (category Lu: Letter, uppercase)
'B': ASCII/Unicode U+0042 (category Lu: Letter, uppercase)
'C': ASCII/Unicode U+0043 (category Lu: Letter, uppercase)
در عبارتهای پیچیدهتر، نقطهها زیاد میشوند (اما کوچکاند و تایپ کردنشان آسان است).
julia> a[a .< 'D' .|| a .> 'W']
6-element Vector{Char}:
'A': ASCII/Unicode U+0041 (category Lu: Letter, uppercase)
'B': ASCII/Unicode U+0042 (category Lu: Letter, uppercase)
'C': ASCII/Unicode U+0043 (category Lu: Letter, uppercase)
'X': ASCII/Unicode U+0058 (category Lu: Letter, uppercase)
'Y': ASCII/Unicode U+0059 (category Lu: Letter, uppercase)
'Z': ASCII/Unicode U+005A (category Lu: Letter, uppercase)
یادآوری اینکه «بردار» در واقع میتواند هر تکرارپذیر مرتب و مناسب باشد، مثل یک بازه:
julia> n = 3:10
3:10
julia> n[isodd.(n)]
4-element Vector{Int64}:
3
5
7
9
در داخل، محمول به یک BitVector تبدیل میشود که سپس بهعنوان نمایه به کار میرود.
julia> condition = a .< 'D'
26-element BitVector:
1
1
1
0
# display truncated
julia> a[condition]
3-element Vector{Char}:
'A': ASCII/Unicode U+0041 (category Lu: Letter, uppercase)
'B': ASCII/Unicode U+0042 (category Lu: Letter, uppercase)
'C': ASCII/Unicode U+0043 (category Lu: Letter, uppercase)